openapi: 3.1.0
info:
  title: CCA Violence Data API
  version: 0.1.0
  summary: Evidence-linked firearm incident research API
  description: |
    Public firearm incident data for the United States, its territories, and
    tribal jurisdictions. The schema is designed for future non-firearm violence.

    ## Read the limitations first

    This database is not complete. Candidate records can be wrong. Sources often
    conflict. Automated extraction can make errors. Map points may be approximate.
    A nearby school is not necessarily involved. Older records are substantially
    less complete. This service is not an investigating agency or final legal record.

    Every JSON response links to `/limitations` and `/api/v1/limitations`.
    Verified-only is the default scope for statistics.
  contact:
    name: Cooperative Computing Alliance
    url: https://coopalliance.org
servers:
  - url: https://violence.datasourceapi.com/api/v1
tags:
  - { name: Service, description: Health, methodology, and limitations }
  - { name: Incidents, description: Public incident records and fact-level evidence }
  - { name: Search, description: Public-field search with strict privacy boundaries }
  - { name: Schools, description: Exact CCA School MCI relationships; proximity is not causation }
  - { name: Provenance, description: Public source, hash, and prohibited-source information }
  - { name: Changes, description: Permanent public change history }
  - { name: Statistics, description: Precomputed counts; verified-only by default }
