# Result completeness & per-source status

> Every response labels which sources completed. counts, sourceStatus and the closed incompleteReason enum tell you whether an empty result is an answer.

- **HTML:** https://offendersearch.app/docs/result-completeness
- **Base URL:** https://api.offendersearch.app
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **All documentation as markdown:** https://offendersearch.app/docs.md

## Per-source status on every response

Every jurisdiction a search touches is reported individually in `sourceStatus[]`, on every response, whether or not it contributed a record. That is what makes a result auditable: a search is never a single number you have to trust, and an incomplete search is always labelled as one.

| status | Meaning |
| --- | --- |
| `ok` | Queried successfully; matched holds the count from this source. |
| `pending` | Still running (async searches) — results not yet in. |
| `error` | This source did not complete for this request; see note. |
| `restricted` | This source is not commercially available for this request; see note. |
| `no_coverage` | The source does not cover the queried location. |

**A complete search**

```json
{
  "searchId": "srch_9f2a7c",
  "status": "complete",
  "counts": {
    "records": 1,
    "sourcesQueried": 2,
    "sourcesComplete": 2,
    "sourcesIncomplete": 0,
    "sourcesSkippedByScope": 0
  },
  "warnings": [],
  "sourceStatus": [
    { "source": "NJ", "status": "ok", "matched": 1,
      "lastCheckedAt": "2026-08-13T04:12:00Z",
      "incomplete": false, "incompleteReason": null },
    { "source": "NSOPW", "status": "ok", "matched": 1,
      "lastCheckedAt": "2026-08-13T04:20:00Z",
      "incomplete": false, "incompleteReason": null }
  ],
  "records": []
}
```

**A labelled partial search**

```json
{
  "searchId": "srch_31c9de",
  "status": "partial",
  "counts": {
    "records": 4,
    "sourcesQueried": 58,
    "sourcesComplete": 56,
    "sourcesIncomplete": 2,
    "sourcesSkippedByScope": 0
  },
  "warnings": [
    "2 of 58 sources did not complete: TX, WA."
  ],
  "sourceStatus": [
    { "source": "TX", "status": "ok", "matched": 0,
      "incomplete": true, "incompleteReason": "truncated" },
    { "source": "WA", "status": "error", "matched": 0,
      "incomplete": true, "incompleteReason": "unavailable",
      "note": "source did not complete for this request" },
    { "source": "NJ", "status": "ok", "matched": 4,
      "incomplete": false, "incompleteReason": null }
  ],
  "records": []
}
```

**Read `sourceStatus[].incomplete`, not `status`.** `sourceStatus[].status` describes whether the source responded; `incomplete` describes whether the search of that source finished. A source can be `"ok"` and `incomplete: true` at the same time.

## Reading an empty result

An empty `records` array is not, on its own, evidence that a person is not registered. Two very different events produce the same empty list, and the response tells you which one you are holding.

| What happened | What the response says |
| --- | --- |
| Every jurisdiction completed and nobody matched. **This is an answer.** | `status: "complete"`, `counts.sourcesIncomplete: 0`, `counts.sourcesComplete === counts.sourcesQueried` |
| One or more jurisdictions did not complete. **This is a lower bound.** | `status: "partial"`, `counts.sourcesIncomplete > 0`, and a `warnings[]` sentence naming the jurisdictions |

Never infer absence without checking `counts.sourcesIncomplete === 0` first. Record an incomplete search as *not determined* and re-query more narrowly.

```javascript
const { status, counts, records } = await search(query);

if (counts.sourcesIncomplete > 0) {
  // Not an answer about anyone: a lower bound.
  return { outcome: "not_determined", counts };
}
if (records.length === 0) {
  // Every queried source completed and nobody matched.
  return { outcome: "no_match", counts };
}
return { outcome: "matches", records, counts };
```

## counts — how much of the search finished

`status` is one word, and one word cannot tell you how partial a partial result is. A search that reached 57 of 58 jurisdictions and one that reached 7 of 58 both say `"partial"`, and they are very different answers.

