# Handling partial results

> A cached search is always complete. A live check can fall short at one source, and when it does the envelope says exactly where, why, and whether a retry is worth it.

- **HTML:** https://offendersearch.app/docs/guides/criminal/partial-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

## What you'll build

The completeness branch of a live-verification integration: code that never treats `records: []` as "clear" until it has checked that every source finished, that knows which `incompleteReason` values are worth a retry and which are not, and that tells a reviewer which sources did and did not answer. The discipline is the same as the Sex Offender API's, with one addition particular to criminal: a source that could not be checked live may be answered from its stored copy instead, and the row says so.

## Before you start

- A live key, eventually. The sandbox never simulates a short source — every requested sandbox source answers complete — so the `partial` branch can be exercised only against `https://api.offendersearch.app` with codes from `GET /v1/criminal/jurisdictions?live=true`. Build and unit-test the branch against the bodies below.
- The vocabulary: [Completeness & source status](/docs/criminal/result-completeness) and the [Cached vs. live verification](/docs/guides/criminal/cached-vs-live) guide.

> **partial is about live sources only — and it is a lower bound.** `status: "partial"` means at least one live source has `incomplete: true`. The records you did get are real; the ones you did not get are unknown. An empty `records[]` on a `partial` answer is not evidence of absence. A cached search (no `live` block) is never `partial`.

## Step by step

1. **Check status before records.** `complete` → every live source finished (or was answered from file). `partial` → walk `sourceStatus[]`.
2. **Classify each sourceStatus row.** `ok: true, incomplete: false, live: true` → searched live to the end. `incomplete: true` → read `incompleteReason`. `live: false, answeredFrom: "on_file"` → the live check did not run and the stored copy answered; `liveOutcome` says why.
3. **Read warnings[].** One sentence per condition, loudest first: `INCOMPLETE SEARCH:` names the short sources; `LIVE CHECK NOT AVAILABLE:` names the on-file fallbacks; `WEBSITE NOT COVERED:` names an unsupported `query.website`. `[]` is a valid completeness check.
4. **Decide: retry, narrow, or accept.** Transient reasons → resend the same request. Structural reasons → accept the stored copy or drop the source. `truncated_over_cap` → narrow the query.

**Python**
```python
import requests

API, KEY = "https://api.offendersearch.app", "os_live_your_key"   # the sandbox never produces partial
RETRYABLE = {"timeout", "source_down", "rate_limited", "captcha_unsolved", "proxy_required"}

def classify(env):
    """Return (outcome, rows_to_retry, notes) for a live search envelope."""
    retry, notes = [], []
    for row in env["sourceStatus"]:
        code = row["code"] or "(unsupported site)"
        if row.get("answeredFrom") == "on_file":
            notes.append(f"{code}: answered from the stored copy (live check {row.get('liveOutcome')})")
        elif row["incomplete"]:
            why = row["incompleteReason"]
            notes.append(f"{code}: incomplete — {why}")
            if why in RETRYABLE:
                retry.append(code)
        else:
            notes.append(f"{code}: searched live, {row['records']} record(s)")
    if env["status"] == "complete":
        outcome = "clear" if env["counts"]["records"] == 0 else "records"
    else:
        outcome = "incomplete" if env["counts"]["records"] == 0 else "records_lower_bound"
    return outcome, retry, notes

body = {"query": {"lastName": "Rivera", "firstName": "Jordan", "dob": "1988-04-12", "state": "TX"},
        "live": {"jurisdictions": ["TX-BEXAR", "TX-DOC"]}}
r = requests.post(f"{API}/v1/criminal/search", headers={"X-API-Key": KEY}, json=body, timeout=90)
r.raise_for_status()                      # a 202 would need the async poller
env = r.json()
outcome, retry, notes = classify(env)
print(outcome, "| retry:", retry)
print("\n".join(env["warnings"] + notes))
if retry:
    env2 = requests.post(f"{API}/v1/criminal/search", headers={"X-API-Key": KEY}, json=body, timeout=90).json()
    print("after retry:", env2["status"], env2["counts"]["sourcesIncomplete"], "still incomplete")
```

