openapi: 3.1.0
info:
  title: CCA Exempt Orgs API
  version: '1.0'
  description: >-
    Permanent mirror, archive & intelligence API over IRS exempt-organization open data
    (EO Business Master File, Publication 78, auto-revocations, 1023-EZ approvals, Form 990 e-file,
    Section 527 disclosures) and linked FEC committee/contributor data.
    Every JSON response uses the fleet envelope. Mirror purity: all reads are served from the local
    D1/R2/KV mirrors — no upstream is ever called in the request path; missing data returns
    ERROR_NOT_IN_MIRROR or ERROR_NOT_FOUND.
servers:
  - url: /api/v1
security: []
tags:
  - name: service
  - name: reference
  - name: discovery
  - name: organizations
  - name: political
  - name: history
  - name: resolution
  - name: permalinks
  - name: admin
paths:
  /health:
    get:
      tags: [service]
      summary: Service banner and docs links
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /status:
    get:
      tags: [service]
      summary: Coverage, ingest cycles and history counters
      parameters:
        - { name: summary, in: query, schema: { type: boolean, default: false }, description: Return only the headline counters used by lightweight dashboards }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /sources:
    get:
      tags: [service]
      summary: Upstream source catalog merged with live cycle bookkeeping
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /taxonomy:
    get:
      tags: [reference]
      summary: Available versioned classification catalogs
      description: >-
        Lists the local NTEE and IRS EO subsection catalogs. Request handlers read the
        versioned Workers KV index and use the identical checked-in catalog as a fallback; they
        never call IRS, NCCS, or the U.S. Code at request time. Every subsection definition
        separately reports sourced candidate-campaign and lobbying rules. Section 527 political
        organizations are a separate, primarily election-focused category, not a subsection
        classification such as 501(c)(4).
      responses:
        '200': { description: Catalog manifest, content: { application/json: { schema: { $ref: '#/components/schemas/TaxonomyIndexEnvelope' } } } }
  /taxonomy/lookup:
    get:
      tags: [reference]
      summary: Resolve an NTEE code or EO subsection label through one typed lookup
      description: >-
        Deterministically recognizes NTEE codes, two-digit or unpadded IRS EO BMF subsection
        values, `501(c)(n)` citations, special `501(d)`/`501(e)`/`501(f)`/`501(k)`/`501(n)`
        citations, `529`, and `4947(a)(1)`. The returned definition is identical to the
        corresponding typed taxonomy endpoint. Missing, repeated, blank, or unrecognized code
        shapes receive the fleet `ERROR_VALIDATION` envelope.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string }, examples: { ntee: { value: R60 }, social_welfare: { value: '501(c)(4)' }, raw_subsection: { value: '04' } } }
      responses:
        '200': { description: Detected taxonomy kind and exact typed definition, content: { application/json: { schema: { $ref: '#/components/schemas/TaxonomyLookupEnvelope' } } } }
        '400': { description: 'Missing, repeated, ambiguous, or invalid query', content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Recognized code shape is not in the mirrored catalog, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /taxonomy/{catalog}:
    get:
      tags: [reference]
      summary: Complete NTEE or EO subsection code catalog
      parameters:
        - { name: catalog, in: path, required: true, schema: { type: string, enum: [ntee, subsection] } }
      responses:
        '200': { description: Versioned catalog with sources, interpretation notice, and every code definition, content: { application/json: { schema: { $ref: '#/components/schemas/TaxonomyCatalogEnvelope' } } } }
  /taxonomy/{catalog}/{code}:
    get:
      tags: [reference]
      summary: Resolve one NTEE or EO subsection code
      description: >-
        NTEE accepts a major letter, a three-character code, or a legacy four-character
        code (resolved to its three-character core definition when necessary). Subsection accepts
        the two-digit BMF value, an unpadded number, or a citation such as `501(c)(3)`.
      parameters:
        - { name: catalog, in: path, required: true, schema: { type: string, enum: [ntee, subsection] } }
        - { name: code, in: path, required: true, schema: { type: string }, example: X20 }
      responses:
        '200':
          description: Exact code definition
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaxonomyEntryEnvelope' }
              examples:
                ntee:
                  summary: Descriptive NTEE category
                  value:
                    request_status: success
                    message: OK
                    data:
                      system: NTEE
                      code: X20
                      formal_name: Christianity
                      plain_language: Organizations primarily classified in the "Christianity" NTEE category.
                      major: X
                      major_name: Religion-Related
                      broad_category: Religion-Related
                      classification_level: core_code
                      legal_effect: descriptive_only
                      legal_reference: null
                      source: nccs-ntee
                      matched_code: X20
                      specificity: exact
                      modifier: null
                    meta:
                      sources: [nccs-ntee]
                      cache: { status: miss, layer: none }
                      upstream: { status: skipped }
                      cycle: null
                      as_of: null
                      response_time_ms: 1
                subsection:
                  summary: Statutory EO subsection
                  value:
                    request_status: success
                    message: OK
                    data:
                      system: IRC
                      code: '03'
                      label: 501(c)(3)
                      formal_name: Religious, educational, charitable, scientific, literary, public-safety-testing, amateur-sports, and anti-cruelty organizations
                      plain_language: Charities and similar public-benefit organizations operated for one or more purposes listed in section 501(c)(3).
                      legal_reference: 26 U.S.C. § 501(c)(3)
                      legal_reference_url: https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title26-section501&num=0&edition=prelim
                      status: current
                      legal_effect: statutory_tax_classification
                      source: irs-publication-557
                      political_activity:
                        candidate_campaign_intervention:
                          status: prohibited
                          summary: Absolutely prohibited from participating or intervening in a political campaign for or against a candidate for public office.
                        lobbying:
                          status: limited
                          summary: No substantial part of activities may attempt to influence legislation; eligible public charities may elect the section 501(h) expenditure test.
                        caveat: The subsection code alone does not distinguish public charities from private foundations, whose lobbying rules differ.
                        sources:
                          - citation: 26 U.S.C. § 501(c)(3)
                            url: https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title26-section501&num=0&edition=prelim
                          - citation: IRS, Restriction of Political Campaign Intervention by Section 501(c)(3) Tax-Exempt Organizations
                            url: https://www.irs.gov/charities-non-profits/charitable-organizations/restriction-of-political-campaign-intervention-by-section-501c3-tax-exempt-organizations
                    meta:
                      sources: [irs-eo-subsections]
                      cache: { status: miss, layer: none }
                      upstream: { status: skipped }
                      cycle: null
                      as_of: null
                      response_time_ms: 1
        '400': { description: Invalid code shape, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Valid code is not in the mirrored catalog, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /search:
    get:
      tags: [discovery]
      summary: Omni search — EIN/domain fast paths or names/places across EO BMF and Section 527 records
      description: >-
        An EIN-shaped `q` resolves exactly. A website hostname or URL uses the indexed domain
        identifier graph; anything else runs BMF FTS5 plus the bounded
        political-organization name index with optional structured filters. Each legacy directory
        row carries
        `ntee_code`/`ntee_major` plus decoded `ntee_label`/`ntee_broad_category`,
        `ntee_definition`, `subsection_label`, and `subsection_definition`, as well as its persisted
        geography status; the compatibility `geocode` field is non-null only when usable
        coordinates exist. Missing or stale rows are geocoded through the internal CCA GEOCODE
        service binding for this response only; failures degrade per item.
        `type=poi` is a separate service-binding contract: it requires `q`, returns only organizations
        with persisted usable coordinates, caps `limit` at 20, and does not perform live geocoding.
        Its existing `subtype` remains the NTEE code and can be decoded through `/taxonomy/ntee/{code}`.
      parameters:
        - { name: q, in: query, schema: { type: string }, description: Name, website domain/URL, city, ZIP or EIN (with or without dash) }
        - { name: type, in: query, schema: { type: string, enum: [poi] }, description: Select the normalized unified-POI response contract instead of legacy directory search }
        - { name: cc, in: query, schema: { type: string, pattern: '^[A-Za-z]{2}$' }, description: Optional ISO alpha-2 country filter in POI mode; US territory codes are normalized to the corresponding US region }
        - { name: zip, in: query, schema: { type: string }, description: 5-digit or ZIP+4 filter }
        - { name: state, in: query, schema: { type: string }, description: 2-letter state/territory code; POI consumers should send the US parent country plus territory region (for example cc=US&state=PR) }
        - { name: city, in: query, schema: { type: string } }
        - { name: subsection, in: query, schema: { type: string }, description: IRS subsection code (e.g. 03 for 501(c)(3)) }
        - { name: ntee, in: query, schema: { type: string }, description: NTEE major group letter A–Z }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0, minimum: 0 }, description: Offset pagination; responses include next_offset/previous_offset }
        - { name: geocode, in: query, schema: { type: boolean, default: true }, description: Set false to skip address geocoding }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SearchEnvelope' }
              examples:
                poi:
                  summary: Unified POI result (`type=poi`)
                  value:
                    request_status: success
                    message: OK
                    data:
                      query: first baptist
                      type: poi
                      count: 1
                      results:
                        - id: cca-exemptorgs:23-7069110
                          poi_id: cca-exemptorgs:23-7069110
                          ein: 23-7069110
                          name: FIRST BAPTIST CHURCH OF EXAMPLE
                          category: church
                          subtype: X20
                          address:
                            address1: 100 MAIN ST
                            address2: null
                            locality: SPOKANE
                            region: WA
                            postal: '99201'
                            country_code: US
                            formatted: 100 MAIN ST, SPOKANE, WA 99201
                          lat: 47.6588
                          lon: -117.426
                          bbox: null
                          source: cca-exemptorgs
                          provider: osm
                          score: 0.95
                          detail_url: https://exemptorgs.datasourceapi.com/api/v1/organization/23-7069110
                          provenance:
                            organization: irs-eo-bmf
                            coordinates: osm
                            coordinate_dataset: null
                            coordinate_cycle: null
                    meta:
                      sources: [irs-eo-bmf, osm]
                      cache: { status: miss, layer: none }
                      upstream: { status: skipped }
                      cycle: 2026-07
                      as_of: null
                      count: 1
                      response_time_ms: 1
        '400': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organizations.geojson:
    get:
      tags: [discovery]
      summary: Geocoded organizations as a bare GeoJSON FeatureCollection
      description: >-
        The ONLY non-envelope endpoint — MapLibre consumes it directly. Only organizations with a
        matched row in `entity_geography` appears. Zooms below 8 return deterministic viewport
        grid clusters over the complete matching set; zooms 8+ require bbox and return points.
      parameters:
        - { name: state, in: query, schema: { type: string } }
        - { name: zip, in: query, schema: { type: string }, description: 5-digit ZIP filter }
        - { name: ntee, in: query, schema: { type: string }, description: NTEE major group letter }
        - { name: subsection, in: query, schema: { type: string } }
        - { name: q, in: query, schema: { type: string }, description: Name contains (LIKE) }
        - { name: bbox, in: query, schema: { type: string }, description: 'minLon,minLat,maxLon,maxLat' }
        - { name: zoom, in: query, schema: { type: number, default: 3, minimum: 0, maximum: 18 } }
        - { name: limit, in: query, schema: { type: integer, default: 5000, maximum: 20000 }, description: Point mode only }
      responses:
        '200': { description: GeoJSON FeatureCollection, content: { application/geo+json: { schema: { type: object } } } }
  /organization/search:
    get:
      tags: [discovery]
      summary: Alias of /search
      parameters:
        - { name: q, in: query, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/SearchEnvelope' } } } }
  /organization/nearest:
    get:
      tags: [discovery]
      summary: Organizations nearest a point (haversine over geocoded coordinates)
      description: >-
        Each returned row carries `ntee_code`/`ntee_major` plus decoded `ntee_label`/
        `ntee_broad_category`, `ntee_definition`, `subsection_label`, and
        `subsection_definition`, in addition to `distance_km`/`distance_mi`.
      parameters:
        - { name: lat, in: query, required: true, schema: { type: number } }
        - { name: lon, in: query, required: true, schema: { type: number } }
        - { name: radius, in: query, schema: { type: number }, description: Radius in MILES (max 50) }
        - { name: radius_km, in: query, schema: { type: number }, description: Radius in km (alternative to radius) }
        - { name: ntee, in: query, schema: { type: string } }
        - { name: subsection, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0, minimum: 0 } }
        - { name: geocode, in: query, schema: { type: boolean, default: true }, description: Re-geocode each returned directory address through GEOCODE }
      responses:
        '200': { description: OK — results carry distance_km and distance_mi, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}:
    get:
      tags: [organizations]
      summary: Full organization bundle
      description: >-
        BMF headline + Pub 78 + revocation + latest filings + Section 527 summary + identifiers,
        organization relationships, filing expectation, coordinates and MCI link. The returned
        geography includes precision, dataset, input hash, cycles, and persisted/live provenance.
        Missing or stale persisted geography uses a response-only GEOCODE fallback. The
        `postal_facility` block classifies the IRS address through cca-postalfacilities as an exact
        USPS facility address, PO Box host, licensed commercial mail-receiving agency, no match, or
        unavailable. Matched addresses are explicitly scoped as mail-receiving rather than proof
        of an operating location; raw DPV fields and private mailbox numbers are not exposed.
        Classification includes exact `ntee_definition` and `subsection_definition` objects.
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string }, description: EIN with or without dash }
        - { name: postal, in: query, schema: { type: boolean, default: true }, description: Set false to skip response-time postal-facility and licensed CMRA classification }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Not in mirror, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/efilings:
    get:
      tags: [organizations]
      summary: Every mirrored e-filed return (990/990-EZ/990-PF/990-T XML) for one organization
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 200 } }
        - { name: offset, in: query, schema: { type: integer, default: 0, minimum: 0 } }
        - { name: annual, in: query, schema: { type: boolean, default: false }, description: Restrict to 990/990-EZ/990-PF annual returns (exclude 990-T) }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /filing/{objectId}:
    get:
      tags: [organizations]
      summary: E-filed return metadata (viewer + XML links)
      description: >-
        The viewer offers an executive explorer and a Complete rendered return. Complete mode
        inventories every attached document and renders all main-form parts. Every official
        Form 990 schedule (A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, R) renders as its own
        authoritative, tax-year-aware facsimile in official page/part/line order and geometry
        (real Yes/No checkboxes, repeated-row tables, year-column support-test grids — never a
        generic field dump). Any unrecognized/future schedule letter or non-schedule attachment
        falls back to a humanized field/label/value facsimile. Every reported value is accounted
        for exactly once — mapped to its official line, or listed in that document's own
        "Supplemental statements" section — with no source-XML diagnostics in the rendered form
        itself.
      parameters:
        - { name: objectId, in: path, required: true, schema: { type: string }, description: IRS ObjectId of the e-filed return }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Not in mirror, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /filing/{objectId}.xml:
    get:
      tags: [organizations]
      summary: The original e-filed XML, streamed straight out of the mirrored IRS zip
      description: One R2 range read piped through DecompressionStream — the corpus is never extracted. Immutable-cached.
      parameters:
        - { name: objectId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: The IRS XML exactly as filed, content: { application/xml: { schema: { type: string } } } }
  /filing/{objectId}.pdf:
    get:
      tags: [organizations]
      summary: Download a Letter-sized PDF of the complete self-hosted return
      description: >-
        Supports 990, 990-EZ and 990-PF XML up to 5 MiB. Cold generation is rate-limited and
        persisted PDFs are edge-cached for 24 hours. For Form 990 filings whose own tax-year
        revision falls within the individually verified 2019-2025 range (see the
        `X-PDF-Renderer: official-template` response header), pages 1-12 are the ACTUAL official
        IRS Form 990 AcroForm template (public domain U.S. government work, mirrored byte-exact
        per tax-year revision) filled offline from the mirrored XML and flattened — not an HTML
        facsimile — using the same underlying value model as the interactive viewer, so the two
        presentations can never disagree on a line's value or Yes/No state. A Form 990 filing
        whose own revision falls OUTSIDE that verified range (an older or not-yet-mirrored future
        tax year) is never filled against a clamped, geometry-unverified official template —
        instead it falls back to the same revision-agnostic Browser Run facsimile 990-EZ/990-PF
        always use, visibly disclosed via `X-PDF-Renderer: browser-facsimile` (never silent, and
        never just internal storage metadata). Every attached schedule (A, B, C, D, E, F, G, H, I,
        J, K, L, M, N, O, R) follows in source order, rendered via the bound Cloudflare Browser Run
        PDF Quick Action as its own tax-year-aware official facsimile — real checkboxes and
        repeated-row tables in official geometry, never a generic dump — with a disclosed (never
        silent) fallback to the nearest supported revision for tax years outside a schedule's
        verified range. Any main-form or schedule value with no designated official line (including
        the official template's genuinely unfillable officer-signature block, or a value too long
        to fit its field even after every font-shrink/truncation attempt) appears once in that
        document's own "Supplemental statements" section, printed strictly after its official
        pages. Unrecognized/future schedule letters and non-schedule attachments use a humanized
        field/label/value facsimile. 990-EZ and 990-PF still render their entire return (main form +
        schedules) as an official-page-order HTML facsimile via Browser Run, unchanged. No synthetic
        table of contents is ever printed before page 1 (an equivalent navigation aid is
        screen-only in the interactive viewer). A missing official template for a SUPPORTED
        revision, an incomplete/incorrect field mapping, or a Browser Run failure always surfaces
        as an explicit error — there is no silent fallback to a misleading approximate rendering.
        Unsupported/large returns retain their original XML link.
      parameters:
        - { name: objectId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Rendered filing PDF, content: { application/pdf: { schema: { type: string, format: binary } } } }
        '413': { description: ERROR_TOO_LARGE — use the original XML for pathological returns }
        '429': { description: ERROR_RATE_LIMITED — cold PDF generation quota exceeded; persisted PDFs remain public }
  /fec/committee/{committeeId}:
    get:
      tags: [political]
      summary: FEC committee identity, Section 527 links, donor coverage and recent filings
      parameters:
        - { name: committeeId, in: path, required: true, schema: { type: string, pattern: '^C\d{8}$' } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Committee not in mirror, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /fec/committee/{committeeId}/contributions:
    get:
      tags: [political]
      summary: Itemized FEC receipts from one bounded R2 contribution page
      description: >-
        Donor transaction rows live only in immutable gzip JSON pages in R2; D1 stores bounded
        release/partition pointers and counts. Rows preserve amendments and memo codes. FEC
        contributor information may not be used for solicitation or commercial purposes.
      parameters:
        - { name: committeeId, in: path, required: true, schema: { type: string, pattern: '^C\d{8}$' } }
        - { name: cycle, in: query, schema: { type: string, pattern: '^\d{4}$' }, description: Defaults to latest mirrored election cycle }
        - { name: release, in: query, schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}T\d{6}Z$' }, description: Immutable release id; requires cycle }
        - { name: part, in: query, schema: { type: integer, minimum: 1, default: 1 } }
      responses:
        '200': { description: At most 5,000 contribution rows plus local filing/PDF links, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Committee, release or partition not mirrored, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /fec/filing/{fileNumber}:
    get:
      tags: [political]
      summary: FEC filing metadata with local raw/PDF availability
      parameters:
        - { name: fileNumber, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Filing not in mirror, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /fec/filing/{fileNumber}.pdf:
    get:
      tags: [political]
      summary: Digest-verified whole-report FEC PDF streamed from R2
      description: >-
        A filing PDF is shared by all contribution lines reported in that filing; FEC does not
        publish a distinct PDF for each donation. The Worker never fetches docquery.fec.gov.
      parameters:
        - { name: fileNumber, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Immutable archived filing, content: { application/pdf: { schema: { type: string, format: binary } } } }
        '404': { description: Filing or verified PDF not in mirror, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/financials:
    get:
      tags: [organizations]
      summary: 990 headline summaries, newest first
      description: Each protocol-v2 headline includes the immutable source ObjectId plus viewer and XML links.
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
        - { name: tax_year, in: query, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/filing/{tax_year}:
    get:
      tags: [organizations]
      summary: Full parsed 990 JSON from the R2 archive
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
        - { name: tax_year, in: path, required: true, schema: { type: integer } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Summary exists but full return not mirrored (ERROR_NOT_IN_MIRROR), content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/compliance:
    get:
      tags: [organizations]
      summary: Computed compliance view (deductibility, revocation, latest filing, flags)
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/identifiers:
    get:
      tags: [organizations]
      summary: Identifier graph (external ids are aliases; the EIN is canonical)
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/relationships:
    get:
      tags: [organizations]
      summary: National, chapter, booster and affiliate relationship graph
      description: >-
        Combines authoritative IRS group-exemption membership, explicitly ingested directional
        relationships and shared-domain evidence. Every edge carries type, confidence, provenance
        and evidence; name similarity alone never creates a relationship. Related organizations
        carry exact NTEE and subsection definitions when those codes are present.
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        '200': { description: Paginated related organizations, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/filing-status:
    get:
      tags: [organizations]
      summary: Explain whether and why a standalone annual 990 is expected
      description: >-
        Distinguishes mirrored returns, 990-N e-Postcard filers, church/religious exceptions,
        organizations covered by a central group return, and expected returns not yet in the mirror.
        The explanation includes the BMF and relationship evidence used.
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Filing expectation and evidence, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/political:
    get:
      tags: [political]
      summary: Section 527 identity, disclosures and linked FEC committees
      description: Served entirely from the D1/R2 mirror; pure 527 organizations need not appear in the EO BMF.
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string }, description: EIN with or without dash }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: No Section 527 record in the mirror, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/political/contributions:
    get:
      tags: [political]
      summary: IRS Form 8872 Schedule A contribution records from a bounded R2 page
      description: Original and amended reports are preserved; amounts require amendment-aware reconciliation.
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
        - { name: cycle, in: query, schema: { type: string, pattern: '^\d{4}-\d{2}$' }, description: Defaults to the current weekly snapshot }
        - { name: release, in: query, schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}T\d{6}Z$' }, description: Immutable weekly release id; requires cycle }
        - { name: part, in: query, schema: { type: integer, minimum: 1, default: 1 } }
      responses:
        '200': { description: At most 5,000 contribution rows, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Organization, release or page not mirrored, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/political/expenditures:
    get:
      tags: [political]
      summary: IRS Form 8872 Schedule B expenditure records from a bounded R2 page
      description: Original and amended reports are preserved; amounts require amendment-aware reconciliation.
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
        - { name: cycle, in: query, schema: { type: string, pattern: '^\d{4}-\d{2}$' } }
        - { name: release, in: query, schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}T\d{6}Z$' } }
        - { name: part, in: query, schema: { type: integer, minimum: 1, default: 1 } }
      responses:
        '200': { description: At most 5,000 expenditure rows, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Organization, release or page not mirrored, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /political-filing/{source}/{filingId}:
    get:
      tags: [political]
      summary: Mirrored IRS Form 8871/8872 filing metadata
      parameters:
        - { name: source, in: path, required: true, schema: { type: string, enum: [irs-8871, irs-8872, irs-8871-paper, irs-8872-paper, irs-990-paper] } }
        - { name: filingId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Filing not in mirror, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /political-filing/{source}/{filingId}.pdf:
    get:
      tags: [political]
      summary: Digest-verified IRS Form 8871/8872 PDF streamed from R2
      parameters:
        - { name: source, in: path, required: true, schema: { type: string, enum: [irs-8871, irs-8872, irs-8871-paper, irs-8872-paper, irs-990-paper] } }
        - { name: filingId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Immutable archived filing, content: { application/pdf: { schema: { type: string, format: binary } } } }
        '404': { description: Filing or verified PDF not in mirror, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/history:
    get:
      tags: [history]
      summary: Snapshot cycles + change counts per cycle
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/changes:
    get:
      tags: [history]
      summary: Field-level change log for one organization
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
        - { name: offset, in: query, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/snapshots:
    get:
      tags: [history]
      summary: List frozen per-cycle snapshots in the R2 archive
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/snapshots/{cycle}:
    get:
      tags: [history]
      summary: One frozen snapshot ({ ein, cycle, data, raw, ingested_at })
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
        - { name: cycle, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: ERROR_NOT_IN_MIRROR, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/raw:
    get:
      tags: [history]
      summary: Raw source row(s) exactly as ingested (latest snapshot)
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/raw/{cycle}:
    get:
      tags: [history]
      summary: Raw source row(s) for a specific cycle
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
        - { name: cycle, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /organization/{ein}/compare:
    get:
      tags: [history]
      summary: Structural diff of two snapshot cycles for one organization
      parameters:
        - { name: ein, in: path, required: true, schema: { type: string } }
        - { name: from, in: query, required: true, schema: { type: string } }
        - { name: to, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /changes:
    get:
      tags: [history]
      summary: Global field-level change feed
      parameters:
        - { name: cycle, in: query, schema: { type: string } }
        - { name: field, in: query, schema: { type: string } }
        - { name: since, in: query, schema: { type: string }, description: ISO date or datetime }
        - { name: until, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
        - { name: offset, in: query, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /compare:
    get:
      tags: [history]
      summary: Global cycle-over-cycle summary (records, change counts, top changed fields)
      parameters:
        - { name: from, in: query, required: true, schema: { type: string } }
        - { name: to, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /resolve/{scheme}/{value}:
    get:
      tags: [resolution]
      summary: Resolve any external identifier to the canonical organization bundle
      parameters:
        - { name: scheme, in: path, required: true, schema: { type: string }, description: 'ein, mci, hifld, osm, wikidata, state_sos, domain, …' }
        - { name: value, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: Nothing resolves, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /domain/{domain}:
    get:
      tags: [resolution]
      summary: Resolve a website domain to linked organization(s)
      description: >-
        Normalizes a hostname or URL by removing the scheme, port, path and leading `www`.
        Exact domain evidence is ranked before linked subdomains. Results retain the evidence
        confidence and provenance, include exact NTEE/subsection definitions when applicable, and
        are served only from the local identifier graph.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string }, example: pbs.org }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/DomainResolutionEnvelope' } } } }
        '404': { description: No organization has indexed evidence for the domain, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /domain:
    get:
      tags: [resolution]
      summary: Resolve a website URL or domain supplied as a query parameter
      description: Query-parameter form of `/domain/{domain}`; use this form for a complete URL.
      parameters:
        - { name: domain, in: query, required: true, schema: { type: string }, example: 'https://www.pbs.org/' }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/DomainResolutionEnvelope' } } } }
        '404': { description: No organization has indexed evidence for the domain, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /permalink:
    post:
      tags: [permalinks]
      summary: Freeze a GET route's envelope forever (citation permalink)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path]
              properties:
                path: { type: string, examples: ['/api/v1/organization/91-1234567'] }
      responses:
        '201': { description: Frozen, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /permalink/{id}:
    get:
      tags: [permalinks]
      summary: Replay a frozen envelope verbatim
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: The frozen envelope exactly as stored, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/ingest:
    post:
      tags: [admin]
      summary: Transitional push-based batch ingest
      x-admin-capability: legacy-ingest
      description: >-
        Transitional legacy route accepted by ADMIN_KEY, PIPELINE_KEY, or INGEST_KEY while clients
        move to the ingest-session protocol. Datasets: bmf (organizations, with field-level change
        detection + optional R2 snapshots),
        pub78, revocations, 1023ez, filing_summaries, identifiers, coordinates,
        organization_relationships,
        political_organizations, fec_committees, political_fec_links, political_filings,
        fec_contribution_release, fec_contribution_partitions, fec_donor_index, irs527_release and
        irs527_transaction_partitions. Max 500 index rows/call. Any R2
        pointer is rejected until archive_objects reports digest-verified status=complete.
        Legacy pub78/revocations calls are rejected; complete snapshots use
        /admin/ingest-session/* so unverified writes cannot alter accepted tagged rows. BMF legacy
        row batches remain available but never update or complete cycles; production BMF promotion
        depends on a separate validated exact-count completion protocol. For `identifiers` rows
        with `scheme: domain`, `external_id` may be a hostname or URL and is normalized to a
        lowercase hostname. `source: irs-990-websites` is rejected because exact-count IRS domain
        evidence is owned by `/admin/domains/*`. OSM website evidence is supplemental and must
        already have an explicit OSM-to-EIN link rather than a name-only match.
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [dataset, cycle]
              properties:
                dataset:
                  type: string
                  enum: [bmf, pub78, revocations, 1023ez, filing_summaries, identifiers, coordinates, organization_relationships, political_organizations, fec_committees, political_fec_links, political_filings, fec_contribution_release, fec_contribution_partitions, fec_donor_index, irs527_release, irs527_transaction_partitions]
                source: { type: string, description: Stable source id recorded in cycle bookkeeping and used as default identifier provenance }
                cycle: { type: string, examples: ['2026-07', '2026'] }
                release: { type: string, examples: ['2026-07-30T170000Z'], description: Immutable FEC or IRS 527 release id }
                phase: { type: string, enum: [begin, complete, abort, preflight, register], description: Release control, or fec_donor_index preflight/register }
                source_sha256: { type: string, pattern: '^[a-f0-9]{64}$', description: Release begin or donor-index preflight }
                source_record_count: { type: integer, minimum: 0, description: fec_donor_index preflight only }
                manifest_sha256: { type: string, pattern: '^[a-f0-9]{64}$', description: fec_donor_index preflight only }
                manifest_r2_key: { type: string, description: Digest-verified release manifest in R2_RAW, or R2_PARSED for fec_donor_index }
                rows: { type: array, items: { type: object } }
                snapshots: { type: boolean, default: false, description: 'bmf only: write per-entity R2 snapshots' }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '401': { description: ERROR_ADMIN_AUTH, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/ingest-session/begin:
    post:
      tags: [admin]
      summary: Begin or resume an exact Pub 78/revocation snapshot
      x-admin-capability: ingest-session
      description: >-
        Creates one dataset-fenced receiving session. The raw source archive key, size and SHA-256
        must exactly match archive_objects status=complete. Reusing run_id is idempotent only when
        every immutable field matches. The atomic insert also requires no newer completed source
        cycle and, when refresh_id is present, a refresh still awaiting the archive pipeline.
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [run_id, owner_id, dataset, cycle, expected_rows, expected_batches, archive]
              properties:
                run_id: { type: string, maxLength: 200 }
                owner_id: { type: string, maxLength: 200 }
                dataset: { type: string, enum: [pub78, revocations] }
                cycle: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' }
                refresh_id: { type: string, maxLength: 200 }
                expected_rows: { type: integer, minimum: 1 }
                expected_batches: { type: integer, minimum: 1 }
                archive:
                  type: object
                  required: [bucket, key, size, sha256]
                  properties:
                    bucket: { type: string, const: raw }
                    key: { type: string }
                    size: { type: integer, minimum: 0 }
                    sha256: { type: string, pattern: '^[a-f0-9]{64}$' }
      responses:
        '200': { description: Receiving or previously completed session, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '409': { description: Active-run, immutable-input or archive conflict, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/ingest-session/resume:
    post:
      tags: [admin]
      summary: Resume a run or fence a stale owner
      x-admin-capability: ingest-session
      description: >-
        The same owner refreshes its heartbeat. A different owner can take over only after fifteen
        minutes without a heartbeat; takeover increments owner_epoch and fences the prior owner.
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [run_id, owner_id]
              properties:
                run_id: { type: string }
                owner_id: { type: string }
      responses:
        '200': { description: Current owner epoch, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '409': { description: Owner heartbeat is still fresh, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/ingest-session/batch:
    post:
      tags: [admin]
      summary: Accept one digest-addressed snapshot batch
      x-admin-capability: ingest-session
      description: >-
        Body is the exact raw JSON row array hashed by X-Content-SHA256. The Python client must hash
        and send/replay the same byte buffer. Indices are contiguous and zero-based; the same
        run/index/digest is a no-op after acceptance. Batches never update cycles.
      security: [{ AdminKey: [] }]
      parameters:
        - { name: run_id, in: query, required: true, schema: { type: string } }
        - { name: batch_index, in: query, required: true, schema: { type: integer, minimum: 0 } }
        - { name: X-Ingest-Owner, in: header, required: true, schema: { type: string } }
        - { name: X-Ingest-Epoch, in: header, required: true, schema: { type: integer, minimum: 1 } }
        - { name: X-Content-SHA256, in: header, required: true, schema: { type: string, pattern: '^[a-f0-9]{64}$' } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              minItems: 1
              maxItems: 500
              items: { type: object }
      responses:
        '200': { description: Accepted or idempotently replayed, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '409': { description: Ordering, content identity or owner-epoch conflict, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/ingest-session/complete:
    post:
      tags: [admin]
      summary: Validate and finalize an exact satellite snapshot
      x-admin-capability: ingest-session
      description: >-
        Validates declared, accepted and live tagged counts plus the immutable archive identity.
        Pub 78 replacement is bounded and resumable; responses may remain finalizing until stale
        rows are gone. The terminal transition SETS exact cycle counts and accepted_run_id.
      security: [{ AdminKey: [] }]
      parameters:
        - { name: X-Ingest-Owner, in: header, required: true, schema: { type: string } }
        - { name: X-Ingest-Epoch, in: header, required: true, schema: { type: integer, minimum: 1 } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [run_id, final_row_count, final_batch_count, archive_sha256]
              properties:
                run_id: { type: string }
                final_row_count: { type: integer, minimum: 1 }
                final_batch_count: { type: integer, minimum: 1 }
                archive_sha256: { type: string, pattern: '^[a-f0-9]{64}$' }
      responses:
        '200': { description: Finalizing progress or complete snapshot, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '409': { description: Count, archive or owner mismatch, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/ingest-session/abort:
    post:
      tags: [admin]
      summary: Abort an unmodified receiving session
      x-admin-capability: ingest-session
      description: Sessions that have written any row must be resumed and completed, not aborted.
      security: [{ AdminKey: [] }]
      parameters:
        - { name: X-Ingest-Owner, in: header, required: true, schema: { type: string } }
        - { name: X-Ingest-Epoch, in: header, required: true, schema: { type: integer, minimum: 1 } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [run_id]
              properties:
                run_id: { type: string }
                reason: { type: string }
      responses:
        '200': { description: Aborted, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '409': { description: Rows already written or owner mismatch, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/ingest-session/status:
    post:
      tags: [admin]
      summary: Inspect active or recent satellite ingest sessions
      x-admin-capability: ingest-session
      security: [{ AdminKey: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                run_id: { type: string }
      responses:
        '200': { description: Session and batch progress, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/efile-release:
    post:
      tags: [admin]
      summary: Versioned, atomically promoted Form 990 e-file year release
      x-admin-capability: efile-release
      description: >-
        The single entry point for the paired `cca-exemptorgs-pipeline` release protocol
        (api "efile-release", version "2", max_batch_rows 500). One POST body carries
        `operation` and, for everything except `capabilities`, `api_version`. There is no
        legacy fallback: an unusable release fails closed.


        Release-scoped rows are AUTHORITATIVE SERVING DATA, not a staging copy. `put_batch`
        writes a year into `efile_release_index` / `efile_release_zips` /
        `efile_release_summaries`; the public read paths go through the `*_serving` views, which
        expose a release only while `efile_release_current` points at it. Promotion therefore
        copies nothing: it flips a durable pointer plus bounded release metadata in a constant
        seven-statement D1 transaction, whatever the year's size.


        `capabilities` advertises the exact contract and rejects an unsupported
        `required_version`. `status` is a resumable view of releases, the per-year current
        pointer and the bounded row-GC backlog. `begin` compare-and-swaps a release against the
        year's current release and requires the catalog, source index, and release manifest to
        already be digest-verified `complete` in `archive_objects`; an identical replay resumes,
        any changed immutable input conflicts. `put_batch` writes one `zips`/`index`/`summaries`
        batch of 1..500 rows whose `sha256` is the canonical NDJSON digest (per row: compact JSON
        with recursively sorted keys, UTF-8, plus a newline). Index rows are checked against the
        release's own ZIP inventory (byte range inside the serving ZIP, matching archive keys) and
        against cross-release ObjectId immutability at write time. Financial headlines are
        mandatory: summary count equals eligible `(EIN,tax_year)` count, extraction errors are
        zero, and every 990/990-EZ/990-PF row is checked against its same-release source ObjectId.
        Index batches transactionally materialize the distinct eligible keys, whose Worker-observed
        count must match the declared and written summary counts at preflight.
        Send all `zips`, then all `index`, then all `summaries` batches. The manifest row, every
        data row, the counters and token invalidation are ONE
        transaction whose guard is evaluated inside it, so a batch that races an abort or promote
        writes nothing at all. `preflight` re-verifies batch coverage, declared digests,
        materialized counters, ZIP archive-registry completeness and manifest identity — all
        bounded, never a corpus scan — then mints a one-use, content-bound token that any later
        write invalidates. `promote` exchanges that token for the pointer flip, superseding the
        base release and setting (never accumulating) the `irs-990-efile` cycle count for the
        year; a failed guard flips nothing and a terminal replay is idempotent. `abort` retires a
        non-current release. Rows of an aborted or superseded release are collected in bounded,
        resumable passes reported as `pending_row_gc`; a serving release's rows are never
        collected. `POST /admin/prune {"efile_release_rows": true}` drains the backlog on demand.
        `collect_legacy` requires a year whose pointer names a retained, complete current release.
        It reasserts that exact coverage guard inside every delete, removes at most 5,000 rows per
        pass and 12 passes per legacy table, and returns per-table counts plus
        `complete`/`more_work`. It deletes only hidden legacy index/ZIP rows for that release year;
        a legacy headline summary is removed only when the current release serves the exact same
        `(ein,tax_year)`. Current release rows, pointers, cycles, archive metadata, R2, and derived
        domain evidence are untouched.


        This route never writes or deletes R2.
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operation]
              properties:
                operation:
                  type: string
                  enum: [capabilities, status, begin, put_batch, preflight, promote, abort, collect_legacy]
                api_version: { type: string, const: '2', description: Required for every operation except capabilities }
                required_version: { type: string, description: capabilities only }
                release_id: { type: string, maxLength: 200 }
                year: { type: integer, minimum: 1990, maximum: 2200, description: Required by collect_legacy; optionally filters status and identifies begin coverage }
                fingerprint: { type: string, pattern: '^[a-f0-9]{64}$' }
                expected_base_release_id:
                  type: [string, 'null']
                  description: begin only — the release currently serving this year, or null
                catalog_r2_key: { type: string }
                catalog_sha256: { type: string, pattern: '^[a-f0-9]{64}$' }
                source_index_r2_key: { type: string }
                source_index_sha256: { type: string, pattern: '^[a-f0-9]{64}$' }
                manifest_r2_key: { type: string }
                manifest_sha256: { type: string, pattern: '^[a-f0-9]{64}$' }
                expected:
                  type: object
                  description: Immutable coverage declaration; counts, per-kind batch counts and per-kind batch-manifest digests
                  required: [zip_count, physical_xml_count, unique_object_count, duplicate_count, non_xml_count, index_csv_count, matched_index_count, csv_only_count, summary_count, summary_eligible_count, summary_extraction_error_count, batch_counts, digests]
                  properties:
                    zip_count: { type: integer, minimum: 1 }
                    physical_xml_count: { type: integer, minimum: 0 }
                    unique_object_count: { type: integer, minimum: 1 }
                    duplicate_count: { type: integer, minimum: 0 }
                    non_xml_count: { type: integer, minimum: 0 }
                    index_csv_count: { type: integer, minimum: 0 }
                    matched_index_count: { type: integer, minimum: 0 }
                    csv_only_count: { type: integer, minimum: 0 }
                    summary_count: { type: integer, minimum: 1 }
                    summary_eligible_count: { type: integer, minimum: 1, description: Exact distinct eligible EIN/tax-year count; must equal summary_count }
                    summary_extraction_error_count: { type: integer, minimum: 0, maximum: 0 }
                    batch_counts:
                      type: object
                      properties:
                        zips: { type: integer, minimum: 0 }
                        index: { type: integer, minimum: 0 }
                        summaries: { type: integer, minimum: 0 }
                    digests:
                      type: object
                      properties:
                        zips: { type: string, pattern: '^[a-f0-9]{64}$' }
                        index: { type: string, pattern: '^[a-f0-9]{64}$' }
                        summaries: { type: string, pattern: '^[a-f0-9]{64}$' }
                kind: { type: string, enum: [zips, index, summaries] }
                batch_index: { type: integer, minimum: 0 }
                row_count: { type: integer, minimum: 1, maximum: 500 }
                sha256: { type: string, pattern: '^[a-f0-9]{64}$', description: Canonical NDJSON digest of rows }
                rows: { type: array, minItems: 1, maxItems: 500, items: { type: object } }
                preflight_token: { type: string, pattern: '^[a-f0-9]{64}$' }
                reason: { type: string, maxLength: 1000, description: abort only; must be non-blank }
      responses:
        '200': { description: Capability, release, batch, preflight, promotion, or bounded legacy-collection result, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '400': { description: ERROR_VALIDATION — bad operation, identity, row shape or digest, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '401': { description: ERROR_ADMIN_AUTH — ADMIN_KEY or PIPELINE_KEY required, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: ERROR_NOT_FOUND — unknown release id or non-canonical route, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '409': { description: ERROR_CONFLICT — version, base-generation, archive-registry, coverage, token, or legacy-collection guard conflict, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '413': { description: ERROR_TOO_LARGE — the release manifest exceeds the bounded read limit, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/override:
    post:
      tags: [admin]
      summary: Add or deactivate a read-time field override (mirror purity)
      description: ADMIN_KEY superuser only.
      x-admin-capability: override
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/mci:
    post:
      tags: [admin]
      summary: Link EINs to CCA master canonical ids
      description: ADMIN_KEY superuser only.
      x-admin-capability: mci
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/backfill:
    post:
      tags: [admin]
      summary: Maintenance actions (fts-rebuild)
      description: ADMIN_KEY superuser only.
      x-admin-capability: backfill
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/prune:
    post:
      tags: [admin]
      summary: Prune old change_log rows, or collect a retired e-file release's rows
      description: >-
        ADMIN_KEY superuser only. `{ change_log_before: "YYYY-MM-DD" }` prunes change history.
        `{ efile_release_rows: true | "<release_id>" }` runs one bounded, resumable garbage
        collection pass over an aborted or superseded e-file release; it can never touch a
        release that any year's current pointer names, and it never touches R2. Call it
        repeatedly until `pending_row_gc` reaches zero (the same backlog is reported by
        `/api/v1/status` and by the release protocol's `status` operation).
      x-admin-capability: prune
      security: [{ AdminKey: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                change_log_before: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
                efile_release_rows:
                  oneOf:
                    - { type: boolean }
                    - { type: string, maxLength: 200 }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '409': { description: ERROR_CONFLICT — the release is still serving a year, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/cycle:
    post:
      tags: [admin]
      summary: Explicit cycle bookkeeping upsert
      description: ADMIN_KEY superuser only.
      x-admin-capability: cycle
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/cache-purge:
    post:
      tags: [admin]
      summary: Purge cached filing PDF (and optionally XML) responses after a PDF template bump
      x-admin-capability: cache-purge
      description: >-
        Accepted only by ADMIN_KEY or CACHE_PURGE_KEY.
        R2 namespaces rendered PDFs by PDF_TEMPLATE_VERSION, but the edge Workers Cache is keyed
        by request URL and does not know a new template version was deployed — a canonical
        /filing/{id}.pdf URL cached before the deploy keeps serving its old bytes until purged.
        Call this immediately after deploying a PDF_TEMPLATE_VERSION bump with the object ids you
        need fresh. Uses the Workers-native ctx.cache.purge({ pathPrefixes }), scoped to this
        Worker only: never a zone-wide purge, never deletes or modifies any R2 object. By
        default only the .pdf path is purged per id; pass include_xml to also purge the .xml
        path (safe — XML bytes are immutable, so this is only ever a transient cache miss).
      security: [{ AdminKey: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [object_ids]
              properties:
                object_ids: { type: array, items: { type: string }, maxItems: 100 }
                include_xml: { type: boolean, default: false }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/r2mpu:
    post:
      tags: [admin]
      summary: Create-once multipart R2 upload with remote SHA-256 promotion
      x-admin-capability: r2mpu
      description: >-
        Accepted only by ADMIN_KEY, PIPELINE_KEY, or ARCHIVE_KEY.
        Pipeline-only state machine: create → part(s) → complete → range read(s) → verify.
        The archive registry atomically binds bucket/key to size + SHA-256; existing permanent
        keys cannot be replaced. `op=read` returns raw bytes for post-upload digest verification.
      security: [{ AdminKey: [] }]
      parameters:
        - { name: op, in: query, required: true, schema: { type: string, enum: [status, create, part, complete, read, verify, abort] } }
        - { name: bucket, in: query, schema: { type: string, enum: [raw, parsed, snapshots], default: raw } }
        - { name: key, in: query, required: true, schema: { type: string } }
        - { name: expectedSize, in: query, schema: { type: integer, minimum: 0 }, description: Required for create }
        - { name: sha256, in: query, schema: { type: string, pattern: '^[a-f0-9]{64}$' }, description: Required for create }
        - { name: uploadId, in: query, schema: { type: string }, description: Required for part/abort }
        - { name: part, in: query, schema: { type: integer, minimum: 1 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0 }, description: Verification read offset }
        - { name: length, in: query, schema: { type: integer, minimum: 1, maximum: 99614720 }, description: Verification read length }
      responses:
        '200': { description: Envelope for control operations; application/octet-stream for read }
        '409': { description: ERROR_CONFLICT — immutable key/digest or active-lease conflict, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/geography/start:
    post:
      tags: [admin]
      summary: Start a durable full or incremental geography job
      description: Accepted only by ADMIN_KEY or GEOGRAPHY_KEY.
      x-admin-capability: geography-start
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [geocode_cycle]
              properties:
                geocode_cycle: { type: string, examples: ['2026-07-31'] }
                mode: { type: string, enum: [full, incremental], default: full }
                page_size: { type: integer, minimum: 10, maximum: 500, default: 250 }
                concurrency: { type: integer, minimum: 1, maximum: 16, default: 8 }
                max_service_attempts: { type: integer, minimum: 1, maximum: 5, default: 3 }
                job_id: { type: string, description: Optional caller-stable id for idempotent recovery }
      responses:
        '200': { description: Job created and planner queued, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/geography/status:
    post:
      tags: [admin]
      summary: Geography job, batch, and queue status
      description: Accepted only by ADMIN_KEY or GEOGRAPHY_KEY.
      x-admin-capability: geography-status
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/geography/resume:
    post:
      tags: [admin]
      summary: Resume durable planning from the persisted cursor
      description: Accepted only by ADMIN_KEY or GEOGRAPHY_KEY.
      x-admin-capability: geography-resume
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/geography/retry:
    post:
      tags: [admin]
      summary: Requeue terminal failed batches
      description: Accepted only by ADMIN_KEY or GEOGRAPHY_KEY.
      x-admin-capability: geography-retry
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/domains/start:
    post:
      tags: [admin]
      summary: Start or recover complete Form 990 website-domain indexing
      description: >-
        Accepted by ADMIN_KEY or PIPELINE_KEY. With release_year, idempotently starts that
        generation-fenced year. Without it, starts or recovers every completed IRS e-file year.
        Source XML remains inside its permanent ZIP; the dedicated queue performs one member range
        read at a time and completion requires the exact release ObjectId count. Partial evidence
        is not searchable; exact completion atomically promotes the release/algorithm generation.
      x-admin-capability: domain-start
      security: [{ AdminKey: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                release_year: { type: integer, minimum: 1990, maximum: 2200 }
                page_size: { type: integer, minimum: 1, maximum: 50, default: 50 }
                concurrency: { type: integer, minimum: 1, maximum: 16, default: 8 }
      responses:
        '200': { description: Jobs queued or recovered, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/domains/status:
    post:
      tags: [admin]
      summary: Domain index job and queue coverage
      description: Accepted by ADMIN_KEY or PIPELINE_KEY.
      x-admin-capability: domain-status
      security: [{ AdminKey: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                job_id: { type: string }
                release_year: { type: integer, minimum: 1990, maximum: 2200 }
      responses:
        '200': { description: Exact per-generation coverage and queue status, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/domains/resume:
    post:
      tags: [admin]
      summary: Resume a failed domain index job from its durable cursor
      description: Accepted by ADMIN_KEY or PIPELINE_KEY.
      x-admin-capability: domain-resume
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [job_id]
              properties:
                job_id: { type: string }
      responses:
        '200': { description: Job requeued from its persisted cursor, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/refresh/trigger:
    post:
      tags: [admin]
      summary: Queue a bounded archive-first release/freshness check
      description: Accepted only by ADMIN_KEY or GEOGRAPHY_KEY; pipeline credentials are denied.
      x-admin-capability: refresh-trigger
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/refresh/status:
    post:
      tags: [admin]
      summary: Refresh handoff status and immutable manifest pointer
      description: Accepted by ADMIN_KEY, PIPELINE_KEY, or GEOGRAPHY_KEY.
      x-admin-capability: refresh-status
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/refresh/recover:
    post:
      tags: [admin]
      summary: Queue a new immutable refresh handoff after interrupted work
      description: Accepted only by ADMIN_KEY or GEOGRAPHY_KEY; pipeline credentials are denied.
      x-admin-capability: refresh-recover
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/refresh/ack:
    post:
      tags: [admin]
      summary: Acknowledge a terminal archive-pipeline refresh manifest
      description: >-
        The bounded JSON completion manifest must itself be digest-verified in archive_objects and
        list every refresh source exactly once. Pub 78/revocations marked ingested must reference
        the currently accepted complete runs for this exact refresh; unchanged or non-session
        verified outcomes need exact digest-verified JSON evidence under
        refresh/{refresh_id}/sources/{source}/ that names the same refresh, source and outcome.
        A validating claim fences new sessions before evidence reads. Exact complete retry is
        idempotent and a different manifest conflicts.
      x-admin-capability: refresh-ack
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_id, manifest]
              properties:
                refresh_id: { type: string }
                manifest:
                  type: object
                  required: [bucket, key, size, sha256]
                  properties:
                    bucket: { type: string, const: raw }
                    key: { type: string }
                    size: { type: integer, minimum: 0, maximum: 1048576 }
                    sha256: { type: string, pattern: '^[a-f0-9]{64}$' }
      responses:
        '200': { description: Refresh completed or exact acknowledgement replayed, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '409': { description: Partial, mismatched or unverified evidence, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
components:
  securitySchemes:
    AdminKey:
      type: apiKey
      in: header
      name: X-Admin-Key
      description: >-
        Capability-scoped credential. ADMIN_KEY is the sole superuser. PIPELINE_KEY is limited to
        r2mpu, transition-only legacy ingest, exact two-segment ingest-session
        begin/resume/batch/complete/abort/status, the exact single-segment efile-release protocol,
        refresh ack, and refresh status. INGEST_KEY is
        limited to legacy ingest, those six ingest-session operations, and refresh ack. ARCHIVE_KEY
        is limited to r2mpu. GEOGRAPHY_KEY is limited to the enumerated geography
        and refresh operations. CACHE_PURGE_KEY is limited to cache-purge. Missing or invalid
        values always receive the same 401 ERROR_ADMIN_AUTH envelope. Prepared operational
        rotation/audit guidance
        lives in PRODUCTION_RUNBOOK.md under the prepared-only admin credential exposure handoff.
  schemas:
    NteeDefinition:
      type: object
      additionalProperties: false
      description: Descriptive NTEE activity category; never a legal, funding, or eligibility determination.
      required: [system, code, formal_name, plain_language, major, major_name, broad_category, classification_level, legal_effect, legal_reference, source]
      properties:
        system: { type: string, const: NTEE }
        code: { type: string, pattern: '^[A-Z](?:[0-9A-Z]{2,3})?$', description: Catalog or requested NTEE code }
        matched_code: { type: string, pattern: '^[A-Z](?:[0-9A-Z]{2,3})?$', description: Indexed code whose definition resolved the requested code }
        specificity: { type: string, enum: [exact, core_code_fallback] }
        modifier: { type: [string, 'null'], pattern: '^[0-9A-Z]$', description: Fourth-character modifier when a legacy value falls back to its core code }
        formal_name: { type: string, description: Short factual NTEE category name }
        plain_language: { type: string, description: Concise human-readable explanation of the category }
        major: { type: string, pattern: '^[A-Z]$' }
        major_name: { type: string }
        broad_category: { type: string }
        classification_level: { type: string, enum: [major_group, core_code, core_code_with_modifier, not_elsewhere_classified] }
        legal_effect: { type: string, const: descriptive_only }
        legal_reference: { type: 'null', description: NTEE is descriptive and has no statutory legal citation }
        source: { type: string, const: nccs-ntee }
    ResolvedNteeDefinition:
      allOf:
        - { $ref: '#/components/schemas/NteeDefinition' }
        - type: object
          description: NTEE definition as returned by detail lookups and taxonomy-enriched result rows.
          required: [matched_code, specificity, modifier]
    PoliticalActivitySource:
      type: object
      additionalProperties: false
      required: [citation, url]
      properties:
        citation: { type: string, description: Specific IRS, Internal Revenue Code, or Treasury-regulation citation supporting the rule summary }
        url: { type: string, format: uri, pattern: '^https://' }
    CandidateCampaignInterventionRule:
      type: object
      additionalProperties: false
      required: [status, summary]
      properties:
        status:
          type: string
          enum: [prohibited, permitted_if_not_primary, restricted, not_established, historical_not_applicable]
          description: "`not_established` means the cited authorities do not establish a category-specific blanket rule; it is not permission."
        summary: { type: string, description: Concise rule for intervention in campaigns for or against candidates for public office }
    LobbyingRule:
      type: object
      additionalProperties: false
      required: [status, summary]
      properties:
        status:
          type: string
          enum: [limited, permitted, restricted, not_established, historical_not_applicable]
          description: Status for attempts to influence legislation, kept separate from candidate campaign intervention.
        summary: { type: string, description: Concise rule for lobbying or legislative activity }
    PoliticalActivity:
      type: object
      additionalProperties: false
      required: [candidate_campaign_intervention, lobbying, caveat, sources]
      properties:
        candidate_campaign_intervention: { $ref: '#/components/schemas/CandidateCampaignInterventionRule' }
        lobbying: { $ref: '#/components/schemas/LobbyingRule' }
        caveat:
          type: [string, 'null']
          description: Category-specific qualification, including section 527(f) tax treatment or why no safer blanket rule is stated.
        sources:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/PoliticalActivitySource' }
    SubsectionDefinition:
      type: object
      additionalProperties: false
      description: IRS EO BMF subsection meaning with its governing statutory citation and separately sourced candidate-campaign and lobbying rules.
      required: [system, code, label, formal_name, plain_language, legal_reference, legal_reference_url, status, legal_effect, source, political_activity]
      properties:
        system: { type: string, const: IRC }
        code: { type: string, pattern: '^[0-9]{2}$', description: Two-digit IRS EO BMF SUBSECTION value }
        label: { type: string, description: Human-readable Internal Revenue Code section }
        formal_name: { type: string }
        plain_language: { type: string, description: Informational summary; not legal advice }
        legal_reference: { type: string }
        legal_reference_url: { type: string, format: uri }
        status: { type: string, enum: [current, repealed] }
        legal_effect: { type: string, enum: [statutory_tax_classification, historical_tax_classification] }
        source: { type: string, enum: [irs-publication-557, irs-eo-bmf-information-sheet] }
        political_activity: { $ref: '#/components/schemas/PoliticalActivity' }
    TaxonomyEnrichedRow:
      type: object
      description: Decoded classification fields on legacy organization search and domain-resolution rows.
      required: [ntee_label, ntee_broad_category, ntee_definition, subsection_label, subsection_definition]
      properties:
        ntee_code: { type: [string, 'null'] }
        ntee_major: { type: [string, 'null'] }
        ntee_label: { type: [string, 'null'], description: Compatibility label for the NTEE major group }
        ntee_broad_category: { type: [string, 'null'] }
        ntee_definition:
          oneOf:
            - { $ref: '#/components/schemas/ResolvedNteeDefinition' }
            - { type: 'null' }
        subsection: { type: [string, 'null'] }
        subsection_label: { type: [string, 'null'] }
        subsection_definition:
          oneOf:
            - { $ref: '#/components/schemas/SubsectionDefinition' }
            - { type: 'null' }
    DirectorySearchRow:
      allOf:
        - { $ref: '#/components/schemas/TaxonomyEnrichedRow' }
        - type: object
          required: [ein, name, street, city, state, zip, address, status, section_527]
          properties:
            ein: { type: string, pattern: '^[0-9]{2}-[0-9]{7}$' }
            name: { type: string }
            street: { type: [string, 'null'] }
            city: { type: [string, 'null'] }
            state: { type: [string, 'null'] }
            zip: { type: [string, 'null'] }
            address: { type: object }
            status: { type: [string, 'null'] }
            section_527: { type: boolean, description: True for the separate section 527 political-organization category; never inferred from a 501(c)(4) or other subsection code }
            matched_domain: { type: string }
            link_confidence: { type: [number, 'null'] }
            link_source: { type: [string, 'null'] }
            geography: { type: [object, 'null'] }
            geocode: { type: [object, 'null'] }
    DomainResolutionRow:
      allOf:
        - { $ref: '#/components/schemas/TaxonomyEnrichedRow' }
        - type: object
          required: [ein, name, street, city, state, zip, status, section_527, matched_domain, link_confidence, link_source]
          properties:
            ein: { type: string, pattern: '^[0-9]{2}-[0-9]{7}$' }
            name: { type: string }
            street: { type: [string, 'null'] }
            city: { type: [string, 'null'] }
            state: { type: [string, 'null'] }
            zip: { type: [string, 'null'] }
            status: { type: [string, 'null'] }
            section_527: { type: boolean, description: True for the separate section 527 political-organization category; never inferred from a 501(c)(4) or other subsection code }
            matched_domain: { type: string }
            link_confidence: { type: [number, 'null'] }
            link_source: { type: [string, 'null'] }
    Pagination:
      type: object
      additionalProperties: false
      required: [limit, offset, returned, page, has_more, next_offset, previous_offset]
      properties:
        limit: { type: integer, minimum: 1 }
        offset: { type: integer, minimum: 0 }
        returned: { type: integer, minimum: 0 }
        page: { type: integer, minimum: 1 }
        has_more: { type: boolean }
        next_offset: { type: [integer, 'null'], minimum: 0 }
        previous_offset: { type: [integer, 'null'], minimum: 0 }
    DirectorySearchData:
      type: object
      required: [query, match, count, results, pagination]
      properties:
        query: { type: [string, 'null'] }
        domain: { type: string }
        match: { type: string, enum: [ein, domain, zip, fts, filter] }
        count: { type: integer, minimum: 0 }
        results:
          type: array
          items: { $ref: '#/components/schemas/DirectorySearchRow' }
        pagination: { $ref: '#/components/schemas/Pagination' }
        geocoding: { type: object }
    PoiSearchData:
      type: object
      required: [query, type, count, results]
      properties:
        query: { type: string }
        type: { type: string, const: poi }
        count: { type: integer, minimum: 0 }
        results: { type: array, items: { type: object } }
    DomainResolutionData:
      type: object
      required: [query, domain, count, results, pagination]
      properties:
        query: { type: string }
        domain: { type: string }
        count: { type: integer, minimum: 0 }
        results:
          type: array
          items: { $ref: '#/components/schemas/DomainResolutionRow' }
        pagination: { $ref: '#/components/schemas/Pagination' }
    SearchEnvelope:
      allOf:
        - { $ref: '#/components/schemas/Envelope' }
        - type: object
          properties:
            data:
              oneOf:
                - { $ref: '#/components/schemas/DirectorySearchData' }
                - { $ref: '#/components/schemas/PoiSearchData' }
    DomainResolutionEnvelope:
      allOf:
        - { $ref: '#/components/schemas/Envelope' }
        - { type: object, properties: { data: { $ref: '#/components/schemas/DomainResolutionData' } } }
    TaxonomyIndex:
      type: object
      required: [version, catalogs]
      properties:
        version: { type: string }
        catalogs:
          type: array
          items:
            type: object
            required: [id, count, catalog_url, lookup_url_template]
            properties:
              id: { type: string, enum: [ntee, subsection] }
              count: { type: integer }
              catalog_url: { type: string }
              lookup_url_template: { type: string }
    TaxonomyCatalog:
      type: object
      required: [id, version, title, notice, count, sources, entries]
      properties:
        id: { type: string, enum: [ntee, subsection] }
        version: { type: string }
        title: { type: string }
        notice: { type: string }
        count: { type: integer }
        sources: { type: array, items: { type: object } }
        entries:
          type: array
          items:
            oneOf:
              - { $ref: '#/components/schemas/NteeDefinition' }
              - { $ref: '#/components/schemas/SubsectionDefinition' }
    TaxonomyLookup:
      type: object
      additionalProperties: false
      required: [query, kind, normalized_code, definition]
      properties:
        query: { type: string, description: Trimmed lookup value supplied by the caller }
        kind: { type: string, enum: [ntee, subsection] }
        normalized_code: { type: string, pattern: '^(?:[A-Z](?:[0-9A-Z]{2,3})?|[0-9]{2})$' }
        definition:
          oneOf:
            - { $ref: '#/components/schemas/ResolvedNteeDefinition' }
            - { $ref: '#/components/schemas/SubsectionDefinition' }
    TaxonomyIndexEnvelope:
      allOf:
        - { $ref: '#/components/schemas/Envelope' }
        - { type: object, properties: { data: { $ref: '#/components/schemas/TaxonomyIndex' } } }
    TaxonomyCatalogEnvelope:
      allOf:
        - { $ref: '#/components/schemas/Envelope' }
        - { type: object, properties: { data: { $ref: '#/components/schemas/TaxonomyCatalog' } } }
    TaxonomyLookupEnvelope:
      allOf:
        - { $ref: '#/components/schemas/Envelope' }
        - { type: object, properties: { data: { $ref: '#/components/schemas/TaxonomyLookup' } } }
    TaxonomyEntryEnvelope:
      allOf:
        - { $ref: '#/components/schemas/Envelope' }
        - type: object
          properties:
            data:
              oneOf:
                - { $ref: '#/components/schemas/ResolvedNteeDefinition' }
                - { $ref: '#/components/schemas/SubsectionDefinition' }
    Envelope:
      type: object
      description: Fleet response envelope — identical across all CCA data services.
      required: [request_status, message, data, meta]
      properties:
        request_status: { type: string, enum: [success, error] }
        message: { type: string }
        error_code:
          type: [string, 'null']
          enum: [ERROR_NOT_FOUND, ERROR_NOT_IN_MIRROR, ERROR_VALIDATION, ERROR_CONFLICT, ERROR_TOO_LARGE, ERROR_RATE_LIMITED, ERROR_ADMIN_AUTH, ERROR_INTERNAL, null]
        data: {}
        meta:
          type: object
          properties:
            sources: { type: array, items: { type: string } }
            cache:
              type: object
              properties:
                status: { type: string, enum: [hit, miss, stale, bypass] }
                layer: { type: string }
            upstream:
              type: object
              properties:
                status: { type: string, enum: [skipped, ok, error] }
            cycle: { type: [string, 'null'], description: 'Source cycle, e.g. IRS 2026-07 or FEC 2026' }
            as_of: { type: [string, 'null'] }
            ein: { type: string }
            response_time_ms: { type: integer }