| Key | Type | Meaning |
| --- | --- | --- |
| `records` | `integer` | How many records matched in TOTAL. This is the whole result set, not the length of one page — records.length equals it unless you asked for a page slice. |
| `sourcesQueried` | `integer` | How many jurisdictions this request fanned out to. 58 on an unscoped search; fewer when you set jurisdictions or locationScoped. |
| `sourcesComplete` | `integer` | How many of those jurisdictions completed. THIS IS THE NUMBER TO ACT ON. status is a single word — "partial" reads identically whether 1 of 58 sources fell short or 51 did, and those are very different answers. sourcesComplete makes them distinguishable: 57 of 58 is a result you can act on; 7 of 58 is one to retry. status: "complete" means exactly sourcesComplete === sourcesQueried. |
| `sourcesIncomplete` | `integer` | The complement: how many jurisdictions did not complete. Anything above 0 means the records you received are a LOWER BOUND rather than a closed answer. Each one is named in sourceStatus[] with an incompleteReason. |
| `sourcesSkippedByScope` | `integer` | How many jurisdictions were not queried because YOUR request narrowed the fan-out — locationScoped: true, or an explicit jurisdictions list. It is separate from sourcesIncomplete on purpose: a jurisdiction you chose not to query is a scope decision, not an incomplete one. 0 on a nationwide search. |

`status: "complete"` means exactly `counts.sourcesComplete === counts.sourcesQueried`. If you log one number per search, log that ratio rather than the word.

## incompleteReason — the closed enum

A closed enum of six values, total over the condition it describes: `incomplete: true` always carries exactly one of these, and `incomplete: false` always carries `null`. An exhaustive switch on this field is safe to write; adding a seventh value would be a breaking change and is treated as one.

| Value | What to do | What it means |
| --- | --- | --- |
| `deadline` | Retry the same request | This source did not complete within the time bound set for this request, so it contributed no records. Transient — the identical request will usually complete. |
| `truncated` | Narrow the query — a retry returns the same answer | The query matches more candidate rows inside this one jurisdiction than a single search examines, and the examined set is not ranked, so a matching person can fall outside it. Deterministic: the identical request returns the identical answer. Add a first name, a date of birth or an age, or a city, and search again. |
| `excluded` | Remove the parameter from your request | A parameter on your request (onStale: "omit") excluded this source from the search. Remove it and this source is queried again. |
| `not_searched` | Treat as not determined; retry later | This source did not contribute to this request, so a zero from it is not an answer about anyone. |
| `unavailable` | Retry with backoff | This source did not respond to this request and contributed no records. |
| `error` | Retry with backoff | This source returned no usable answer for this request and contributed no records. |

## The defined per-source limits

Two per-jurisdiction limits are defined in the contract so that one over-broad query cannot degrade the service for everyone. Both are always reported, never silent.

| Limit | Value | What reaches it |
| --- | --- | --- |
| `truncated` | 15,000 candidate rows per jurisdiction | Your query matches more rows inside that one jurisdiction than a single search examines. The examined set is not ranked, so a matching person can fall outside it. |
| `truncated` | 2,000 typo candidates per jurisdiction | `match: "broad"` only. Broad evaluates a second, speculative set of candidates for spelling variants of your surname, with its own smaller budget. |
| `deadline` | 15 seconds per jurisdiction | That jurisdiction did not complete within the bound and contributed zero records. |

A `dob` or `age` is never what gets displaced: when your query constrains the birth year, the records whose birth year matches are examined first, to the full 15,000.

**A cap is not a page.** `perPage` slices an answer you can walk in full — `counts.records` is the true total and `page = 1 … totalPages` returns every record exactly once. A candidate limit is the opposite: there is no next 15,000 to ask for. That is why it is reported and paging is not.

### Which query shapes reach a limit

| Query shape | Behaviour |
| --- | --- |
| `lastName` + `state` | **Safest.** Use this shape for any automated reconciliation job. |
| `lastName` alone | Fine for most surnames. Reaches the limit on short or very common surname tokens — Lee, Ford, Hill, Wood, Ward, Ray, West, James, Allen, Thomas — because alias and address matching widen the reach of a surname. |
| `firstName` with no `lastName` | **Declined with `422`.** A first name on its own has nothing to narrow on. Pair it with a surname, or with a date of birth or age. |
| `city`, `zipCode`, `dob` or `age` with no name | Reaches the limit in large jurisdictions — a single birth year still matches tens of thousands of people. With a name, the date is never what is displaced. |

**On the compatibility endpoint.** `/v1/compat/sexoffender` mirrors the legacy envelope, which has room for exactly one completeness signal: the integer `error: 503`. `{"offenders": [], "error": 503}` means nothing was established — not that nobody matched. Call `POST /v1/search` when you need to tell those two apart.

---

## Related

- Previous: [Searching by date of birth](https://offendersearch.app/docs/date-of-birth.md)
- Next: [Pagination & response size](https://offendersearch.app/docs/pagination.md)
- Index: [Offendersearch API documentation](https://offendersearch.app/docs.md)