**JavaScript**
```javascript
const API = 'https://api.offendersearch.app'; // the sandbox never produces partial
const H = { 'X-API-Key': 'os_live_your_key', 'Content-Type': 'application/json' };
const RETRYABLE = new Set(['timeout', 'source_down', 'rate_limited', 'captcha_unsolved', 'proxy_required']);

function classify(env) {
  const retry = [], notes = [];
  for (const row of env.sourceStatus) {
    const code = row.code ?? '(unsupported site)';
    if (row.answeredFrom === 'on_file') notes.push(code + ': answered from the stored copy (live check ' + row.liveOutcome + ')');
    else if (row.incomplete) { notes.push(code + ': incomplete — ' + row.incompleteReason); if (RETRYABLE.has(row.incompleteReason)) retry.push(code); }
    else notes.push(code + ': searched live, ' + row.records + ' record(s)');
  }
  const none = env.counts.records === 0;
  const outcome = env.status === 'complete' ? (none ? 'clear' : 'records') : (none ? 'incomplete' : 'records_lower_bound');
  return { outcome, retry, notes };
}

const body = { query: { lastName: 'Rivera', firstName: 'Jordan', dob: '1988-04-12', state: 'TX' }, live: { jurisdictions: ['TX-BEXAR', 'TX-DOC'] } };
const r = await fetch(API + '/v1/criminal/search', { method: 'POST', headers: H, body: JSON.stringify(body) });
if (r.status !== 200) throw new Error('HTTP ' + r.status); // a 202 would need the async poller
const env = await r.json();
const { outcome, retry, notes } = classify(env);
console.log(outcome, '| retry:', retry);
console.log([...env.warnings, ...notes].join('\n'));
```

*A partial live answer — one source timed out (production shape, trimmed)*
```json
{
  "status": "partial",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 2, "sourcesComplete": 1, "sourcesIncomplete": 1,
              "candidatesCapped": false, "exclusionsIncluded": false },
  "warnings": [
    "INCOMPLETE SEARCH: 1 of 2 sources could not be searched to completion (TX-DOC). The records below are a LOWER BOUND: an absent person may simply not have been reached. This is NOT evidence that someone is absent — retry, or narrow the query."
  ],
  "sourceStatus": [
    { "code": "TX-BEXAR", "source": "TX-BEXAR", "ok": true,  "incomplete": false, "incompleteReason": null,      "records": 1, "lastCheckedAt": "2026-10-25T15:04:11+00:00", "live": true },
    { "code": "TX-DOC",   "source": "TX-DOC",   "ok": false, "incomplete": true,  "incompleteReason": "timeout", "records": 0, "lastCheckedAt": "2026-10-25T15:04:11+00:00", "live": true }
  ],
  "records": [ { "recordCategory": "jail", "liveChecked": true, "sources": [ { "code": "TX-BEXAR", "live": true, "…": "…" } ], "…": "…" } ],
  "usage": { "liveChecks": 1, "liveChargeUsd": 0.02, "capped": false, "display": "1 live checks · $0.02" },
  "meterEvents": [ { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null },
                   { "kind": "criminal_live_source", "quantity": 1, "chargeCeilingUsd": 2.0 } ]
}
```

*The on-file fallback — status stays complete, the row says live: false*
```json
{
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 2, "sourcesComplete": 2, "sourcesIncomplete": 0, "…": "…" },
  "warnings": [
    "LIVE CHECK NOT AVAILABLE: TX-DOC could not be checked live (not_searched) and was answered from the stored copy. …"
  ],
  "sourceStatus": [
    { "code": "TX-BEXAR", "source": "TX-BEXAR", "ok": true, "incomplete": false, "incompleteReason": null, "records": 1, "lastCheckedAt": "2026-10-25T15:04:11+00:00", "live": true },
    { "code": "TX-DOC",   "source": "TX-DOC",   "ok": true, "incomplete": false, "incompleteReason": null, "records": 0, "lastCheckedAt": "2026-10-25T15:04:11+00:00",
      "live": false, "answeredFrom": "on_file", "liveOutcome": "not_searched" }
  ],
  "usage": { "liveChecks": 1, "liveChargeUsd": 0.02, "capped": false, "display": "1 live checks · $0.02" }
}
```

In the second body the Texas prison system was not checked live (it has no live connector in scope, hence `not_searched`), but its stored records were in the query's state scope, so the row was answered from file: `ok: true`, `incomplete: false`, `live: false`, not billed — `usage.liveChecks` counts only the one real live check. The `warnings[]` sentence is quoted in shape; read the real one from the response.

