# Data Quality

Data completeness and confidence metrics. Understand how thoroughly each record is populated and identify low-confidence entries that may need enrichment.

Source: https://docs.gunspec.io/en/api/data-quality

## Field Coverage

`GET /v1/data/coverage`

Auth: API key required  
Tier: Enterprise

Returns per-field completeness across the catalog, so you can tell which specifications are reliably populated before depending on them.

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.firearms` | object | Per-field completeness across the firearms table. |
| `data.firearms.total` | integer | Total records matching the query across every page. |
| `data.firearms.fields` | object | Per-field completeness, keyed by column name. |
| `data.calibers` | object | Per-field completeness across the calibers table. |
| `data.calibers.total` | integer | Total records matching the query across every page. |
| `data.calibers.fields` | object | Per-field completeness, keyed by column name. |
| `data.manufacturers` | object | Per-field completeness across the manufacturers table. |
| `data.manufacturers.total` | integer | Total records matching the query across every page. |
| `data.manufacturers.fields` | object | Per-field completeness, keyed by column name. |

```json
{
  "success": true,
  "data": {
    "firearms": {
      "total": 9162,
      "fields": {
        "year_introduced": {
          "filled": 8140,
          "percentage": 88.8
        }
      }
    },
    "calibers": {
      "total": 9162,
      "fields": {
        "year_introduced": {
          "filled": 8140,
          "percentage": 88.8
        }
      }
    },
    "manufacturers": {
      "total": 9162,
      "fields": {
        "year_introduced": {
          "filled": 8140,
          "percentage": 88.8
        }
      }
    }
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Enterprise** tier.
- Coverage percentages are rounded to one decimal place.
- Use this endpoint to monitor data quality over time or prioritize data enrichment efforts.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/data/coverage' \
  --header 'X-API-Key: your_key'
```

## Catalog Gaps

`GET /v1/data/gaps`

Auth: Optional API key  
Tier: Explorer+

Two kinds of problem, reported separately.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `entity` | string | no | Report on one catalog instead of all of them. Every check is a scan of a catalog table, so a scoped call runs only the checks that concern that catalog and is the cheaper request by a wide margin. Omitted, the whole report comes back. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.scope` | string | Which catalog this report covers, or `all`. Echoed so a stored copy still says what it describes. |
| `data.schedule` | object | Two different times, deliberately separate. The gap report is recomputed on every request, so `computedAt` describes this body. The daily snapshot is what has a last run and a next run, and it is what `/v1/data/gaps/history` records. |
| `data.schedule.computedAt` | string | When this response was computed. Not when you read it: the response is edge-cached for a minute, so a body can be up to that old. |
| `data.schedule.cadence` | string | How often the recorded snapshot is taken. |
| `data.schedule.lastRunAt` | string \| null | When the last snapshot was stored, or null before the job has ever run. |
| `data.schedule.lastRunOn` | string \| null | The UTC day that snapshot describes. |
| `data.schedule.nextRunAt` | string | When the next snapshot is due. |
| `data.schedule.sourceAuditAt` | string \| null | When the daily source audit last judged the catalog's citations, or null before it has ever run. Its own time rather than the snapshot's: `record-cites-an-authority` reads a table a separate job writes, and the two run in parallel. |
| `data.entities` | object | Keyed by entity. Each carries its total and, per tracked field, how many records lack it. A field is missing when it is null **or empty**, so a column full of empty strings does not read as complete. |
| `data.media` | object[] | Visual coverage, one entry per catalog that stores media - firearms today, and an empty array for a scope that stores none. A catalog with no pictures and a catalog that does not hold pictures are different answers, so absence is never reported as zero coverage.  Each kind is counted twice: `records` is how many records have at least one asset of that kind, which is the coverage figure, and `assets` is how many files exist, which is larger wherever a record carries several. `none` is how many records have nothing to show at all.  This is what field coverage cannot report. A firearm's line drawing is a column, but its photographs, renders and 3D models are rows in a media table, so "does this record have a render" is not a question a field count can answer. |
| `data.media[].entity` | string | Which catalog this covers. |
| `data.media[].total` | number | How many records that catalog holds. |
| `data.media[].kinds` | object | Keyed by kind: `silhouette`, `photo`, `render`, `model`, `schematic`. Each carries `records`, `assets` and `coveragePct`. |
| `data.media[].none` | number | Records with no drawing, photograph, render, model or schematic. |
| `data.checks` | object[] | One entry per integrity check, each naming what passing means so a failure is actionable without reading the source. |
| `data.checks[].id` | string | Stable id, usable at /v1/data/gaps/checks/{checkId}. |
| `data.checks[].describes` | string | What passing means. |
| `data.checks[].status` | string | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data.checks[].found` | number | How many records failed. |
| `data.checks[].sample` | string[] | Up to five failing ids. Ask the check endpoint for the rest. |
| `data.checks[].values` | object[] | The distinct values behind the failure, present only on a check about a value rather than a record. A vocabulary failure is decided once per spelling rather than once per record, so thirty-four records reporting nineteen spellings are nineteen decisions, and ids alone would mean fetching thirty-four records to recover them. Absent on every other check rather than empty, so a check with no values to report and a check that found none cannot be confused. |
| `data.linkHealth` | object | Reserved for whether each cited website still resolves, which a scheduled job will fill. Reports `unavailable` with a reason until then. |

```json
{
  "success": true,
  "data": {
    "scope": "manufacturers",
    "schedule": {
      "computedAt": "2026-09-15T02:44:57.731Z",
      "cadence": "daily",
      "lastRunAt": "2026-09-15 03:00:12",
      "lastRunOn": "2026-09-15",
      "nextRunAt": "2026-09-16T03:00:00.000Z",
      "sourceAuditAt": "2026-09-15 03:00:41"
    },
    "entities": {},
    "media": [
      {
        "entity": "firearms",
        "total": 9162,
        "kinds": {},
        "none": 6159
      }
    ],
    "checks": [
      {
        "id": "firearm-manufacturer-exists",
        "describes": "Every firearm is attributed to a manufacturer that exists in the catalog.",
        "status": "pass",
        "found": 0,
        "sample": [
          "string"
        ],
        "values": [
          {
            "value": "dao",
            "count": 15
          }
        ]
      }
    ],
    "linkHealth": {}
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/data/gaps' \
  --header 'X-API-Key: your_key'
```

## List data remediation tasks

`GET /v1/data/tasks`

Auth: Optional API key  
Tier: Explorer+

The work generated from what the quality checks find, and where each item has got to.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | no | Only tasks in this state. |
| `entity` | string | no | Only tasks for one catalog. |
| `limit` | integer | no | How many to return. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.tasks` | object[] | Most recently seen first. |
| `data.tasks[].taskKey` | string | Stable id, derived from the finding rather than assigned, so the same finding is the same task tomorrow. |
| `data.tasks[].kind` | string | What the asset is: `silhouette`, `render`, `photo`, `schematic`, `model`. `/v1/firearms/{id}/media/{selector}` addresses an asset by this. |
| `data.tasks[].entity` | string | Which catalog. |
| `data.tasks[].subject` | string | The record, or for a vocabulary task the value being decided. |
| `data.tasks[].finding` | string \| null | The check id or field this came from. |
| `data.tasks[].status` | string | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data.tasks[].records` | number | How many records this would fix. One for most; a vocabulary decision covers every record holding that spelling. |
| `data.tasks[].openedAt` | string | When the finding was first seen. |
| `data.tasks[].startedAt` | string \| null | When a worker began, or null. |
| `data.tasks[].closedAt` | string \| null | When it closed, or null. |
| `data.tasks[].hasResult` | boolean | Whether a worker has filed a conclusion. Never what it says. |
| `data.tasks[].assignedTo` | string \| null | Which kind of worker holds the task: `redback`, the platform's agent, or `staff`, a person. Null when nobody does. Never which agent or which person. |

```json
{
  "success": true,
  "data": {
    "tasks": [
      {
        "taskKey": "contradiction:caliber-confidence-has-a-source:12-gauge-3-5",
        "kind": "contradiction",
        "entity": "calibers",
        "subject": "12-gauge-3-5",
        "finding": "caliber-confidence-has-a-source",
        "status": "open",
        "records": 1,
        "openedAt": "2026-09-15T03:00:12Z",
        "startedAt": "2026-09-15T09:14:00Z",
        "closedAt": null,
        "hasResult": false,
        "assignedTo": "redback"
      }
    ]
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/data/tasks' \
  --header 'X-API-Key: your_key'
```

## Count data tasks per finding

`GET /v1/data/tasks/summary`

Auth: Optional API key  
Tier: Explorer+

How many remediation tasks each finding has, by state, for a page that lists the checks and wants a marker on each. A finding is a check id, or the field for a gap. Keyless, on the same reasoning as the task list; counts reveal nothing a task does not.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `entity` | string | no | Only tasks for one catalog. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.findings` | object | Finding id -> state -> number of tasks. Keys are dynamic; a state with no tasks is absent rather than zero. |

```json
{
  "success": true,
  "data": {
    "findings": {
      "year_introduced": {
        "open": 1840,
        "resolved": 12
      },
      "bullet-type-is-drawable": {
        "open": 9
      }
    }
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/data/tasks/summary' \
  --header 'X-API-Key: your_key'
```

## Get one data remediation task

`GET /v1/data/tasks/{taskKey}`

Auth: Optional API key  
Tier: Explorer+

Where one task has got to, for a reader following a finding. The same narrow view the list returns: no payload, no claimant, no proposal.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `taskKey` | string | yes | The task id, as the list reports it. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.task` | object | The task, in the public shape the list returns. |

```json
{
  "success": true,
  "data": {
    "task": {}
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/data/tasks/contradiction%3Acaliber-confidence-has-a-source%3A12-gauge-3-5' \
  --header 'X-API-Key: your_key'
```

## Quality History

`GET /v1/data/gaps/history`

Auth: Optional API key  
Tier: Explorer+

One row per day, oldest first, from the daily snapshot the platform stores. `/v1/data/gaps` recomputes live and keeps nothing, so it can say what is missing now and not whether that is better or worse than last month.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `days` | integer | no | How many days back to read. One row per day, so 90 is a quarter. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.days` | integer | The window that was read. |
| `data.runs` | object[] | One row per day, oldest first. |
| `data.runs[].ranOn` | string | The UTC day this row describes. |
| `data.runs[].ranAt` | string | When the snapshot was taken. |
| `data.runs[].scope` | string | Which catalog the run covered. |
| `data.runs[].checksTotal` | integer | How many integrity checks ran. |
| `data.runs[].checksFailing` | integer | How many of them failed. |
| `data.runs[].recordsFailing` | integer | How many records were behind those failures, summed across checks. |
| `data.runs[].coveragePct` | number | Weighted field coverage across every tracked catalog, as a percentage of possible values. Null before any field is tracked. |

```json
{
  "success": true,
  "data": {
    "days": 90,
    "runs": [
      {
        "ranOn": "2026-09-15",
        "ranAt": "2026-09-15 03:00:00",
        "scope": "all",
        "checksTotal": 8,
        "checksFailing": 1,
        "recordsFailing": 3,
        "coveragePct": 62.4
      }
    ]
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/data/gaps/history' \
  --header 'X-API-Key: your_key'
```

## Records Missing a Field

`GET /v1/data/gaps/records`

Auth: API key required  
Tier: Enterprise

The ids lacking a given field, paginated. This is the half a machine consumes: ask which manufacturers have no website, get the ids back, hand them to whatever fills them in.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `entity` | string | yes | Which catalog to report on |
| `field` | string | yes | The field the records are missing. Which fields are valid depends on `entity`, since each catalog tracks different columns: a field the named entity does not track is refused with 400 and the error lists the ones it does. |
| `page` | integer | no | Page number, from 1 to 10,000 |
| `per_page` | integer | no | Items per page (max 100) |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | One page of results |
| `data[].id` | string | Slug of the record. |
| `data[].name` | string | Its name, so a worklist is readable without a second lookup. |
| `data[].field` | string | The field this row is missing. Echoed so a stored batch still says what it was collected for. |
| `data[].value` | string \| null | What the record holds in that field. Always null here: the query selects records missing it, so this says "no value" rather than "not looked at". |
| `data[].sources` | string[] | The pages the record already cites. Carried so a consumer can judge whether the missing value is findable without fetching the record first. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "accu-tek",
      "name": "Accu-Tek",
      "field": "website",
      "value": null,
      "sources": [
        "https://www.accu-tekfirearms.com/"
      ]
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 42,
    "totalPages": 3
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/data/gaps/records?entity=manufacturers&field=website' \
  --header 'X-API-Key: your_key'
```

## Records Failing a Check

`GET /v1/data/gaps/checks/{checkId}`

Auth: API key required  
Tier: Enterprise

Every failing id rather than the sample `/v1/data/gaps` carries, for a check you have decided to act on.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `checkId` | string | yes | The check id, as `/v1/data/gaps` reports it. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.id` | string | The check. |
| `data.describes` | string | What passing means. |
| `data.ids` | string[] | Every failing record. |

```json
{
  "success": true,
  "data": {
    "id": "firearm-manufacturer-exists",
    "describes": "Every firearm is attributed to a manufacturer that exists in the catalog.",
    "ids": [
      "string"
    ]
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/data/gaps/checks/firearm-manufacturer-exists' \
  --header 'X-API-Key: your_key'
```

## Low Confidence Records

`GET /v1/data/confidence`

Auth: API key required  
Tier: Enterprise

Returns a confidence score per firearm derived from source count and field completeness, for filtering out thinly sourced records.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `below` | number | no | Only return records scoring below this threshold |
| `page` | integer | no | Page number, from 1 to 10,000 |
| `per_page` | integer | no | Items per page (max 100) |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | One page of results |
| `data[].id` | string | Slug of the firearm. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].data_confidence` | number | How completely the record is specified and how well it is sourced, 0 to 1. A record-level completeness and provenance measure, **not** a per-field probability of correctness. `dataConfidence` on the camelCase endpoints. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "glock-17-gen5",
      "name": "Glock 17 Gen5",
      "data_confidence": 0.42
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 42,
    "totalPages": 3
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Enterprise** tier.
- Results are sorted by confidence ascending (lowest confidence first).
- Confidence scores are computed from field completeness, source count, and cross-reference consistency.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/data/confidence?below=0.3&per_page=10' \
  --header 'X-API-Key: your_key'
```
