Extracting Structured Fields From a Chain of Custody Form
9 min read · updated August 11, 2026
Everything else on a chain of custody form is metadata. The document exists to assert one thing — that the item was never unaccounted for — and that assertion lives in the ordering of the transfer rows, not in any single field. An extraction that returns a flat object with a “collected by” and a “received by” has thrown away the evidence.
The document is a sequence, not a field set
Custody forms differ by domain — the Federal Drug Testing Custody and Control Form used for US Department of Transportation workplace testing, an environmental laboratory’s sampling form, a forensic evidence log, a clinical specimen manifest — but they share one shape. A header identifies the item with a number that also appears on the item itself: a specimen ID printed on the seal, an evidence bag number, a sample identifier stencilled on a bottle. Beneath it is a table with one row per handoff. The whole document is a linked list, serialised onto paper, and the header field is the key that ties the list to a physical object.
That means the output schema is an object with an array, and the array is ordered. Model it as { item_id, item_id_source, transfers: [...] } where each transfer carries its own index. Flattening the transfers into columns — released_by_1, received_by_1, released_by_2 — works until a form with seven handoffs arrives, and it makes the continuity check below awkward to write — the general trade-off between a nested shape and a flattened one is its own page. If you need a refresher on shaping the target before you prompt for it, the library’s schema design page covers the general case and this page will not repeat it.
The header also carries the number that makes the document worth anything. On the DOT form the collector is required to confirm that the specimen ID number preprinted at the top matches the number on the seal placed over the specimen bottle, and the form travels as a multi-part set with copies routed to the laboratory, the medical review officer, the collector, the employer and the donor. If your scan is one copy of a five-part set, the fields the other copies carry are simply not there, and an extractor that reports them as empty rather than as absent will look like it failed.
What one transfer row actually contains
A transfer row is two half-rows printed side by side, and this is the single most common source of wrong output. The left half is the release: who relinquished the item, their signature, the date and time. The right half is the receipt: who took it, their signature, the date and time. Visually they are one row. Semantically they are two different events by two different people that happen to be simultaneous.
Reading order breaks this constantly. A model that flattens the table left-to-right pairs the releaser’s name with the receiver’s timestamp, or reads the two signature boxes as one field. The fix is not a better prompt; it is extracting with coordinates so that a cell is assigned to a column by its horizontal position rather than by where it landed in the text stream. This is the same coordinate-clustering problem that breaks two-column PDFs, and the same remedy applies.
Per row, the fields worth naming explicitly:
- released_by — printed name, and separately the organisation. The printed name and the signature are two fields and you want both, because the check in the next section runs on names and the signature only proves the row was executed.
- released_signature_present — a boolean, not text. Asking a model to transcribe a signature produces confident nonsense; asking whether ink is present in the region is a different, answerable question.
- received_by and received_signature_present — the mirror image.
- timestamp — one per half-row, not one per row.
- purpose — free text, often a controlled vocabulary in practice: analysis, storage, transport, disposal, return to submitter.
The continuity check
Here is the whole point of the exercise. If the item was never unaccounted for, then whoever received it in row n must be whoever released it in row n+1. Custody is a path through a graph and a valid form has no jump between non-adjacent parties.
function custodyGaps(transfers) {
const gaps = [];
for (let i = 0; i < transfers.length - 1; i++) {
const receiver = norm(transfers[i].received_by);
const nextReleaser = norm(transfers[i + 1].released_by);
if (!receiver || !nextReleaser) {
gaps.push({ at: i, kind: "missing_endpoint" });
continue;
}
if (receiver !== nextReleaser) {
gaps.push({
at: i,
kind: "party_jump",
received_by: transfers[i].received_by,
next_released_by: transfers[i + 1].released_by,
});
}
}
return gaps;
}
// Names are handwritten. Compare on a normalised form, and treat a
// near-match as a review item rather than as a pass or a failure.
const norm = (s) =>
(s || "").toLowerCase().replace(/[^a-z]/g, "");Two things about that comparison are worth being deliberate about. First, exact string equality is too strict for handwriting: the same person signs “J. Okonkwo” on one line and “Jane Okonkwo” on the next, and a strict comparison reports a custody break that does not exist. Second, fuzzy equality is too loose to be the final word, because the failure this check exists to catch is precisely a plausible-looking name substitution. The workable answer is three states — match, mismatch, and near-match routed to a human — which is the same tri-state that field-level confidence feeds elsewhere.
Run the same loop over organisations as a secondary signal. A transfer where the names differ but the organisation is constant is usually a shift change inside one laboratory, which is a weaker finding than a jump between two organisations.
Where the chain legitimately looks broken
A validator that flags every discontinuity will flag mostly valid forms, because there is one handoff where nobody signs for receipt: shipping. When a courier takes the item, the released-by half is signed and the received-by half is a tracking number, a seal number, or the words “shipped via overnight carrier, seal intact on arrival.” The chain is maintained by the seal rather than by a signature — that is why the seal number is on the form at all.
So the check needs a third row type. Classify each transfer as person_to_person, shipment, or storage, and apply continuity only across the person-to-person edges, with a shipment edge satisfied instead by a seal or tracking identifier present on both sides of it. A storage row — item placed in a locked freezer, item removed from the same freezer — has the same releaser and receiver by design and would otherwise be a false positive in the opposite direction.
Other genuine, non-fraudulent oddities to expect:
- A continuation form. The item is on its second sheet and row 1 of sheet 2 has a releaser who never appears as a receiver on that sheet. Detect this by looking for a page-of-page marker in the header and refusing to run continuity on a fragment.
- A row with a receiver and no releaser at the top of the table, because the first event is collection rather than transfer. Seed the chain with the collector from the header rather than reporting a gap.
- A voided row, struck through with initials. It is still ink on the page and a transcription model will happily return it as live. Strike-through detection belongs at the region level, and a struck row that is silently included corrupts the whole sequence after it.
Dates, times and the ordering rule
The second check is monotonicity: timestamps must not go backwards along the chain. It is cheap and it catches transcription errors that the name check cannot, because a digit inverted in a year or a month usually produces an impossible ordering.
Getting there requires resolving two ambiguities that these forms make worse than most. Times are frequently written without a meridiem marker on a form printed with a 12-hour box, so “7:15” could be either — leave it as a partial time with an explicit precision flag rather than picking one. And dates handwritten as 03/04/25 are ambiguous between US and day-first order unless the form is one where the printed field label resolves it, which the DOT form and most laboratory forms do by labelling the boxes. Where the label resolves it, parse against the label and record which convention you used. Where it does not, a chain of three or more rows often resolves it for you: only one reading of the whole sequence is monotonic.
Report the outcome as a document-level status with the individual findings attached — complete, gap_at_index, out_of_order, unsigned_row — rather than as a single confidence number. The person who reviews these is deciding whether the item is admissible or the sample is defensible, and “0.87” does not help them do that.