# Working with incomplete results

> An incomplete search is labelled, never silently short. This guide turns the labels into a retry policy.

- **HTML:** https://offendersearch.app/docs/guides/sex-offender/incomplete-results
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## When to use this

Most searches finish: all 58 registries answer and `status` is `complete`. When one does not — a registry was slow, a common-name query matched more candidate rows than one search may examine, a backend fault — the response says so in three places at once: `status: "partial"`, `counts.sourcesIncomplete`, and a per-registry `sourceStatus[].incompleteReason`. You will build the branch that reads those, decides whether the answer is usable, and retries only the registries whose reason says a retry will help.

## Before you start

- The sandbox **never** answers `partial`, `429` or `504` — production does not answer that way for the fixed test people, so neither does the sandbox. The partial and error bodies in this guide are the production shapes from the contract; test the branch with a fixture in your own suite.
- `incompleteReason` is a closed vocabulary of six values: `deadline`, `truncated`, `excluded`, `not_searched`, `unavailable`, `error`. It is non-null exactly when `sourceStatus[].incomplete` is `true`.
- Two request fields shape the behaviour: `deadlineMs` (1000–600000, default 120000) and `onDeadline` (`partial` default, or `error`).

> **The one thing people get wrong.** `sourceStatus[].status: "ok"` does not mean the registry finished. `status` says the registry **responded**; `incomplete` says whether it was searched **to the end**. A registry that timed out still reads `ok`. Branch on `incomplete` and `incompleteReason`, never on `status` or on the free-text `note`.

## Step by step

1. **Send `onDeadline` explicitly.** Decide what you want when a registry cannot finish. `"onDeadline": "partial"` (sent explicitly) returns whatever completed and opts out of the empty-result `504`; `"onDeadline": "error"` turns a forced-partial answer into a `504` so nothing partial ever reaches your code.
2. **Read the envelope.** If `status` is `partial`, `records[]` is a lower bound. Collect the rows with `incomplete: true` and group them by `incompleteReason`.
3. **Retry what retrying fixes.** Re-run the same request (or raise `deadlineMs`) for `deadline`, `unavailable` and `error`; narrow the query for `truncated`; remove the option for `excluded`; schedule later for `not_searched`.
4. **Handle the two error statuses.** A `504` with `detail.code: "incomplete_no_results"` means the search found nothing before some registries finished; a `429` with `detail.code: "overloaded"` means the service refused or could not finish under load. Both carry the registries involved; neither is a clear answer.

### Step 1 — the request

*POST /v1/search body*
```json
{
  "query": { "firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12" },
  "deadlineMs": 120000,
  "onDeadline": "partial"
}
```

`deadlineMs` is a target, not a cut-off: since 2026-10-15 a search that can still finish keeps working past it, up to the roughly 40 seconds the gateway allows, so that you get a complete answer rather than a partial one. Values above ~40 s do not extend the search; values below 1000 or above 600000 are a `422`.

### Step 2 — a partial answer (production shape)

*200 with status partial (trimmed to the rows that matter)*
```json
{
  "searchId": "srch_2b7e4c91d0a3f58e6c1b9d47",
  "status": "partial",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 56, "sourcesIncomplete": 2, "sourcesSkippedByScope": 0 },
  "sourceStatus": [
    { "source": "CA", "status": "ok", "matched": 0, "fromCache": true, "note": null, "incomplete": true, "incompleteReason": "deadline", "cappedOmitted": false },
    { "source": "FL", "status": "ok", "matched": 0, "fromCache": true, "note": null, "incomplete": true, "incompleteReason": "truncated", "cappedOmitted": false },
    { "source": "OH", "status": "ok", "matched": 1, "fromCache": true, "note": null, "incomplete": false, "incompleteReason": null, "cappedOmitted": false }
  ],
  "records": [ { "recordId": "rec_28db9639d712c4c61e8e", "matchState": "dob_match", "matchConfidence": 0.9 } ],
  "warnings": [
    "INCOMPLETE SEARCH: 2 of 58 registries could not be searched to completion (CA, FL). Records from those registries may be missing; this result is a lower bound."
  ]
}
```

One registrant was found in OH, and two registries did not finish. The right outcome is both: the OH candidate is real and must be reviewed, **and** the answer is a lower bound. Report `candidates` with the incomplete list attached; never `clear`. If `records` had been empty, the outcome would be `incomplete`, not `clear`.

