openapi: 3.1.0
info:
  title: CCA Religious Orgs API
  version: '1.0'
  description: >-
    Permanent mirror & intelligence API over US religious congregation, denomination and
    religious-building directory data, plus a first-class SOURCED, VERSIONED religions &
    traditions reference graph (religion/family/denomination/tradition/movement/rite/school/
    sect/interfaith) and ZIP/address/place geo intelligence (/nearby, /area, /stats/zip). Every
    JSON response uses the fleet envelope except /congregations.geojson and /buildings.geojson
    (bare FeatureCollections for MapLibre). Mirror purity: OWNED data (congregations, buildings,
    the traditions graph, stats) is always served from the local D1/R2 mirror — no upstream is
    ever called in the request path; missing data returns ERROR_NOT_IN_MIRROR or ERROR_NOT_FOUND.
    This worker owns per-entity administrative geography derived offline by exact containment of
    mirrored points in immutable archived Census boundaries (state/county/ZCTA/place, with
    precision/confidence/cycle/field provenance). It never stores boundary geometry, treats a ZCTA
    as an exact postal ZIP, or provides a general geocoder/Census hierarchy/demographics copy.
    Tax/financial data is NEVER stored here either: congregation bundles delegate to
    cca-exemptorgs at read time via an EIN cross-reference (data.tax is null when no EIN is
    linked or the delegation is unavailable); address/ZIP/place resolution delegates to
    cca-geocode (GEOCODE); Census geography (county/CBSA/tract/ZCTA) delegates to cca-community
    (COMMUNITY). Every fleet delegation degrades to null/a note on ANY failure — it never turns a
    valid local response into a 500. The traditions graph is SOURCED, CONTESTABLE classification,
    never asserted as universal truth: every claim carries a confidence + source, self-description
    is distinguished from historical/academic description and from machine classification, and
    every mutation is history-preserving (change_log + optional snapshots) — see CLAUDE.md.
    Directory records derived from OpenStreetMap are © OpenStreetMap contributors and available
    under ODbL 1.0; bulk GeoJSON responses include X-Data-License and rel=license headers.
servers:
  - url: /api/v1
