openapi: 3.1.0
info:
  title: cca-postalfacilities API
  version: 1.0.0
  description: >
    US postal facility & ZIP intelligence — a CCA data-service fleet member.
    Mirror + identifier graph + relationship graph over USPS post offices, stations, branches,
    CPUs, processing/distribution facilities, collection boxes, and the ZIP universe they serve.
    Headline: resolve a PO Box to the physical address of the post office that hosts it.

    Reuse invariant: geography, boundaries, and geocoding are delegated to the fleet services
    cca-geocode and cca-community at read time and are never duplicated in this service.
servers:
  - url: https://postalfacilities.datasourceapi.com
    description: Production
  - url: http://localhost:8787
    description: Local dev (wrangler)
tags:
  - name: lookup
    description: Headline lookups
  - name: facilities
    description: Facility records & sub-resources
  - name: internal
    description: >
      Licensed surface (USPS Addresses/DPV data — no public display per license §4.1). Served
      under /api/v1/internal/*, which the operator blocks at the zone WAF; fleet workers reach it
      via the POSTAL service binding (bypasses the zone WAF). No in-worker auth.
  - name: system
    description: Status & metadata
  - name: admin
    description: Operator routes (gated by the X-Admin-Key header)
components:
  securitySchemes:
    AdminKey:
      type: apiKey
      in: header
      name: X-Admin-Key
  schemas:
    Envelope:
      type: object
      required: [request_status, message, data, meta]
      properties:
        request_status:
          type: string
          enum: [success, error]
        message:
          type: string
        data:
          nullable: true
        meta:
          type: object
          properties:
            generated_at: { type: string, format: date-time }
            elapsed_ms: { type: integer }
            sources:
              type: array
              items: { type: string }
    Address:
      type: object
      properties:
        line: { type: string }
        city: { type: string }
        state: { type: string }
        zip: { type: string }
    Facility:
      type: object
      properties:
        mci: { type: string, example: pofc-7h2k9m4x1a0b }
        name: { type: string }
        type: { type: string, example: Post Office }
        status: { type: string, example: active }
        physical_address: { $ref: '#/components/schemas/Address' }
        finance_number: { type: string }
        nass_code: { type: string }
        phone: { type: string }
        lat: { type: number, nullable: true }
        lon: { type: number, nullable: true }
  responses:
    Envelope:
      description: Fleet envelope
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Envelope' }
paths:
  /api/v1/status:
    get:
      tags: [system]
      summary: Service status, dataset versions, shard health
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
  /api/v1/pobox:
    get:
      tags: [lookup]
      summary: Resolve a PO Box (or its ZIP) to the hosting post office's physical address
      parameters:
        - in: query
          name: address
          schema: { type: string }
          description: >
            A PO-Box address line, e.g. "PO Box 1234, Bellevue, WA 98009". A PMB line
            ("10 Main St PMB 204, …") is recognized as a private mailbox at a CMRA — flagged
            `address_kind: cmra-pmb`, never mistaken for a USPS PO Box.
        - in: query
          name: zip
          schema: { type: string, pattern: '^\d{5}$' }
          description: A bare 5-digit ZIP (alternative to address).
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
        '404':
          description: PO Box could not be resolved to a facility
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
  /api/v1/pobox/{zip}:
    get:
      tags: [lookup]
      summary: Resolve a PO-Box ZIP to the hosting post office (path form)
      parameters:
        - in: path
          name: zip
          required: true
          schema: { type: string, pattern: '^\d{5}$' }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
  /api/v1/internal/cmra:
    get:
      tags: [internal]
      summary: (Internal) CMRA / PMB — verify an address against the USPS CMRA registry (DPV)
      description: >
        LICENSED SURFACE — DPV data is USPS Licensed Materials (Addresses API License §4.1: no
        public display), so this endpoint lives under `/api/v1/internal/*`: the operator blocks
        that prefix at the zone WAF, and fleet workers reach it via the POSTAL service binding
        (service-binding traffic bypasses the zone WAF). The worker itself adds no auth here.
        Commercial Mail Receiving Agencies (UPS Store, PostalAnnex, mailbox stores) host Private
        Mail Boxes (PMB). USPS publishes no bulk CMRA registry — the authority is the per-address
        DPV `DPVCMRA` flag (USPS Addresses 3.0), so verification is read-through: mirror-first,
        then a live USPS check; a verified CMRA persists permanently as a `postal-partner`
        facility (MCI prefix `cpuf`, designation CMRA) with a `served_by` edge to the ZIP's host
        post office (the delivery unit holding its Form 1583-A registrations). PMB numbers are
        parsed and echoed only — never stored. Alias route: `/api/v1/internal/pmb`.
      parameters:
        - in: query
          name: address
          schema: { type: string }
          description: 'Single-line address, e.g. "1630 S Delaware St PMB 204, San Mateo, CA 94402".'
        - { in: query, name: street, schema: { type: string }, description: 'Street line (structured alternative to address=).' }
        - { in: query, name: secondary, schema: { type: string }, description: 'Secondary unit (STE/APT…).' }
        - { in: query, name: city, schema: { type: string } }
        - { in: query, name: state, schema: { type: string, minLength: 2, maxLength: 2 } }
        - { in: query, name: zip, schema: { type: string, pattern: '^\d{5}(-\d{4})?$' } }
        - { in: query, name: firm, schema: { type: string }, description: 'Business name at the address (improves the standardized firm).' }
        - { in: query, name: refresh, schema: { type: string, enum: ['1'] }, description: 'Skip the mirror and force a live USPS re-verification.' }
      responses:
        '200':
          description: 'Verified answer: `cmra: true` (facility + serving post office + map point) or `cmra: false`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
        '404':
          description: Could not verify (unknown address, USPS unconfigured, or upstream down with nothing mirrored) — `error_code ERROR_CMRA_UNVERIFIED`, honest note.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
  /api/v1/internal/cmra/{zip}:
    get:
      tags: [internal]
      summary: (Internal) Verified CMRAs mirrored in a ZIP
      description: >
        LICENSED SURFACE (see /api/v1/internal/cmra). The CMRAs verified via USPS DPV to date
        (read-through mirror). Honest scope — USPS has no bulk CMRA registry, so this list grows
        with lookups and is never exhaustive.
      parameters:
        - in: path
          name: zip
          required: true
          schema: { type: string, pattern: '^\d{5}$' }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
  /api/v1/internal/facilities/{id}:
    get:
      tags: [internal]
      summary: (Internal) Facility by any identifier, INCLUDING licensed (CMRA) rows
      description: >
        Same contract as /api/v1/facilities/{id} (sub-resources included via
        /api/v1/internal/facilities/{id}/{sub}), but licensed CMRA rows resolve here instead of
        404ing. Public facility endpoints exclude licensed rows at the query layer.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: query
          name: by
          schema: { type: string, enum: [finance, nass, locale, zip, facilityid, deliverypoint] }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
        '404': { $ref: '#/components/responses/Envelope' }
  /api/v1/zip/{zip}:
    get:
      tags: [lookup]
      summary: Postal-code profile — classification, host facility, address counts, served geography
      description: >
        Returns postal attributes owned by this service (classification P/U/M/S, host/owning
        facility, finance number, locale key, USPS delivery/address counts, HUD crosswalk ratios).
        Served-geography names/boundaries are resolved live from cca-community. `zip` is the US case
        of a general `postal_code`; pass `?country=` (ISO 3166-1 alpha-2) for non-US codes. The US
        5-digit rule is enforced only when `country=US` (the default).
      parameters:
        - in: path
          name: zip
          required: true
          description: Postal code (US ZIP is 5 digits; other countries use their own format).
          schema: { type: string, example: '98004' }
        - in: query
          name: country
          required: false
          description: ISO 3166-1 alpha-2 country of the postal code (defaults to US).
          schema: { type: string, default: US, example: US }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
  /api/v1/area:
    get:
      tags: [lookup]
      summary: Area intelligence — postal facilities around an address/ZIP/place/coords
      parameters:
        - { in: query, name: q, schema: { type: string }, description: 'Free-form: address, ZIP, "City, ST", or "lat,lon".' }
        - { in: query, name: zip, schema: { type: string } }
        - { in: query, name: address, schema: { type: string } }
        - { in: query, name: lat, schema: { type: number } }
        - { in: query, name: lon, schema: { type: number } }
        - { in: query, name: radius, schema: { type: number, default: 10, maximum: 100 }, description: 'Search radius in miles (default 10, max 100).' }
        - { in: query, name: limit, schema: { type: integer, default: 50, maximum: 200 }, description: 'Max facilities to return.' }
        - { in: query, name: format, schema: { type: string, enum: [json, geojson] }, description: 'geojson returns a FeatureCollection of the nearby facilities.' }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
  /api/v1/search:
    get:
      tags: [lookup]
      summary: Omni-search by ZIP, finance number, NASS code, name, "City, ST", address, or coords
      parameters:
        - in: query
          name: q
          required: true
          schema: { type: string, minLength: 1, maxLength: 200 }
          description: >
            Street-address searches include `data.address_match.is_postal_facility`, scoped only
            to non-licensed facilities in the current public mirror. This is not general
            delivery-address validation and never exposes licensed CMRA/PMB rows. Address-query
            responses use `Cache-Control: no-store`.
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
        '400': { $ref: '#/components/responses/Envelope' }
  /api/v1/facilities/{id}:
    get:
      tags: [facilities]
      summary: Fetch a facility by MCI, finance number, NASS code, or ZIP
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
        - { in: query, name: by, schema: { type: string, enum: [mci, finance, nass, zip] }, description: Optional explicit identifier kind. }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
  /api/v1/facilities/{id}/{sub}:
    get:
      tags: [facilities]
      summary: Facility sub-resource
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
        - in: path
          name: sub
          required: true
          schema:
            type: string
            enum: [full, identifiers, relations, history, changes, snapshots, raw, geojson]
        - in: query
          name: cycle
          required: false
          description: For `raw`, select a specific archived cycle (defaults to the latest).
          schema: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
  /api/v1/admin/ingest:
    post:
      tags: [admin]
      summary: Ingest a USPS PostalPro, HUD crosswalk, cblevins, or City State file (operator-supplied, R2-first)
      description: >
        Trigger ingest of a supported dataset. **PostalPro** ("Post Office & Collection Box Data")
        sits behind a free login, **HUD–USPS crosswalk** files are public quarterly CSVs, **cblevins**
        is a static historical post-office release (Harvard Dataverse, CC0), and **City State** is the
        licensed USPS AMS product (premium — gated by the `USPS_CITYSTATE_KEY` secret). In all cases
        the operator supplies either a source `url` or a pre-staged R2 `rawKey`. The worker fetches →
        archives the raw file to R2 (system of record) → runs the scan/extract/entry pipeline. For
        PostalPro this mints permanent MCIs and upserts the CORE + FACILITIES shards, the identifier
        graph, and skeleton ZIP rows. For HUD it upserts `zip_crosswalk` and enriches `zip_codes`
        city/state/county_fips. For cblevins it mints `pofc` MCIs in the HIST shard and writes
        `historical_offices` (+ `cblevins` identifier aliases); run `/admin/relations` with
        `scope=historical` afterward to link present-day facilities to their predecessor via a governed
        `succeeds` edge. For City State it authoritatively sets `zip_codes` classification/finance/locale.
        Never any geography (names/boundaries come from cca-community at read time). By default the job
        is enqueued on the bulk queue; pass `?inline=1` (or run without a queue binding, e.g. local dev)
        to execute it synchronously. Gated by the X-Admin-Key header.
      security:
        - AdminKey: []
      parameters:
        - in: query
          name: inline
          schema: { type: string, enum: ['1'] }
          description: Run the pipeline synchronously in this request instead of enqueuing it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                dataset:
                  type: string
                  default: postalpro
                  enum: [postalpro, hud, cblevins, citystate]
                  description: >
                    Dataset alias — `postalpro` (usps-postalpro-locations), `hud`
                    (hud-usps-crosswalk), `cblevins` (cblevins-post-offices, historical), or
                    `citystate` (usps-city-state, premium — requires USPS_CITYSTATE_KEY).
                geoKind:
                  type: string
                  enum: [tract, county, cbsa, cd]
                  description: >
                    Required when `dataset=hud`: which geography this crosswalk file maps ZIP→
                    (each HUD file covers exactly one).
                url:
                  type: string
                  description: Source URL to fetch (mutually exclusive with rawKey).
                rawKey:
                  type: string
                  description: Key of a pre-staged raw file already in the R2 archive.
                format:
                  type: string
                  enum: [geojson, psv, csv]
                  description: Override format detection (otherwise inferred from extension/content; HUD is always csv).
                defaultKind:
                  type: string
                  enum: [post-office, processing-facility, collection-box]
                  description: Entity-type hint for files that are entirely one kind (e.g. collection boxes).
                cycle:
                  type: string
                  description: >
                    Override the cycle. PostalPro uses a snapshot date (YYYY-MM-DD); HUD uses a
                    quarter (YYYYQn). Defaults to Last-Modified/today (PostalPro) or the current
                    quarter (HUD).
              oneOf:
                - required: [url]
                - required: [rawKey]
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
        '400':
          description: Missing/invalid dataset or neither url nor rawKey supplied
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
        '401':
          description: Missing or invalid X-Admin-Key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }

  /api/v1/admin/cmra-scan:
    post:
      tags: [admin]
      summary: Bulk CMRA universe build — verify a candidate-address list via USPS DPV
      description: >
        USPS publishes no bulk CMRA registry, so the national CMRA universe is built by
        candidate-driven enumeration: harvest candidate addresses from open sources, then verify
        EVERY candidate through the USPS DPV `DPVCMRA` flag before anything enters the mirror (a
        bad candidate costs one API call and verifies 'N' — candidate quality affects coverage,
        never truth). Candidate sources, one per call: `addresses` (objects
        {street,city,state,zip,firm,lat,lon} or single-line strings), `url` / `rawKey`
        (CSV / JSON / GeoJSON / Overpass-export candidate file — Overture Places
        `mailbox_rental_service` exports and franchise lists work as-is; archived raw to R2 before
        parsing), `source:"osm"` (live Overpass harvest of known US CMRA brands, custom `query`
        supported), or `source:"mirror"` (re-verify every mirrored CMRA — the freshness sweep).
        Candidate coordinates and OSM POI ids persist as their own `cmra-candidates` ledger
        entries under the `osm`/`operator` authority — never attributed to USPS. OSM harvests and
        mirror sweeps default to a FULL run (publishes the `usps-addresses` cycle when complete);
        `mode` can force `full`/`spot`. `?inline=1` (or ≤5 candidates / no queue binding) runs
        in-request; otherwise the cmra-scan queue stages fan out with 429 backoff. Requires the
        USPS OAuth secrets. Gated by the X-Admin-Key header.
      security:
        - AdminKey: []
      parameters:
        - in: query
          name: inline
          schema: { type: string, enum: ['1'] }
          description: Run in-request instead of enqueueing (local dev / small batches).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                addresses:
                  type: array
                  items:
                    oneOf:
                      - type: string
                      - type: object
                        properties:
                          street: { type: string }
                          secondary: { type: string }
                          city: { type: string }
                          state: { type: string }
                          zip: { type: string }
                          firm: { type: string }
                          lat: { type: number }
                          lon: { type: number }
                url: { type: string, description: 'Candidate file URL (CSV/JSON/GeoJSON/Overpass export)' }
                rawKey: { type: string, description: 'Pre-staged R2 key of a candidate file' }
                source: { type: string, enum: [osm, mirror] }
                query: { type: string, description: 'Custom Overpass QL (source=osm only)' }
                mode: { type: string, enum: [full, spot] }
                cycle: { type: string, description: 'Cycle date YYYY-MM-DD (defaults to today)' }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
        '401':
          description: Missing or invalid X-Admin-Key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
  /api/v1/admin/cmra-resume:
    post:
      tags: [admin]
      summary: Resume a budget-halted CMRA scan run
      description: >
        Bulk CMRA verification halts (run left open) when the monthly USPS Addresses budget is
        exhausted (the product is metered — see /admin/usage). This re-enqueues (or, with
        `?inline=1`, re-walks) the run's frozen manifest; parts already verified are skipped and
        never re-billed. Gated by the X-Admin-Key header.
      security:
        - AdminKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [runId]
              properties:
                runId: { type: string }
                inline: { type: boolean }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
        '401':
          description: Missing or invalid X-Admin-Key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
  /api/v1/admin/usage:
    post:
      tags: [admin]
      summary: USPS API usage, budget state & tiered cost estimate
      description: >
        Month-to-date apis.usps.com usage from the D1 meter (every call is counted at the network
        chokepoint). Billable (Addresses product) calls are hard-capped by the
        USPS_ADDRESSES_MONTHLY_BUDGET var; 50/80/95% + exhaustion alerts and a daily report email
        the operator via the EMAIL_SVC fleet binding. `{"email": true}` sends the report now.
        Gated by the X-Admin-Key header.
      security:
        - AdminKey: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: boolean }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
        '401':
          description: Missing or invalid X-Admin-Key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
  /api/v1/admin/cmra-runs:
    post:
      tags: [admin]
      summary: CMRA universe-build run progress
      description: >
        Progress of CMRA verification runs (dataset usps-addresses). Without `runId`: the 25 newest
        runs (completed/verified counts, percent), the mirrored-CMRA total, and budget posture.
        With `runId`: that run's full progress row. Gated by the X-Admin-Key header.
      security:
        - AdminKey: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                runId: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
        '401':
          description: Missing or invalid X-Admin-Key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
  /api/v1/admin/relations:
    post:
      tags: [admin]
      summary: (Re)build the governed relationship graph
      description: >
        Rebuild `entity_relations` from already-ingested data. `facilities` derives
        station_of/branch_of edges from shared USPS finance numbers (a single unambiguous main
        office parents its stations/branches/CPUs; ambiguous groups are parked in relation_review).
        `zips` derives served_by (ZIP→host office) and hosts_pobox (office→PO-Box/unique ZIP) edges,
        minting first-class `zip` entities as needed. `historical` links each present-day office to its
        cblevins predecessor via a governed `succeeds` edge — created only when name+state agree AND
        coordinates confirm the location (≤2mi); unconfirmable name matches are parked in
        relation_review. Idempotent. Gated by the X-Admin-Key header.
      security:
        - AdminKey: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                scope:
                  type: string
                  enum: [all, facilities, zips, historical]
                  default: all
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
        '401':
          description: Missing or invalid X-Admin-Key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }

  /api/v1/admin/refresh:
    post:
      tags: [admin]
      summary: Run release-detection now (manual cron trigger)
      description: >
        Compare each mirrored dataset's newest successful cycle to its expected release and enqueue a
        bulk `scan` for any auto-fetchable dataset that is behind. With a `dataset` it checks just that
        one; without, it runs the full daily-watchdog sweep. Datasets that are stale but not
        auto-fetchable (PostalPro login, HUD form, licensed City State) are reported as
        `awaiting operator ingest`. Detection never mutates the mirror. Gated by the X-Admin-Key header.
      security:
        - AdminKey: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                dataset:
                  type: string
                  enum: [postalpro, hud, cblevins, citystate]
                  description: Optionally limit detection to a single dataset.
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
        '401':
          description: Missing or invalid X-Admin-Key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }

  /api/v1/admin/override:
    post:
      tags: [admin]
      summary: Upsert a cca-override correction (applied at read time)
      description: >
        Upsert a curated correction into the `overrides` table. Corrections are layered onto a
        facility response at READ time only — the D1/KV/R2 mirror stays pure upstream (invariant #7).
        Idempotent on (authority, entity_type, entity_id, field_path). `field_path` is a dot-path
        (e.g. `phone`, `hours.lobby`); `value` may be a string or any JSON value. Set `active:false`
        to retire a correction. Responses that applied corrections list the field paths in
        `meta.overrides`. Gated by the X-Admin-Key header.
      security:
        - AdminKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entity_type, entity_id, field_path]
              properties:
                entity_type: { type: string, example: post-office }
                entity_id: { type: string, description: The permanent MCI, example: pofc-000000000001 }
                field_path: { type: string, description: Dot-path of the field to correct, example: phone }
                value: { description: Replacement value (string or any JSON), example: 425-555-0100 }
                note: { type: string }
                author: { type: string }
                active: { type: boolean, default: true }
                authority: { type: string, default: usps }
      responses:
        '200': { $ref: '#/components/responses/Envelope' }
        '401':
          description: Missing or invalid X-Admin-Key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
