Extracting Structured Fields From a Building Permit Inspection Card
9 min read · updated August 11, 2026
A permit inspection card is filled in a line at a time over six months by half a dozen different people with different pens. Extracting it as a set of fields loses the one property that makes it worth extracting: the order.
The card is a ledger, not a form
The header block behaves like a form — permit number, parcel or address, contractor, scope of work, issue date — and it is the easy half. Everything below it is an append-only log. Each row is an inspection event: a type, a date, a result, an inspector’s initials or badge number, and often a correction note squeezed into the margin. The card is the physical embodiment of a project’s history, and the reason a jurisdiction requires it to be posted on site is precisely that the sequence is checkable at a glance.
That tells you the shape of the output before you write a prompt. One object for the permit, one array of event records beneath it, each event carrying its own date and result. Do not fold the log into per-type fields like framing_result and framing_date: the moment an inspection is failed and repeated — which is the normal case, not the exception — that schema has to choose which of two truths to keep.
One row per attempt, not per inspection type
A failed rough-electrical inspection followed by a corrected re-inspect produces two rows with the same type and different dates and results. Both matter. The first is the record that a defect existed; the second is the record that it was cleared. Downstream questions differ: a close-out check wants the latest row per type, a quality or claims question wants the count of failures, and a scheduling question wants the gap between them.
{
"permit_number": "B26-014882",
"jurisdiction": "City of Example, Building Safety Division",
"site_address": "118 Sample Ave",
"inspections": [
{ "seq": 1, "type": "Footing", "date": "2026-01-19",
"result": "approved", "inspector": "R.M.", "notes": null },
{ "seq": 2, "type": "Rough Electrical", "date": "2026-02-24",
"result": "corrections_required", "inspector": "T.K.",
"notes": "GFCI missing, kitchen island" },
{ "seq": 3, "type": "Rough Electrical", "date": "2026-03-02",
"result": "approved", "inspector": "T.K.", "notes": null },
{ "seq": 4, "type": "Insulation", "date": "2026-03-05",
"result": "approved", "inspector": "R.M.", "notes": null }
]
}seq is the row’s position on the card, not a derived ordering. Keep it. Rows are sometimes written out of date order — an inspector uses the next blank line regardless — and the difference between “the card is in date order” and “the card is not” is itself a signal worth preserving. Sort by date for analysis; keep position for provenance, so a human opening the scan can find the line you are talking about.
The result vocabulary is local
There is no national result vocabulary. Cards variously print Approved, Passed, Partial, Corrections Required, Not Ready, Cancelled, Rejected, Disapproved, and the inspector may write “OK”, “OK to cover” or a tick instead. Two of those are not failures in the sense people assume: Partial means part of the work was approved and the rest still needs inspecting, and Not Ready usually means the inspector arrived and the work was not presentable, which is a scheduling event rather than a defect.
So normalise to your own enum, and store the raw string alongside it. Mapping Not Ready onto failed quietly turns a wasted trip into a code violation in your data. This is the same discipline as any other controlled-vocabulary mapping: the normalised value is a convenience, the verbatim value is the evidence. Where the mapping is uncertain, that uncertainty belongs at the field level, which is what per-field confidence scoring is for.
The sequencing check
This is the validator the document gives you for free, and it is document-specific in a way a generic schema check is not. Construction inspection order is not arbitrary: work that will be concealed must be inspected before it is concealed. The model codes published by the International Code Council state the required inspections and their order — the residential provisions at IRC section R109 and the building provisions at IBC section 110 — and jurisdictions adopt them with local amendments. The ICC’s published code library is the authority for the model text; the authority having jurisdiction is the authority for what actually applies to this permit.
The practical form of the check is a small prerequisite graph. Footing and foundation precede any framing. Rough electrical, plumbing and mechanical precede insulation. Insulation precedes wall cover. Final follows everything. Given that graph, a row where Insulation is approved on 3 March and Rough Plumbing is approved on 11 March is either a transcription error in your extraction or a real anomaly on the card, and both are worth surfacing.
- Most hits are date reads, not violations. A handwritten 3/2 read as 3/12, or a two-digit year crossing a January boundary, produces exactly this signature — which is an argument for running the standard date-field validation rules before the sequencing check rather than after it. Route the flag to a person with both dates highlighted on the source image, not to a rejection.
- Absence is not a failure. Not every permit requires every inspection. A missing insulation row on a deck permit means nothing. Run the check only over prerequisites for types that are actually present.
- Do not enforce the model order as the local order.Local amendments add and reorder inspections. Make the graph a per-jurisdiction configuration and say in your own documentation where it came from.
Ink, initials and the second card
Three physical properties of the artefact cause most of the remaining errors. First, the card is handwritten by rotating staff, so the log is mixed-hand handwriting, not print; the realistic accuracy ceiling and the ways it fails are the subject of handwriting recognition with a language model, and initials in particular are two ambiguous glyphs with no redundancy, which is why an inspector identifier should be validated against the jurisdiction’s roster rather than trusted as read.
Second, the card lives outdoors in a weather sleeve for months. Faded rows, water damage and a stamp printed over handwriting are ordinary, and the correct output for an illegible row is a row explicitly marked illegible, not a blank. A dropped row breaks the sequencing check silently, which is worse than a row that says it could not be read.
Third, cards run out of lines. A long project produces a second card, or a continuation sheet stapled to the first, and the two are separate images that belong to one permit. Group by permit number before you build the event array, and expect the header to be abbreviated or absent on the continuation. If your pipeline treats one file as one document, this is where it produces two permits with half a history each — the same joining problem described in document ingestion, arriving through a stapler.