### Step 3 — the retry policy in code

**Python**
```python
import os, time, requests

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
H = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}
RETRY_SAME = {"deadline", "unavailable", "error"}     # a second identical pass can finish these

def search(body):
    r = requests.post(BASE + "/v1/search", headers=H, json={**body, "onDeadline": "partial"}, timeout=90)
    if r.status_code == 429:
        raise RuntimeError("overloaded; wait Retry-After=%s" % r.headers.get("Retry-After"))
    if r.status_code == 504:
        raise RuntimeError("incomplete_no_results: %s" % r.json()["detail"])
    r.raise_for_status()
    return r.json()

def screen(body, passes=2):
    result = search(body)
    for _ in range(passes - 1):
        if result["status"] == "complete":
            break
        unfinished = [s for s in result["sourceStatus"] if s["incomplete"]]
        if not any(s["incompleteReason"] in RETRY_SAME for s in unfinished):
            break                                       # truncated / excluded / not_searched: retrying will not help
        time.sleep(2)
        again = search({**body, "jurisdictions": [s["source"] for s in unfinished]})
        result["records"] = merge(result["records"], again["records"])
        result["sourceStatus"] = [s for s in result["sourceStatus"] if not s["incomplete"]] + again["sourceStatus"]
        result["status"] = "complete" if not any(s["incomplete"] for s in result["sourceStatus"]) else "partial"
    unfinished = [s["source"] for s in result["sourceStatus"] if s["incomplete"]]
    reasons = {s["source"]: s["incompleteReason"] for s in result["sourceStatus"] if s["incomplete"]}
    if result["records"]:
        return {"outcome": "candidates", "records": result["records"], "unfinished": unfinished, "reasons": reasons}
    return {"outcome": "clear" if not unfinished else "incomplete", "unfinished": unfinished, "reasons": reasons}

def merge(a, b):
    seen = {r["personRef"] for r in a}
    return a + [r for r in b if r["personRef"] not in seen]
```

**JavaScript**
```javascript
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': process.env.OFFENDERSEARCH_KEY, 'Content-Type': 'application/json' };
const RETRY_SAME = new Set(['deadline', 'unavailable', 'error']);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function search(body) {
  const res = await fetch(BASE + '/v1/search', { method: 'POST', headers: H, body: JSON.stringify({ ...body, onDeadline: 'partial' }) });
  if (res.status === 429) throw new Error('overloaded; wait Retry-After=' + res.headers.get('Retry-After'));
  if (res.status === 504) throw new Error('incomplete_no_results: ' + JSON.stringify((await res.json()).detail));
  if (!res.ok) throw new Error('HTTP ' + res.status);
  return res.json();
}

async function screen(body, passes = 2) {
  let result = await search(body);
  for (let i = 1; i < passes && result.status !== 'complete'; i++) {
    const unfinished = result.sourceStatus.filter((s) => s.incomplete);
    if (!unfinished.some((s) => RETRY_SAME.has(s.incompleteReason))) break;   // truncated / excluded / not_searched
    await sleep(2000);
    const again = await search({ ...body, jurisdictions: unfinished.map((s) => s.source) });
    const seen = new Set(result.records.map((r) => r.personRef));
    result.records.push(...again.records.filter((r) => !seen.has(r.personRef)));
    result.sourceStatus = [...result.sourceStatus.filter((s) => !s.incomplete), ...again.sourceStatus];
    result.status = result.sourceStatus.some((s) => s.incomplete) ? 'partial' : 'complete';
  }
  const unfinished = result.sourceStatus.filter((s) => s.incomplete).map((s) => s.source);
  const reasons = Object.fromEntries(result.sourceStatus.filter((s) => s.incomplete).map((s) => [s.source, s.incompleteReason]));
  if (result.records.length) return { outcome: 'candidates', records: result.records, unfinished, reasons };
  return { outcome: unfinished.length ? 'incomplete' : 'clear', unfinished, reasons };
}
```

The second pass re-runs only the unfinished registries by naming them in `jurisdictions`, so it is cheap and does not disturb the registries that already answered. Two passes is a reasonable ceiling; a registry that is still `unavailable` after that is alerting on the platform side and should be recorded as unfinished, not retried in a loop.

### Step 4 — the two error statuses (production shapes)