paths:
  /health:
    get:
      tags: [Service]
      summary: Runtime and required-binding health
      responses:
        '200': { description: Healthy, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
        '503': { description: Degraded }
  /status:
    get:
      tags: [Service]
      summary: Collection, source, shard, corpus-field, review, and budget health
      description: Includes per-field known, conflicting, and unavailable public-event counts plus categorized lifetime and recent run/delivery failures. Infrastructure health does not imply complete incident coverage.
      responses:
        '200': { description: Status, content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } }
  /limitations:
    get:
      tags: [Service]
      summary: Plain-language system limitations
      responses:
        '200': { description: Limitations registry }
  /incidents:
    get:
      tags: [Incidents]
      summary: Search public incidents
      description: A missing result does not prove an incident did not occur.
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: date_from, in: query, schema: { type: string, format: date } }
        - { name: date_to, in: query, schema: { type: string, format: date } }
        - { name: state, in: query, schema: { type: string, minLength: 2, maxLength: 2 } }
        - { name: city, in: query, schema: { type: string } }
        - { name: confidence, in: query, schema: { $ref: '#/components/schemas/ConfidenceLabel' } }
        - { name: verification, in: query, schema: { $ref: '#/components/schemas/VerificationStatus' } }
        - { name: type, in: query, schema: { type: string } }
        - { name: page, in: query, schema: { type: integer, minimum: 1, maximum: 5, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: cursor, in: query, schema: { type: string }, description: Opaque `next_cursor` from the previous page; use this instead of deep page numbers to traverse the full public corpus }
      responses:
        '200': { description: Paginated incidents with `next_cursor` and `has_more` metadata for full-corpus traversal }
  /search:
    get:
      tags: [Search]
      summary: Search published public fields
      description: |
        Searches public places, venues, school names and MCIs, narratives, and
        the narrow audited public-record-name exception. It never queries the
        restricted PII database. Victims, witnesses, living subjects, and other
        private people cannot be found here.

        Exact CCA School MCIs use exact identifier matching. Minor place
        misspellings can return an explicitly labeled approximate match.
        Strict event groups may combine duplicate source-derived records; the
        response reports the grouped record count and IDs.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 2, maxLength: 200 }, example: 'Example Valley Mall' }
      responses:
        '200': { description: Public search results with match quality }
        '400': { description: Invalid query }
  /incidents/{incident_id}:
    get:
      tags: [Incidents]
      summary: Source incident record with nested durable event projection
      description: |
        Public data omits victim/private-person names and aliases, dates of birth,
        and firearm serial numbers. Use `include=pii` with an approved Bearer key
        and `X-PII-Purpose` for audited restricted access.
        Only names admitted by the audited deceased-perpetrator public-record
        policy can appear. `school_context.incident_schools` contains only
        schools explicitly identified by archived source evidence; nearest
        proximity is reported separately and must not be treated as incident
        involvement. When one exact source quote states both a fatality total
        and child/adult counts, the source record's
        `casualties.fatality_breakdown` and the aggregate
        `event.casualties.fatality_breakdown` return that structured breakdown
        with its claim, quote, evidence, and scope.
        Source records include a public Internet Archive snapshot URL when
        available, or a structured Wayback status when capture is pending,
        blocked, limited, or failed. CCA's raw evidence copy remains gated.
        The top-level `incident_id`, `claims`, `entities`, and optional
        restricted values always belong to the requested source record. The
        nested `event`, `event_summary`, `event_claims`, and `event_entities`
        fields aggregate current public members without changing source-record
        identity. Non-public person labels remain generic unless admitted by the
        audited public-record naming policy. `event_summary.time` keeps
        source-supported occurrence, start, and end times separate from article
        publication dates. Entity attributes are scoped to their entity type;
        malformed legacy dispositions and cross-entity extraction debris are
        excluded from public summaries. Event evidence is paginated separately.
      security:
        - {}
        - restrictedApiKey: []
      parameters:
        - { $ref: '#/components/parameters/IncidentId' }
        - { name: include, in: query, schema: { type: string, enum: [pii] } }
        - { name: event_source_page, in: query, schema: { type: integer, minimum: 1, maximum: 10000, default: 1 } }
        - { name: event_source_per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 100 } }
        - { name: X-PII-Purpose, in: header, schema: { type: string }, description: Required with include=pii }
      responses:
        '200': { description: Incident record }
        '401': { description: Restricted access authentication missing }
        '403': { description: Restricted access denied }
        '404': { description: Incident not found }
  /incidents/{incident_id}/provenance:
    get:
      tags: [Provenance]
      summary: Fact-to-source provenance and archive hashes
      parameters: [ { $ref: '#/components/parameters/IncidentId' } ]
      responses:
        '200': { description: Public provenance }
  /schools/{school_mci}/incidents:
    get:
      tags: [Schools]
      summary: Search incidents by exact CCA Schools MCI
      description: |
        Returns nearest and 0.5/1/5 km mappings. Distance alone never sets
        `school_related_status=explicit`; explicit source evidence is required.
      parameters:
        - { name: school_mci, in: path, required: true, schema: { type: string }, example: pbed-791d5nzvkmxo }
        - { name: page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
      responses:
        '200': { description: Related incidents }
  /schools/search:
    get:
      tags: [Schools]
      summary: Query alias for school-ID incident search
      parameters:
        - { name: school_id, in: query, required: true, schema: { type: string } }
        - { name: page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
      responses:
        '200': { description: Related incidents }
  /changes:
    get:
      tags: [Changes]
      summary: Permanent public field-level change feed
      parameters:
        - { name: page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
      responses:
        '200': { description: Changes }
  /stats:
    get:
      tags: [Statistics]
      summary: Precomputed verified-only statistics
      parameters:
        - { name: interval, in: query, schema: { type: string, enum: [day, month], default: month } }
        - { name: include_unverified, in: query, schema: { type: boolean, default: false } }
      responses:
        '200': { description: Statistics with scope warning }
  /analytics:
    get:
      tags: [Statistics]
      summary: Filter and group durable event-level research projections
      description: |
        Answers deterministic count and "most common" questions over durable
        event projections derived from collected public source records. Source
        records remain available as provenance and are never deleted merely
        because they refer to the same event. Dates are inclusive. Verified
        events are the default.
        Set `verification_scope=all_published` to include partially verified and
        unverified published records.

        `count_mode=strict_events` counts one row per current durable event
        projection. Event identities use explicit CCA School MCIs, audited
        public-record subjects, exact public venue labels, precise normalized
        addresses, or an isolated record fallback. `source_record_count`,
        `strict_event_count`, and `possible_duplicate_records` remain visible.
        Use `count_mode=records` only when the number of underlying
        source-derived records is itself the research question.

        Classifications are tri-state source-supported facts. Unknown is never
        treated as false. `exclude_classification=school_related` therefore
        requires explicit evidence that the event was not school-related; events
        with unknown school status are excluded from both the positive and
        negative result.

        Firearm normalization recognizes common aliases such as `9 mm` → `9mm`
        and `5.56` → `5.56x45mm`. Ownership is one of `personally_owned`,
        `family_owned`, `stolen`, `borrowed`, or `other`; absent evidence remains
        unknown and is reported in `data_quality`.

        Every response includes `answerability`. The default
        `answer_scope=strict_corpus` requires every collected event in scope to
        have explicit values for all fields needed by the query.
        `status=insufficient_coverage` means `count` and grouped values are
        source-supported lower bounds, not definitive corpus totals.

        `answer_scope=known_values` instead returns a definitive count only
        within records carrying every explicit value required by the query.
        Unknown values are excluded, `corpus_definitive` remains false, and
        `coverage_limitations` reports the known/total denominator. This mode is
        appropriate for questions such as "most common documented calibre"; it
        must never be described as the distribution of all incidents. A numeric
        zero in this mode means no qualifying documented values, not that none
        occurred in the collected corpus or real world.

        `attribution.evidence_manifest_url` preserves the exact filters and
        points to a cursor-paginated manifest of every counted event. Manifest
        pages include every member source record, public source URL, archive
        hashes, Wayback status, and the verbatim source quotes behind public
        claims. Counts therefore remain reproducible without trusting an opaque
        aggregate.

        Example question patterns (all examples are non-identifying):

        ```text
        # Shooting count in an inclusive date range
        /analytics?date_from=2024-10-12&date_to=2025-01-17

        # School-related count; Monday-only count
        /analytics?answer_scope=known_values&classification=school_related&date_from=2024-10-12&date_to=2025-01-17
        /analytics?answer_scope=known_values&classification=school_related&day_of_week=Monday&date_from=2024-10-22&date_to=2025-01-11

        # Gang-related weekend count
        /analytics?answer_scope=known_values&classification=gang_related&day_type=weekend&date_from=2008-10-12&date_to=2025-01-29

        # Most common weekday or month
        /analytics?answer_scope=known_values&classification=school_related&group_by=weekday
        /analytics?answer_scope=known_values&classification=school_related&group_by=month
        /analytics?answer_scope=known_values&classification=gang_related&group_by=weekday

        # School incidents with calibre or multiple firearms/types
        /analytics?answer_scope=known_values&classification=school_related&calibre=9mm
        /analytics?answer_scope=known_values&classification=school_related&calibre=5.56
        /analytics?answer_scope=known_values&classification=school_related&min_firearms=2
        /analytics?answer_scope=known_values&classification=school_related&min_firearm_types=2

        # Events explicitly established as non-school in selected ZIP codes
        /analytics?answer_scope=known_values&exclude_classification=school_related&postal_codes=10001,10002&date_from=2024-01-01&date_to=2024-12-31

        # Ownership breakdown and street-violence calibre breakdowns
        /analytics?answer_scope=known_values&group_by=ownership&date_from=2024-01-01&date_to=2024-12-31
        /analytics?answer_scope=known_values&classification=street_violence&firearm_type=handgun&group_by=calibre
        /analytics?answer_scope=known_values&classification=street_violence&firearm_type=shotgun&group_by=calibre
        /analytics?answer_scope=known_values&classification=street_violence&firearm_type=rifle&group_by=calibre
        /analytics?answer_scope=known_values&classification=street_violence&group_by=calibre
        ```
      parameters:
        - { name: date_from, in: query, schema: { type: string, format: date }, description: Inclusive lower bound }
        - { name: date_to, in: query, schema: { type: string, format: date }, description: Inclusive upper bound }
        - { name: classification, in: query, schema: { $ref: '#/components/schemas/AnalyticClassification' } }
        - { name: exclude_classification, in: query, schema: { $ref: '#/components/schemas/AnalyticClassification' } }
        - { name: day_of_week, in: query, schema: { type: string }, description: Comma-separated Sunday-Saturday or 0-6 }
        - { name: day_type, in: query, schema: { type: string, enum: [weekday, weekend] } }
        - { name: postal_codes, in: query, schema: { type: string }, description: Comma-separated exact postal codes }
        - { name: calibre, in: query, schema: { type: string } }
        - { name: firearm_type, in: query, schema: { type: string, enum: [handgun, rifle, shotgun, other] } }
        - { name: ownership, in: query, schema: { type: string, enum: [personally_owned, family_owned, stolen, borrowed, other] } }
        - { name: min_firearms, in: query, schema: { type: integer, minimum: 1, maximum: 100 } }
        - { name: min_firearm_types, in: query, schema: { type: integer, minimum: 1, maximum: 20 } }
        - { name: group_by, in: query, schema: { type: string, enum: [none, weekday, month, calibre, firearm_type, ownership], default: none } }
        - { name: verification_scope, in: query, schema: { type: string, enum: [verified, all_published], default: verified } }
        - { name: count_mode, in: query, schema: { type: string, enum: [strict_events, records], default: strict_events } }
        - { name: answer_scope, in: query, schema: { type: string, enum: [strict_corpus, known_values], default: strict_corpus }, description: Strict collected-corpus total or explicitly documented-value subset }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
      responses:
        '200': { description: Event count or bounded lower estimate, answerability requirements, evidence-manifest URL, groups, filters, and field coverage }
        '400': { description: Invalid filter combination }
  /analytics/evidence:
    get:
      tags: [Provenance, Statistics]
      summary: Enumerate complete source attribution for an analytics result
      description: |
        Uses the same filters and answer-scope semantics as `/analytics`.
        Cursor pagination enumerates every event included in the selected
        collected-corpus result. Each event includes all member source-record
        IDs, incident and provenance permalinks, original public source URLs,
        archive hashes and status, and verbatim source quotes for public claims.
        Restricted values and gated evidence bytes are never returned. Cursors
        are bound to the normalized query and event-projection revision; if the
        corpus changes mid-export the API returns `409` and requires a restart,
        rather than silently omitting or duplicating events.
      parameters:
        - { name: date_from, in: query, schema: { type: string, format: date }, description: Inclusive lower bound }
        - { name: date_to, in: query, schema: { type: string, format: date }, description: Inclusive upper bound }
        - { name: classification, in: query, schema: { $ref: '#/components/schemas/AnalyticClassification' } }
        - { name: exclude_classification, in: query, schema: { $ref: '#/components/schemas/AnalyticClassification' } }
        - { name: day_of_week, in: query, schema: { type: string } }
        - { name: day_type, in: query, schema: { type: string, enum: [weekday, weekend] } }
        - { name: postal_codes, in: query, schema: { type: string } }
        - { name: calibre, in: query, schema: { type: string } }
        - { name: firearm_type, in: query, schema: { type: string, enum: [handgun, rifle, shotgun, other] } }
        - { name: ownership, in: query, schema: { type: string, enum: [personally_owned, family_owned, stolen, borrowed, other] } }
        - { name: min_firearms, in: query, schema: { type: integer, minimum: 1, maximum: 100 } }
        - { name: min_firearm_types, in: query, schema: { type: integer, minimum: 1, maximum: 20 } }
        - { name: group_by, in: query, schema: { type: string, enum: [none, weekday, month, calibre, firearm_type, ownership], default: none } }
        - { name: verification_scope, in: query, schema: { type: string, enum: [verified, all_published], default: verified } }
        - { name: count_mode, in: query, schema: { type: string, enum: [strict_events, records], default: strict_events } }
        - { name: answer_scope, in: query, schema: { type: string, enum: [strict_corpus, known_values], default: strict_corpus } }
        - { name: cursor, in: query, schema: { type: string }, description: Opaque cursor returned by the previous page }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 10, default: 5 } }
      responses:
        '200': { description: Analytics summary plus an attributed event-membership page }
        '400': { description: Invalid filter or cursor }
        '409': { description: Corpus changed during cursor pagination; restart from the first page }
  /trends:
    get:
      tags: [Statistics]
      summary: Alias for precomputed time-series statistics
      parameters:
        - { name: interval, in: query, schema: { type: string, enum: [day, month], default: month } }
        - { name: include_unverified, in: query, schema: { type: boolean, default: false } }
      responses:
        '200': { description: Trends }
  /provenance/blocklist:
    get:
      tags: [Provenance]
      summary: Restricted source domains
      responses:
        '200': { description: Immutable prohibited and discovery-only source policy }
components:
  securitySchemes:
    restrictedApiKey:
      type: http
      scheme: bearer
      bearerFormat: CCA restricted API key
  parameters:
    IncidentId:
      name: incident_id
      in: path
      required: true
      schema: { type: string, format: uuid }
  schemas:
    ConfidenceLabel:
      type: string
      enum: [confirmed, probable, possible, speculative]
      description: Confidence is not certainty. Read the linked limitations.
    VerificationStatus:
      type: string
      enum: [verified, partially_verified, unverified, historical_unverified, disputed]
    AnalyticClassification:
      type: string
      enum: [school_related, gang_related, street_violence]
    Envelope:
      type: object
      required: [request_status, message, data, meta]
      properties:
        request_status: { type: string, enum: [success, error] }
        message: { type: string }
        error_code: { type: string }
        data: {}
        meta:
          type: object
          properties:
            limitations:
              type: object
              description: Required reminder and link to the limitations registry.
              properties:
                version: { type: string }
                url: { type: string, format: uri }
                required_reading: { type: string }