security: []
tags:
  - name: service
  - name: discovery
  - name: integrations
  - name: congregations
  - name: buildings
  - name: religions
  - name: denominations
  - name: stats
  - 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
      description: Includes separate source and effective derived state/county/postal/ZCTA/city coverage, unmatched/invalid counts, immutable boundary archive manifests, latest source cycles, and latest refresh-run status.
      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, plus the delegated fleet services
      description: >-
        `data.sources` is the OWNED-data ingest catalog (osm, lds-api, denomination-directories,
        rcms, irs-bmf-x-codes). `data.delegated_services` separately lists the fleet service
        bindings called live at read time (EXEMPTORGS for tax data, GEOCODE for arbitrary
        address/ZIP/place resolution, COMMUNITY for the full Census hierarchy/demographics).
        Census boundary files used for per-entity derived labels are an owned archived source.
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /search:
    get:
      tags: [discovery]
      summary: Omni search — congregation-id, website-domain and exact-ZIP structured search, or FTS over names/places
      description: >-
        A congregation-id-shaped `q` (ro-xxxxxxxx) resolves exactly. A domain or URL in `q` (or
        the explicit `?domain=` filter) searches normalized `cross_refs` domain aliases without
        calling an upstream service. Domain aliases are sourced from mirrored congregation website
        fields (including OSM `contact:website`/`website` and denomination directories) or explicit
        cross-reference ingest; 990/tax data is not copied into this worker. A ZIP or ZIP+4 in `q` (or
        the explicit `?zip=`) runs a structured, prefix-matched ZIP search over congregations AND
        buildings instead of FTS. Derived geography is preferred for state/city/postal filtering
        and each congregation/building result includes its geography provenance. Anything else runs FTS5 with optional structured filters.
        Building and tradition matches are returned separately in `building_results`/
        `tradition_results` alongside the congregation `results`, each carrying a `type`.
      parameters:
        - { name: q, in: query, schema: { type: string }, description: Name, city, congregation id, website/domain/URL, ZIP, or ZIP+4 }
        - { name: domain, in: query, schema: { type: string }, description: 'Explicit website domain or URL filter, composable with congregation filters' }
        - { name: zip, in: query, schema: { type: string }, description: 'Explicit ZIP filter (5-digit or ZIP+4), prefix-matched, composable with other filters' }
        - { name: religion, in: query, schema: { type: string }, description: Canonical religion code (e.g. christianity, islam, judaism) }
        - { name: denomination, in: query, schema: { type: string } }
        - { name: tradition, in: query, schema: { type: string }, description: Tradition id or slug (via congregation_traditions); religion/family entries roll up their subtree }
        - { name: state, in: query, schema: { type: string }, description: 2-letter state code }
        - { name: city, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '400': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /nearby:
    get:
      tags: [discovery]
      summary: Congregations AND buildings near a resolved place, interleaved by distance
      description: >-
        Resolves ?lat=&lon= directly, or ?zip=/?address=/?q= delegated to cca-geocode via
        GEOCODE — degrading to ERROR_VALIDATION (never a 500) if nothing resolves. Local
        aggregation (bbox + haversine over congregations and buildings) is a pure mirror read;
        each result preserves the legacy `id`, `name`, `city`, `state`, `zip`, `lat`, `lon`,
        `type`, `entity_type`, and distance fields while additively carrying stable typed identity,
        canonical/display name, street/address, coordinate precision/source, contact/source fields,
        and congregation EIN/cross-reference aliases. `identity_keys` declares the canonical typed
        aliases: `religious_org_id` for congregations and `building_id` for buildings. Mapped points
        are validated and postal/place centroids are excluded. By default, coordinate-less rows
        remain omitted exactly as before; explicitly setting `geocode_limit` above zero enables the
        same bounded, full-street-address GEOCODE fallback used by `/community/nearby` for
        congregations only.
      parameters:
        - { name: lat, in: query, schema: { type: number } }
        - { name: lon, in: query, schema: { type: number } }
        - { name: zip, in: query, schema: { type: string } }
        - { name: address, in: query, schema: { type: string } }
        - { name: q, in: query, schema: { type: string }, description: Free-form place text (ZIP, address, or "City, ST") }
        - { name: radius, in: query, schema: { type: number }, description: Radius in MILES (max 50) }
        - { name: radius_km, in: query, schema: { type: number } }
        - { name: religion, in: query, schema: { type: string } }
        - { name: denomination, in: query, schema: { type: string } }
        - { name: tradition, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: geocode_limit, in: query, schema: { type: integer, default: 0, minimum: 0, maximum: 25 }, description: Opt-in maximum full-address geocodes for coordinate-less congregations; omitted/0 preserves legacy behavior }
      responses:
        '200': { description: Stable mixed congregation/building results, content: { application/json: { schema: { $ref: '#/components/schemas/NearbyEnvelope' } } } }
        '400': { description: Could not resolve a place, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /community/nearby:
    get:
      tags: [integrations]
      summary: Bounded congregation identity records near a point for cca-community
      description: >-
        The service-binding contract for cca-community. Returns congregation records only (never
        building records), each keyed by the permanent `religious_org_id` (`ro-*`) and carrying
        normalized/display names, complete stored address fields, EIN and other aliases, sourced
        tradition links, contact fields, distance, and coordinate provenance. Existing source
        points are validated and postal/ZCTA/place centroids are excluded. Coordinate-less rows
        in the resolved ZIP may be geocoded by full street address through the GEOCODE service
        binding, under the `geocode_limit` cap; ZIP/ZCTA results from that lookup are rejected and
        never emitted as organization points. A failed/unbound GEOCODE binding leaves valid local
        results intact. Candidate geocoding also has a five-second request-wide budget. Radius is
        capped at 50 miles, results at 100, and address geocodes at 25.
      parameters:
        - { name: lat, in: query, schema: { type: number }, description: Origin latitude; provide with lon }
        - { name: lon, in: query, schema: { type: number }, description: Origin longitude; provide with lat }
        - { name: zip, in: query, schema: { type: string }, description: Origin/fallback ZIP; when supplied with lat/lon it avoids a reverse-geocode lookup }
        - { name: address, in: query, schema: { type: string }, description: Alternative origin resolved through GEOCODE }
        - { name: q, in: query, schema: { type: string }, description: Alternative free-form origin resolved through GEOCODE }
        - { name: radius, in: query, schema: { type: number, maximum: 50 }, description: Radius in miles }
        - { name: radius_km, in: query, schema: { type: number, maximum: 80.4672 }, description: Radius in kilometers }
        - { name: religion, in: query, schema: { type: string } }
        - { name: denomination, in: query, schema: { type: string } }
        - { name: tradition, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 25, minimum: 1, maximum: 100 } }
        - { name: geocode_limit, in: query, schema: { type: integer, default: 10, minimum: 0, maximum: 25 }, description: Maximum full-address fallback geocodes; 0 disables fallback }
      responses:
        '200':
          description: Stable congregation identity and matching records
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommunityNearbyEnvelope' }
        '400': { description: Could not resolve a place or invalid bounds, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /area:
    get:
      tags: [discovery]
      summary: Resolved place intelligence — origin + local counts/breakdown + Census geography context
      description: >-
        Resolves an origin the same way as /nearby, then returns local congregation/building
        counts with a religion (scalar) and tradition (congregation_traditions graph) breakdown
        (pure mirror reads), plus Census geography (county/CBSA/tract/ZCTA) via cca-community.
        If COMMUNITY fails or is unbound, `community` is null with a note in `notes` — the origin
        and local counts still succeed, never a 500. `?include=data` forwards to COMMUNITY for the
        optional tract-level demographic bundle.
      parameters:
        - { name: lat, in: query, schema: { type: number } }
        - { name: lon, in: query, schema: { type: number } }
        - { name: zip, in: query, schema: { type: string } }
        - { name: address, in: query, schema: { type: string } }
        - { name: q, in: query, schema: { type: string } }
        - { name: radius, in: query, schema: { type: number }, description: Radius in MILES (max 50) }
        - { name: radius_km, in: query, schema: { type: number } }
        - { name: include, in: query, schema: { type: string, enum: [data] }, description: Forward to COMMUNITY for optional tract demographics }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '400': { description: Could not resolve an area, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /congregations.geojson:
    get:
      tags: [discovery]
      summary: Viewport-aware congregation clusters or points as a bare GeoJSON FeatureCollection
      description: >-
        One of two non-envelope endpoints — MapLibre consumes it directly. At zooms below 8,
        deterministic server-side grid clusters cover the complete matching dataset (nationally
        at zoom 0-4, viewport-scoped at zoom 5-7). At zoom 8+, bbox is required and actual points
        are returned in stable id order with a 5,000-point safety limit. FeatureCollection members
        mode, total_matching, visible_matching, returned, truncated, zoom and bbox describe the
        response. The collection reports derived/fallback geography provenance and point features
        include per-entity provenance. OSM-derived rows are © OpenStreetMap contributors under ODbL 1.0; the response
        includes X-Data-License and rel=license headers.
      parameters:
        - { name: religion, in: query, schema: { type: string } }
        - { name: denomination, in: query, schema: { type: string } }
        - { name: state, in: query, schema: { type: string } }
        - { name: zip, in: query, schema: { type: string }, description: Prefix-matched ZIP filter }
        - { name: bbox, in: query, schema: { type: string }, description: 'minLon,minLat,maxLon,maxLat' }
        - { name: zoom, in: query, schema: { type: number, default: 0, minimum: 0, maximum: 24 } }
        - { name: limit, in: query, schema: { type: integer, default: 2000, maximum: 5000 }, description: High-zoom actual-point safety limit only }
      responses:
        '200': { description: GeoJSON FeatureCollection, content: { application/geo+json: { schema: { type: object } } } }
  /buildings.geojson:
    get:
      tags: [buildings]
      summary: Viewport-aware religious-building clusters or points as a bare GeoJSON FeatureCollection
      description: >-
        At zooms below 8 this bare FeatureCollection contains deterministic grid clusters; at zoom
        8+ bbox is required and actual points are returned in stable id order with explicit
        truncation metadata. OSM-derived rows are © OpenStreetMap contributors under ODbL 1.0.
      parameters:
        - { name: state, in: query, schema: { type: string } }
        - { name: building_type, in: query, schema: { type: string } }
        - { name: zip, in: query, schema: { type: string }, description: Prefix-matched ZIP filter }
        - { name: bbox, in: query, schema: { type: string }, description: 'minLon,minLat,maxLon,maxLat' }
        - { name: zoom, in: query, schema: { type: number, default: 0, minimum: 0, maximum: 24 } }
        - { name: limit, in: query, schema: { type: integer, default: 2000, maximum: 5000 }, description: High-zoom actual-point safety limit only }
      responses:
        '200': { description: GeoJSON FeatureCollection, content: { application/geo+json: { schema: { type: object } } } }
  /congregation/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/Envelope' } } } }
  /congregation/nearest:
    get:
      tags: [discovery]
      summary: Congregations nearest a point (haversine over lat/lon), or nearest a ZIP/address/place
      description: >-
        Provide ?lat=&lon= directly, or ?zip=/?address=/?q= to geocode via the fleet GEOCODE
        binding (cca-geocode). A geocode delegation failure returns ERROR_VALIDATION rather than
        a 500. `origin` in the response is the same rich `{input,label,precision,source,lat,lon}`
        shape used by /nearby and /area.
      parameters:
        - { name: lat, in: query, schema: { type: number } }
        - { name: lon, in: query, schema: { type: number } }
        - { name: zip, in: query, schema: { type: string }, description: Alternative to lat/lon — geocoded via GEOCODE }
        - { name: address, in: query, schema: { type: string }, description: Alternative to lat/lon — geocoded via GEOCODE }
        - { name: q, in: query, schema: { type: string }, description: Free-form place text (ZIP, address, or "City, ST") — geocoded via GEOCODE }
        - { 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: religion, in: query, schema: { type: string } }
        - { name: denomination, in: query, schema: { type: string } }
        - { name: tradition, in: query, schema: { type: string }, description: Tradition id or slug (via congregation_traditions) }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
      responses:
        '200': { description: OK — results carry distance_km and distance_mi, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /congregation/{id}:
    get:
      tags: [congregations]
      summary: Full congregation bundle
      description: >-
        Directory row + service times + buildings (via building_occupants) + hierarchy
        (parents/children) + identifiers (cross_refs) + traditions[] (congregation_traditions
        graph links) + read-time overrides (meta.overrides_applied) + delegated tax data
        (data.tax, only when an EIN cross-ref resolves at cca-exemptorgs). `?include=community` is
        an explicit opt-in (never the default) that additionally embeds a compact `data.community`
        (county/CBSA/tract/ZCTA) via cca-community, using the congregation's coordinates or ZIP.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: Congregation id, e.g. ro-1a2b3c4d }
        - { name: include, in: query, schema: { type: string, enum: [community] }, description: Set to "community" to embed delegated Census geography context }
      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' } } } }
  /congregation/{id}/services:
    get:
      tags: [congregations]
      summary: Recurring worship/service schedule
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /congregation/{id}/hierarchy:
    get:
      tags: [congregations]
      summary: Parent(s) and children in the congregation graph (campus/mission/affiliation edges)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /congregation/{id}/identifiers:
    get:
      tags: [congregations]
      summary: Identifier graph (external ids, incl. EIN, are aliases; the congregation id is canonical)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /congregation/{id}/history:
    get:
      tags: [history]
      summary: Snapshot cycles + change counts per cycle
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /congregation/{id}/changes:
    get:
      tags: [history]
      summary: Field-level change log for one congregation
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /congregation/{id}/snapshots:
    get:
      tags: [history]
      summary: List frozen per-cycle snapshots in the R2 archive
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /congregation/{id}/snapshots/{cycle}:
    get:
      tags: [history]
      summary: One frozen snapshot ({ id, cycle, data, raw, ingested_at })
      parameters:
        - { name: id, 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' } } } }
  /congregation/{id}/raw:
    get:
      tags: [history]
      summary: Raw source row(s) exactly as ingested (latest snapshot)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /congregation/{id}/raw/{cycle}:
    get:
      tags: [history]
      summary: Raw source row(s) for a specific cycle
      parameters:
        - { name: id, 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' } } } }
  /congregation/{id}/compare:
    get:
      tags: [history]
      summary: Structural diff of two snapshot cycles for one congregation
      parameters:
        - { name: id, 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' } } } }
  /building/{id}:
    get:
      tags: [buildings]
      summary: Religious building bundle (directory row + occupant congregations)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: Building id, e.g. bld-1a2b3c4d }
      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' } } } }
  /building/{id}/occupants:
    get:
      tags: [buildings]
      summary: Occupant congregations for one building
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /denomination/search:
    get:
      tags: [denominations]
      summary: 'COMPATIBILITY ALIAS: search denominations (entity_type=denomination in the generic traditions graph)'
      description: Thin URL-compatible wrapper over GET /tradition/search?type=denomination. Prefer /tradition/search or /religions for new integrations.
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: religion, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '400': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /denomination/{id}:
    get:
      tags: [denominations]
      summary: 'COMPATIBILITY ALIAS: denomination bundle (thin wrapper over the generic tradition bundle)'
      description: Identical response shape to GET /tradition/{id}. Kept so anything that bookmarked or scripted against /denomination/* keeps working; the traditions graph (religion -> family -> denomination -> tradition/movement/rite/school/sect/interfaith) is now the single canonical taxonomy.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: Taxonomy id, e.g. denom-sbc }
      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' } } } }
  /denomination/{id}/congregations:
    get:
      tags: [denominations]
      summary: 'COMPATIBILITY ALIAS: congregations matching a denomination (wraps /tradition/{id}/congregations)'
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: state, 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 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /religions:
    get:
      tags: [religions]
      summary: Top-level religions with congregation/building counts and short plain-language summaries
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /religion/{id-or-slug}:
    get:
      tags: [religions]
      summary: Full sourced religion bundle (identity, names, plain explainer, history/beliefs/practices/organization, branches, relations, sources, congregation/building counts)
      description: Restricted to entity_type=religion. Accepts either a canonical id (rel-christianity) or a bare slug/name (christianity), resolved in order — exact id, canonical_name, then any recorded tradition_names variant.
      parameters:
        - { name: id-or-slug, in: path, required: true, schema: { type: string }, description: 'e.g. rel-christianity or christianity' }
      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' } } } }
  /religion/{id-or-slug}/branches:
    get:
      tags: [religions]
      summary: Bounded, cycle-safe recursive descendant tree under a religion
      description: Walks parent_id children breadth-first with a hard depth cap (10) and node cap (2000); response includes `truncated:true` if either cap was hit.
      parameters:
        - { name: id-or-slug, in: path, required: true, schema: { type: string } }
        - { name: depth, in: query, schema: { type: integer, default: 1, maximum: 10 } }
      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' } } } }
  /tradition/search:
    get:
      tags: [religions]
      summary: FTS search over the whole traditions graph (any entity_type) by name/etymology/summary
      description: Searches a materialized FTS5 index (traditions_fts) covering canonical_name/display_name/plain-explainer summary+etymology. Kept in sync incrementally on ingest; fully rebuildable via POST /admin/backfill {"action":"traditions-fts-rebuild"}.
      parameters:
        - { name: q, in: query, schema: { type: string }, description: Name, etymology or summary text }
        - { name: type, in: query, schema: { type: string, enum: [religion, family, denomination, tradition, movement, rite, school, sect, interfaith] } }
        - { name: religion, in: query, schema: { type: string }, description: Restrict to one religion's subtree (id, slug or name) }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '400': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /tradition/{id}:
    get:
      tags: [religions]
      summary: Full sourced bundle for ANY entity_type (religion/family/denomination/tradition/movement/rite/school/sect/interfaith)
      description: Same shape as /religion/{id-or-slug} but not restricted to entity_type=religion. This is the generic route that /denomination/{id} thinly aliases.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: 'e.g. denom-sbc, fam-baptist, trad-vaishnavism' }
      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' } } } }
  /tradition/{id}/congregations:
    get:
      tags: [religions]
      summary: Congregations linked to this tradition via congregation_traditions
      description: For religion/family entity_types this rolls up the WHOLE subtree via the denormalized religion_id/family_id pointers; for every other entity_type it is a direct (leaf-only) match.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: state, 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 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /tradition/{id}/history:
    get:
      tags: [history]
      summary: Ingest-cycle history for one tradition
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /tradition/{id}/changes:
    get:
      tags: [history]
      summary: Field-level change log for one tradition
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /tradition/{id}/snapshots:
    get:
      tags: [history]
      summary: List R2 snapshot cycles for one tradition (snapshots/traditions/{id}/{cycle}.json)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /tradition/{id}/snapshots/{cycle}:
    get:
      tags: [history]
      summary: One frozen R2 snapshot for one tradition
      parameters:
        - { name: id, 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: No snapshot for that cycle, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /tradition/{id}/raw:
    get:
      tags: [history]
      summary: Latest raw ingest payload for one tradition (R2 raw bucket)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /tradition/{id}/raw/{cycle}:
    get:
      tags: [history]
      summary: Raw ingest payload for one tradition at a specific cycle
      parameters:
        - { name: id, 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' } } } }
  /tradition/{id}/compare:
    get:
      tags: [history]
      summary: Cycle-over-cycle field diff for one tradition
      parameters:
        - { name: id, 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' } } } }
  /stats/county/{fips}:
    get:
      tags: [stats]
      summary: Every tradition's U.S. Religion Census (RCMS) rollup for one county
      parameters:
        - { name: fips, in: path, required: true, schema: { type: string }, description: 4-5 digit county FIPS code }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: No RCMS statistics mirrored for this county, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /stats/county:
    get:
      tags: [stats]
      summary: Same as /stats/county/{fips}, but resolving the county from a ZIP via cca-community
      description: >-
        ?zip= is resolved to a county FIPS via cca-community's Census geography hierarchy first
        (GEOCODE is not needed for this — COMMUNITY resolves ZIP -> county directly), then
        answers identically to /stats/county/{fips}. Returns ERROR_NOT_FOUND (not a 500) when the
        ZIP can't be resolved to a county (e.g. COMMUNITY unbound/unavailable).
      parameters:
        - { name: zip, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '404': { description: ZIP did not resolve to a county, or no RCMS statistics mirrored for it, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /stats/zip/{zip}:
    get:
      tags: [stats]
      summary: Local congregation/building counts + religion breakdown for one ZIP, plus delegated county/ZCTA context
      description: >-
        Local counts (prefix-matched on the zip column, a pure mirror read) always succeed.
        `community` (compact county/CBSA/tract/ZCTA context) and `county_stats` (that county's
        RCMS rollup, when mirrored) are populated via cca-community and are simply null with a
        note in `notes` if that delegation fails or is unbound — never a 500.
      parameters:
        - { name: zip, in: path, required: true, schema: { type: string }, description: 5-digit ZIP code }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '400': { description: Not a valid 5-digit ZIP, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /stats/denomination/{id}:
    get:
      tags: [stats]
      summary: One tradition's RCMS rollup across all mirrored counties
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      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' } } } }
  /changes:
    get:
      tags: [history]
      summary: Global field-level change feed
      parameters:
        - { name: entity_type, in: query, schema: { type: string, enum: [congregation, building] } }
        - { 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 } }
      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 a congregation OR a tradition bundle
      description: >-
        `scheme` selects the identifier space. Congregation schemes: id, mci, ein, osm_node,
        lds_unit, wikidata, state_sos, … The domain scheme can resolve either a congregation alias
        or an exact normalized `traditions.website` match. Other tradition/taxonomy schemes:
        tradition (an id or
        id-or-slug), denomination (compatibility alias of tradition, entity_type=denomination
        preferred), normalized-name (a bare lowercase name/slug looked up across the whole
        traditions graph). The response's `data.kind` discriminates `congregation` vs `tradition`.
      parameters:
        - { name: scheme, in: path, required: true, schema: { type: string }, description: 'id, mci, ein, osm_node, lds_unit, wikidata, state_sos, domain, tradition, denomination, normalized-name, …' }
        - { 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 congregation and tradition records
      description: >-
        Normalizes case, scheme, `www`, port and path, then returns exact-host matches from
        congregation `cross_refs` aliases and official `traditions.website` values. Results are
        discriminated by `type`: `congregation` keeps the existing congregation result shape;
        `tradition` includes the graph entity subtype in `entity_type` (for example `movement`).
        Meaningful subdomains remain distinct. Reads only the local mirror; no DNS, website,
        tax-service or other upstream lookup occurs in the request path.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string }, description: Bare domain (use the query form for a full URL) }
      responses:
        '200': { description: Exact locally mirrored domain matches, content: { application/json: { schema: { $ref: '#/components/schemas/DomainEnvelope' } } } }
        '404': { description: No locally mirrored congregation alias or tradition website matched, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /domain:
    get:
      tags: [resolution]
      summary: Resolve a website domain or URL to congregation and tradition records
      description: >-
        Query-form equivalent of `/domain/{domain}` for inputs containing a scheme, path or query.
        Matching is performed only after normalization and remains exact; `type` discriminates
        congregation records from tradition graph records.
      parameters:
        - { name: domain, in: query, required: true, schema: { type: string }, description: Bare domain or full website URL }
      responses:
        '200': { description: Exact locally mirrored domain matches, content: { application/json: { schema: { $ref: '#/components/schemas/DomainEnvelope' } } } }
        '404': { description: No locally mirrored congregation alias or tradition website matched, 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/congregation/ro-1a2b3c4d'] }
      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: Push-based batch ingest (X-Admin-Key)
      description: >-
        Datasets: congregations (field-level change detection + optional R2 snapshots), buildings,
        entity_geography (validated per-entity state/county/postal/ZCTA/place + provenance),
        (change detection, no snapshots), occupants, hierarchy, cross_refs, service_times
        (full-schedule replace per congregation_id in the batch), rcms — plus the sourced
        religions & traditions graph: traditions (change detection + optional R2 snapshots under
        snapshots/traditions/{id}/{cycle}.json, re-materializes traditions_fts), tradition_names,
        tradition_explainers (preserves fields absent from the batch, re-materializes
        traditions_fts for the plain/en audience), tradition_relations, tradition_sources,
        congregation_traditions. Max 500 rows/call. Every ingest is history-preserving: field-level
        diffs are recorded in change_log with entity_type set to congregation/building/tradition.
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [dataset, cycle, rows]
              properties:
                dataset: { type: string, enum: [congregations, buildings, entity_geography, occupants, hierarchy, cross_refs, service_times, rcms, traditions, tradition_names, tradition_explainers, tradition_relations, tradition_sources, congregation_traditions] }
                cycle: { type: string, examples: ['2026-07'] }
                rows: { type: array, items: { type: object } }
                snapshots: { type: boolean, default: false, description: 'congregations and traditions 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/override:
    post:
      tags: [admin]
      summary: Add or deactivate a read-time field override (mirror purity)
      description: 'Provide { entity_type?, entity_id, field, value, note? } or { deactivate: id }. entity_type accepts congregation, building or tradition.'
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/mci:
    post:
      tags: [admin]
      summary: Link congregation/building ids to CCA master canonical ids
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/backfill:
    post:
      tags: [admin]
      summary: Maintenance actions (FTS and domain-alias rebuilds)
      description: >-
        { "action": "fts-rebuild" } rebuilds congregations_fts from the congregations table.
        { "action": "traditions-fts-rebuild" } fully rebuilds traditions_fts (canonical_name +
        display_name + plain/en explainer summary/etymology) from traditions +
        tradition_explainers — the explicit recovery path for the traditions_fts materialized
        index, since it has no database triggers of its own (see migrations/0001_initial.sql).
        { "action": "domain-aliases-rebuild", "cursor": "ro-...", "limit": 250 } replaces only
        automatically derived domain aliases from mirrored congregation website fields, preserving
        explicitly sourced aliases. Repeat with `next_cursor` until `done` is true. This path uses
        the same URL/IDN normalizer as live ingest.
      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 (never deletes R2 raw files, snapshots or permalinks)
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/cycle:
    post:
      tags: [admin]
      summary: Explicit cycle bookkeeping upsert
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/storage:
    post:
      tags: [admin]
      summary: Exact R2 object and byte inventory for the raw and snapshot buckets
      description: Paginates both bound buckets and reports totals plus top-level archive-prefix breakdowns.
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/refresh:
    post:
      tags: [admin]
      summary: Trigger or inspect external archive-first source refreshes
      description: >-
        Protected coordination ledger; heavy downloads/parsing run on the external data runner.
        Operations: status, trigger, update, release, archive, reconcile-osm and reconcile-finish.
        Reconciliation consumes a run-scoped complete seen set and removes only stale OSM-owned
        records. Trigger records a queued run; it does not execute national OSM/Census processing
        inside the Worker.
      security: [{ AdminKey: [] }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /admin/r2mpu:
    post:
      tags: [admin]
      summary: R2 multipart upload relay (create/part/complete/abort) for large raw file pushes
      description: Archive keys are immutable and atomically reserved in D1 before R2 upload creation. Create retries are idempotent when they reuse the same client-generated requestToken.
      security: [{ AdminKey: [] }]
      parameters:
        - { name: op, in: query, required: true, schema: { type: string, enum: [head, create, part, complete, abort] } }
        - { name: key, in: query, required: true, schema: { type: string } }
        - { name: bucket, in: query, schema: { type: string, enum: [raw, snapshots], default: raw } }
        - { name: requestToken, in: query, schema: { type: string, minLength: 16, maxLength: 128 }, description: Required for op=create; reuse for retries }
        - { name: uploadId, in: query, schema: { type: string }, description: Required for part/abort }
        - { name: part, in: query, schema: { type: integer }, description: Required for op=part }
        - { name: sha256, in: query, schema: { type: string, pattern: '^[a-fA-F0-9]{64}$' }, description: Optional on create; persisted as immutable R2 custom metadata and returned by head/complete }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
components:
  securitySchemes:
    AdminKey:
      type: apiKey
      in: header
      name: X-Admin-Key
  schemas:
    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_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'] }
            as_of: { type: [string, 'null'] }
            congregation_id: { type: string }
            building_id: { type: string }
            tradition_id: { type: string }
            response_time_ms: { type: integer }
    DomainEnvelope:
      allOf:
        - { $ref: '#/components/schemas/Envelope' }
        - type: object
          properties:
            data: { $ref: '#/components/schemas/DomainResolution' }
    DomainResolution:
      type: object
      required: [query, domain, count, results]
      properties:
        query: { type: string, description: Input exactly as supplied by the caller }
        domain: { type: string, description: Normalized exact-match hostname; leading www is removed }
        count: { type: integer, description: Total congregation and tradition results }
        results:
          type: array
          items: { $ref: '#/components/schemas/DomainResult' }
    DomainResult:
      oneOf:
        - { $ref: '#/components/schemas/DomainCongregationResult' }
        - { $ref: '#/components/schemas/DomainTraditionResult' }
      discriminator:
        propertyName: type
        mapping:
          congregation: '#/components/schemas/DomainCongregationResult'
          tradition: '#/components/schemas/DomainTraditionResult'
    DomainCongregationResult:
      type: object
      required: [type, entity_type, id, name, city, state, geography, religion, denomination, website, matched_domain, link_confidence, link_source]
      properties:
        type: { type: string, const: congregation }
        entity_type: { type: string, const: congregation }
        id: { type: string, pattern: '^ro-[a-z0-9]{8}$' }
        name: { type: string }
        city: { type: [string, 'null'] }
        state: { type: [string, 'null'] }
        geography: { type: [object, 'null'] }
        religion: { type: [string, 'null'] }
        denomination: { type: [string, 'null'] }
        website: { type: [string, 'null'] }
        matched_domain: { type: string }
        link_confidence: { type: [number, 'null'] }
        link_source: { type: [string, 'null'] }
    DomainTraditionResult:
      type: object
      required: [type, entity_type, id, name, canonical_name, display_name, parent_id, religion_id, family_id, geography_scope, congregation_count, building_count, status, website, matched_domain, link_confidence, link_source, cycle, updated_at]
      properties:
        type: { type: string, const: tradition, description: Broad domain-result discriminator }
        entity_type: { type: string, enum: [religion, family, denomination, tradition, movement, rite, school, sect, interfaith], description: Specific traditions-graph subtype }
        id: { type: string, pattern: '^(rel|fam|denom|trad)-[a-z0-9-]+$' }
        name: { type: string, description: Display-name compatibility alias }
        canonical_name: { type: string }
        display_name: { type: string }
        parent_id: { type: [string, 'null'] }
        religion_id: { type: [string, 'null'] }
        family_id: { type: [string, 'null'] }
        geography_scope: { type: [string, 'null'] }
        congregation_count: { type: integer, description: Active linked congregations; religion/family rows use their existing rollup semantics }
        building_count: { type: integer, description: Buildings occupied by those congregations }
        status: { type: [string, 'null'] }
        website: { type: string }
        matched_domain: { type: string }
        link_confidence: { type: number, const: 1 }
        link_source: { type: string, const: traditions.website }
        cycle: { type: [string, 'null'] }
        updated_at: { type: [string, 'null'] }
    NearbyEnvelope:
      allOf:
        - { $ref: '#/components/schemas/Envelope' }
        - type: object
          properties:
            data: { $ref: '#/components/schemas/NearbyData' }
    NearbyData:
      type: object
      required:
        - identity_keys
        - origin
        - radius_km
        - radius_mi
        - count
        - counts
        - truncated
        - results
        - delegated_sources
      properties:
        identity_keys:
          type: object
          required: [congregation, building]
          properties:
            congregation: { type: string, const: religious_org_id }
            building: { type: string, const: building_id }
        origin:
          type: object
          required: [input, label, precision, source, lat, lon]
          properties:
            input: { type: [string, 'null'] }
            label: { type: [string, 'null'] }
            precision: { type: string }
            source: { type: string, enum: [user, geocode.datasourceapi.com] }
            lat: { type: number }
            lon: { type: number }
        radius_km: { type: number }
        radius_mi: { type: number }
        count: { type: integer }
        counts:
          type: object
          required: [congregations, buildings]
          properties:
            congregations: { type: integer }
            buildings: { type: integer }
        truncated: { type: boolean }
        results:
          type: array
          items: { $ref: '#/components/schemas/NearbyResult' }
        geocoding:
          description: Present only when geocode_limit is explicitly greater than zero.
          type: object
        delegated_sources:
          type: array
          items: { type: string }
    NearbyResult:
      type: object
      description: Additive mixed result. Fields specific to the other entity type are absent.
      required:
        - type
        - entity_type
        - id
        - name
        - city
        - state
        - zip
        - lat
        - lon
        - coordinates
        - distance_km
        - distance_mi
      properties:
        type: { type: string, enum: [congregation, building] }
        entity_type: { type: string, enum: [congregation, building] }
        id: { type: string, description: Legacy canonical typed ID; ro-* for congregations, bld-* for buildings }
        congregation_id: { type: string, pattern: '^ro-[a-z0-9]{8}$' }
        religious_org_id: { type: string, pattern: '^ro-[a-z0-9]{8}$', description: Permanent congregation key; identical to id }
        building_id: { type: string, pattern: '^bld-[a-z0-9]{8}$', description: Permanent building key; identical to id }
        organization_type: { type: string, enum: [congregation, religious_building] }
        canonical_name: { type: [string, 'null'] }
        display_name: { type: [string, 'null'] }
        name: { type: [string, 'null'], description: Legacy alias of display_name }
        street: { type: [string, 'null'] }
        city: { type: [string, 'null'] }
        state: { type: [string, 'null'] }
        zip: { type: [string, 'null'], description: Legacy postal field }
        postal_code: { type: [string, 'null'] }
        address:
          type: object
          required: [line1, line2, city, state, postal_code, country_code, formatted]
          properties:
            line1: { type: [string, 'null'] }
            line2: { type: [string, 'null'] }
            city: { type: [string, 'null'] }
            state: { type: [string, 'null'] }
            postal_code: { type: [string, 'null'] }
            country_code: { type: string, const: US }
            formatted: { type: [string, 'null'] }
        lat: { type: number }
        lon: { type: number }
        coordinates: { $ref: '#/components/schemas/OrganizationCoordinates' }
        coordinate_precision: { type: [string, 'null'] }
        coordinate_source: { type: [string, 'null'] }
        distance_km: { type: number }
        distance_mi: { type: number }
        ein: { type: [string, 'null'], description: Congregations only; normalized to XX-XXXXXXX when nine digits are available }
        identifiers:
          type: array
          description: Congregations only.
          items:
            type: object
            required: [scheme, value, confidence, provenance]
            properties:
              scheme: { type: string }
              value: { type: string }
              confidence: { type: [number, 'null'] }
              provenance: { type: [string, 'null'] }
        religion: { type: [string, 'null'] }
        denomination: { type: [string, 'null'] }
        tradition: { type: [string, 'null'] }
        classification: { type: object }
        status: { type: [string, 'null'] }
        website: { type: [string, 'null'] }
        phone: { type: [string, 'null'] }
        building_type: { type: [string, 'null'] }
        year_built: { type: [integer, 'null'] }
        source: { type: [string, 'null'] }
        source_id: { type: [string, 'null'], description: Congregations only; buildings have no source_id column }
        last_verified: { type: [string, 'null'] }
        cycle: { type: [string, 'null'] }
        updated_at: { type: [string, 'null'] }
    CommunityNearbyEnvelope:
      allOf:
        - { $ref: '#/components/schemas/Envelope' }
        - type: object
          properties:
            data: { $ref: '#/components/schemas/CommunityNearbyData' }
    CommunityNearbyData:
      type: object
      required:
        - integration
        - identity_key
        - identity_scope
        - origin
        - radius_km
        - radius_mi
        - limit
        - count
        - truncated
        - results
        - geocoding
      properties:
        integration: { type: string, const: cca-community }
        identity_key: { type: string, const: religious_org_id }
        identity_scope: { type: string, const: 'cca-religiousorgs:congregation' }
        origin:
          type: object
          required: [input, label, precision, source, lat, lon]
          properties:
            input: { type: [string, 'null'] }
            label: { type: [string, 'null'] }
            precision: { type: string }
            source: { type: string, enum: [user, geocode.datasourceapi.com] }
            lat: { type: number }
            lon: { type: number }
        radius_km: { type: number }
        radius_mi: { type: number }
        limit: { type: integer, maximum: 100 }
        count: { type: integer }
        truncated: { type: boolean }
        results:
          type: array
          items: { $ref: '#/components/schemas/CommunityReligiousOrganization' }
        geocoding:
          type: object
          required:
            - postal_code
            - candidate_limit
            - candidates_considered
            - candidates_truncated
            - attempted
            - resolved
            - included
            - rejected_coarse
            - failed
            - outside_radius
            - skipped_deadline
            - deadline_exhausted
          properties:
            postal_code: { type: [string, 'null'] }
            candidate_limit: { type: integer, maximum: 25 }
            candidates_considered: { type: integer }
            candidates_truncated: { type: boolean }
            attempted: { type: integer }
            resolved: { type: integer }
            included: { type: integer }
            rejected_coarse: { type: integer }
            failed: { type: integer }
            outside_radius: { type: integer }
            skipped_deadline: { type: integer }
            deadline_exhausted: { type: boolean }
        delegated_sources:
          type: array
          items: { type: string, enum: [geocode.datasourceapi.com] }
    CommunityReligiousOrganization:
      type: object
      required:
        - id
        - type
        - congregation_id
        - religious_org_id
        - entity_type
        - organization_type
        - canonical_name
        - display_name
        - address
        - lat
        - lon
        - coordinates
        - distance_km
        - distance_mi
        - identifiers
        - traditions
      properties:
        id: { type: string, pattern: '^ro-[a-z0-9]{8}$' }
        type: { type: string, const: congregation }
        congregation_id: { type: string, pattern: '^ro-[a-z0-9]{8}$' }
        religious_org_id: { type: string, pattern: '^ro-[a-z0-9]{8}$', description: Permanent canonical congregation key; identical to id }
        entity_type: { type: string, const: congregation }
        organization_type: { type: string, const: congregation }
        canonical_name: { type: [string, 'null'], description: Deterministic Unicode-aware lowercase matching form; meaningful words and legal suffixes are retained }
        display_name: { type: [string, 'null'] }
        name: { type: [string, 'null'], description: Alias of display_name }
        street: { type: [string, 'null'] }
        city: { type: [string, 'null'] }
        state: { type: [string, 'null'] }
        zip: { type: [string, 'null'] }
        postal_code: { type: [string, 'null'] }
        address:
          type: object
          required: [line1, line2, city, state, postal_code, country_code, formatted]
          properties:
            line1: { type: [string, 'null'] }
            line2: { type: [string, 'null'] }
            city: { type: [string, 'null'] }
            state: { type: [string, 'null'] }
            postal_code: { type: [string, 'null'] }
            country_code: { type: string, const: US }
            formatted: { type: [string, 'null'] }
        lat: { type: number }
        lon: { type: number }
        coordinates: { $ref: '#/components/schemas/OrganizationCoordinates' }
        coordinate_precision: { type: [string, 'null'] }
        coordinate_source: { type: [string, 'null'] }
        distance_km: { type: number }
        distance_mi: { type: number }
        ein: { type: [string, 'null'], description: EIN alias normalized to XX-XXXXXXX when nine digits are available }
        mci: { type: [string, 'null'] }
        source: { type: [string, 'null'] }
        source_id: { type: [string, 'null'] }
        identifiers:
          type: array
          items:
            type: object
            required: [scheme, value, confidence, provenance]
            properties:
              scheme: { type: string }
              value: { type: string }
              confidence: { type: [number, 'null'] }
              provenance: { type: [string, 'null'] }
        religion: { type: [string, 'null'] }
        denomination: { type: [string, 'null'] }
        tradition: { type: [string, 'null'] }
        classification: { type: object }
        traditions:
          type: array
          items:
            type: object
            required: [id, entity_type, canonical_name, display_name, relationship_type, confidence, provenance]
            properties:
              id: { type: string }
              entity_type: { type: string }
              canonical_name: { type: string }
              display_name: { type: string }
              religion_id: { type: [string, 'null'] }
              family_id: { type: [string, 'null'] }
              relationship_type: { type: string }
              confidence: { type: [number, 'null'] }
              provenance: { type: [string, 'null'] }
        status: { type: [string, 'null'] }
        website: { type: [string, 'null'] }
        phone: { type: [string, 'null'] }
        last_verified: { type: [string, 'null'] }
        cycle: { type: [string, 'null'] }
        updated_at: { type: [string, 'null'] }
    OrganizationCoordinates:
      type: object
      description: A validated organization point. ZIP/ZCTA/place centroids are never emitted.
      required: [lat, lon, precision, source, provider, refined_by, geocoded, match_type, match_score]
      properties:
        lat: { type: number }
        lon: { type: number }
        precision: { type: string, description: Source point or address-level precision such as entrance, rooftop, parcel, or interpolated }
        source: { type: [string, 'null'], description: Mirrored coordinate source or geocode.datasourceapi.com for live fallback }
        provider: { type: [string, 'null'], description: Match provider metadata returned by cca-geocode, when present }
        refined_by: { type: [string, 'null'], description: Refinement provider metadata returned by cca-geocode, when present }
        geocoded: { type: boolean }
        match_type: { type: [string, 'null'] }
        match_score: { type: [number, 'null'] }
        matched_address:
          type: [object, 'null']
          properties:
            number: { type: [string, 'null'] }
            street: { type: [string, 'null'] }
            city: { type: [string, 'null'] }
            state: { type: [string, 'null'] }
            postal_code: { type: [string, 'null'] }