**504 incomplete_no_results**
```json
{
  "detail": {
    "code": "incomplete_no_results",
    "message": "The search found no records, but 3 of 58 registries did not finish; this is not evidence that the person is unregistered.",
    "unfinishedSources": ["CA", "FL", "NY"],
    "sourcesQueried": 58
  }
}
```

**429 overloaded**
```json
// Retry-After: 12
{
  "detail": {
    "code": "overloaded",
    "message": "The service is at capacity for this request. Retry after 12 seconds.",
    "retryAfterSeconds": 12,
    "queueDepth": 41,
    "predictedWaitSeconds": 48.5
  }
}
```

**504 onDeadline=error**
```json
{ "detail": "Deadline hit and onDeadline=error" }
```

The `504 incomplete_no_results` fires only when `records` would have been empty **and** a registry stopped for `deadline` or `truncated` — the one combination that looks like a clear and is not. Sending `"onDeadline": "partial"` explicitly opts out of it and gives you the partial body instead. Under load, both this case and `onDeadline: "error"` answer `429` with `Retry-After` rather than `504`; a `429` was not billed and the same request is safe to retry after the wait.

## Reading the result

| `incompleteReason` | What happened | What to do |
| --- | --- | --- |
| `deadline` | The registry did not answer within its per-registry deadline. | Retry the same request, or raise `deadlineMs`. |
| `truncated` | The query matched more candidate rows than one search may examine; only the first window was searched and it is not ranked. | Do not retry. Narrow: add a surname, a `firstName`, a `dob`/`age`, a place, or name registries. |
| `excluded` | A retired option on your request dropped this registry (`onStale: "omit"`). | Remove the option. |
| `not_searched` | Nothing was available to search for this registry; a coverage failure that already alerts. | Treat as a lower bound; re-run later, not immediately. |
| `unavailable` | A transient backend or transport fault reading this registry. | Retry. |
| `error` | The registry returned an error for this request. | Retry with backoff; a persistent one is alerting. |

| Envelope field | Read it as |
| --- | --- |
| `status` | `complete` ⇔ `sourcesComplete == sourcesQueried`. `partial` covers everything from one slow registry to almost none answering. |
| `counts.sourcesComplete / sourcesQueried` | The ratio to build policy on: accept a high one, hold-and-retry a low one. Do not compute it as `sourcesQueried − sourcesIncomplete`; `restricted` and `no_coverage` rows set no `incomplete` flag. |
| `warnings[0]` | Begins `INCOMPLETE SEARCH:` and names the registries, for logs and reviewers. Prose; branch on the enum. |
| `capped` / `cappedOmitted` | A different condition: the registries finished, the response was too large. Narrow or paginate; see [Pagination](/docs/pagination). |

## Edge cases and pitfalls

- **`partial` is never about data age.** A registry read ten minutes ago can be incomplete; one read yesterday was searched to the end and its zero is real. Currency is `GET /v1/sources` → `health.lastSuccessAt` and `record.sources[].lastCheckedAt`.
- **`broad` at national scope is the common cause of `truncated`** — it cannot use the indexed surname range. Scope it or use `balanced`.
- **A `dob` is never what gets cut.** Under `truncated`, records whose birth year matches yours are examined first; the cut falls on the unknown-birth-date group.
- **Records plus incomplete registries is a real state.** Do not resolve it to one word; carry both the candidates and the unfinished list.
- **`onDeadline` must be spelled exactly**: `"fail"` is a `422` (`Unknown onDeadline 'fail'. Allowed: error, partial.`).
- **Live rows have their own partials**: a failed live source is `status: "partial"`, `incomplete: true`, `fromCache: false`, and is not billed. See [Regular vs. live searches](/docs/guides/sex-offender/regular-vs-live).
- **Batch rows carry no `sourceStatus`.** A row's `counts.sourcesIncomplete` and `warnings[]` are its only completeness signals.

## Production checklist

- `onDeadline` sent explicitly on every search.
- The `partial` branch exists, is tested with a fixture, and never reports `clear`.
- Retry logic keyed on `incompleteReason`; at most two passes; `429` honours `Retry-After`.
- Unfinished registries are stored with the outcome and visible to reviewers.

## Related

- [Result completeness & per-source status](https://offendersearch.app/docs/result-completeness)
- [Errors, status codes & rate limits](https://offendersearch.app/docs/errors)
- [Error handling](https://offendersearch.app/docs/guides/sex-offender/error-handling)
- [Pagination & response size](https://offendersearch.app/docs/pagination)