## Reading the result

| Field | Values | Decision |
| --- | --- | --- |
| `status` | `complete` · `partial` | `partial` iff any row has `incomplete: true`. The gate for "may I call this clear". |
| `counts.sourcesQueried` / `sourcesComplete` / `sourcesIncomplete` | integers | Live rows, finished rows, short rows. `sourcesIncomplete > 0` → lower bound. |
| `sourceStatus[].code` | public code or `null` | `null` only on an `unsupported_site` row from `live.websites`. |
| `sourceStatus[].ok` / `incomplete` | booleans | `ok` = reached and read; `incomplete` = not searched to the end. Read `incomplete`, not `ok`, for completeness. |
| `sourceStatus[].incompleteReason` | `timeout` · `source_down` · `rate_limited` · `captcha_unsolved` · `proxy_required` · `paid_gateway` · `login_required` · `truncated_over_cap` · `unsupported_site` · `not_searched` | Transient (first five): resend the same request. Structural (`paid_gateway`, `login_required`, `unsupported_site`, `not_searched`): a retry will not help. `truncated_over_cap`: narrow the query. Non-null only when `incomplete`. |
| `sourceStatus[].records` | integer | Matcher survivors from this source; a lower bound on an incomplete row. |
| `sourceStatus[].live` | boolean | `true` = checked live now. `false` = on-file fallback row. |
| `sourceStatus[].answeredFrom` / `liveOutcome` | `on_file` / an `incompleteReason` token | Present only on a fallback row. The stored copy answered; `liveOutcome` is why the live check did not run (`not_searched` when the source has no live connector). |
| `warnings[]` | `INCOMPLETE SEARCH:` · `LIVE CHECK NOT AVAILABLE:` · `WEBSITE NOT COVERED:` | Human sentences, loudest first. Show them to the reviewer verbatim. |
| `usage.liveChecks` | integer | Completed live checks only. Failed and on-file rows are not billed. |

## Edge cases and pitfalls

- **The service already retries transient failures once.** Within a synchronous live request, sources that failed with `source_down`, `timeout`, `captcha_unsolved` or `rate_limited` are re-tried once inside a 20-second window before the answer is built. A `partial` you receive has therefore already survived one retry; back off before yours.
- **`not_searched` is immediate, not a timeout.** A source with no live connector, or a job that hit its source cap, reports `not_searched` at once. Check `GET /v1/criminal/jurisdictions?live=true` before asking for it live, or accept the on-file answer.
- **An entirely unsupported `live.websites` list is `200 partial`, not `422`.** Each site becomes a `code: null` row with `unsupported_site`; `resolution[]` says what each mapped to. One unrecognised jurisdiction code among good ones is an `ok: false` row; a list where nothing resolves is `422 no_live_sources`.
- **A complete job can hold a partial result.** On an async job, the poll's JobRecord says `status: "complete"` while `result.status` may be `partial`. Read the inner status.
- **`records[]` on a partial answer are real.** Act on what you have — a `dob_match` from the source that did answer is a finding — and separately record which sources are outstanding.
- **Partial is never billed for the failed source.** `meterEvents[].criminal_live_source.quantity` and `usage.liveChecks` count completed sources only; the `criminal_call` is still metered.
- **Batch is never partial.** No live in batch, so a `partial` row cannot occur; a row that could not run is `not_run` instead.

## Production checklist

- Gate "clear" on `status == "complete"` and `counts.records == 0`; anything else is "records" or "incomplete".
- Retry transient `incompleteReason` values once with backoff; log structural ones and move on.
- Persist `sourceStatus[]` and `warnings[]` with the decision; a reviewer must be able to see which sources answered.
- Treat an `answeredFrom: "on_file"` row as a stored-copy answer in your outcome text, not as a live confirmation.
- Alert on a rising share of `partial` answers for a source you depend on; pair it with the catalog's `health`.

## Related

- [Completeness & source status](https://offendersearch.app/docs/criminal/result-completeness): the reference
- [Cached vs. live verification](https://offendersearch.app/docs/guides/criminal/cached-vs-live)
- [Async & long-running](https://offendersearch.app/docs/criminal/async)
- [Jurisdictions & codes](https://offendersearch.app/docs/criminal/jurisdictions)
