Skip to content
Core Architecture

How to Map DVIR Fields to FMCSA 396.11 Requirements

A mobile inspection payload is not a compliant record until every field it carries is bound to a specific obligation in 49 CFR § 396.11, and a single unmapped field — a missing repair certification, an ambiguous timestamp, an empty defect array read as “no data” instead of “no defects” — is the difference between a clean report and a Vehicle Maintenance BASIC violation surfacing at a roadside inspection. This page answers one focused question: given a raw JSON payload from a driver’s device, exactly which fields must you extract, what must each become, and which § 396.11 clause justifies rejecting or holding the record when a field is absent. Getting the mapping wrong does not fail loudly at ingestion; it fails months later during a DOT audit when the carrier cannot produce the signature chain § 396.11©(2) demands. This how-to sits under the clause-level FMCSA DVIR Rule 396.11 Breakdown reference and implements the field-level contract that breakdown specifies.

Before implementing the mapping layer you need Python 3.10+ (for match statements and X | Y union syntax) and the following:

  • pydantic>=2.6 — runtime validation and strict type coercion for the ingestion contract.
  • python-dateutil>=2.9 — timezone-aware ISO 8601 parsing for offline timestamp reconciliation.
  • hashlib (stdlib) — SHA-256 hashing for signature tamper-evidence and idempotency keys.
  • A low-latency store (Redis or equivalent) for the idempotency key cache described below.

Two upstream contracts feed this page. The canonical record shape you coerce every payload into is defined by the Standardized DVIR JSON Schema Design, and the defect vocabulary each defect_code must resolve against comes from Defect Taxonomy Mapping for Heavy Trucks. Field-level cleanup of driver-entered strings (odometer formatting, unit-number casing) is handled upstream by Automated Field Mapping & Data Normalization, so this layer receives clean strings and concerns itself only with binding them to regulation.

Each mapping decision traces to an exact clause. Keep this table as the authoritative source; the code below implements it field for field.

Raw payload field Maps to Type / constraint CFR clause Action if absent or invalid
driver_id driver_cdl_hash str (SHA-256) § 396.11(a) Reject the payload; return for authentication
vin / unit vehicle_vin, unit_number 17-char VIN, str § 396.11(a) Reject; an unresolved VIN cannot enter the pipeline
submitted_at inspection_timestamp_utc UTC datetime, offset ±12h § 396.11(a) Reject; ambiguous local time is not audit-defensible
defects defects[] list[Defect] § 396.11(a)(3) Coerce empty list to no_defects attestation
defects[].code defect_code controlled vocabulary § 396.11(a)(3) Reject the defect; unknown code cannot be scored
defects[].severity severity_band 0–34 / 35–69 / 70–100 § 396.11(a)(3) Route by band; 70–100 triggers an OOS hold
signature certification_signature base64 blob, SHA-256 hash § 396.11(a) Reject; unsigned report is not a report
repair repair_certification RepairCertification | None § 396.11©(2) Block return-to-service transition
review_sig review_signature Signature | None § 396.11©(2)(iii) Block finalization of the prior report

The single most-missed row is the empty defects array. A driver submitting no defects is making an affirmative attestation under § 396.11(a) that the vehicle is safe — not sending null data. Coerce an empty list into an explicit certification_status: "no_defects" flag so the record reads as a signed clean report, not an incomplete one.

Field-mapping pipeline binding a raw DVIR payload to 49 CFR § 396.11 A left-to-right pipeline. A raw mobile JSON payload enters four sequential mapping stages, each annotated with the § 396.11 clause it satisfies. Stage 1 resolves identity and normalizes the timestamp: the driver_id becomes a hashed CDL and the submitted_at field becomes a UTC datetime under § 396.11(a). Stage 2 coerces the defect array, turning an empty array into an explicit no_defects attestation under § 396.11(a)(3). Stage 3 hashes the signature, validating the base64 blob and producing a SHA-256 tamper-evidence hash under § 396.11(a). Stage 4 gates the repair-certification state transition, requiring the mechanic certification under § 396.11(c)(2) and the next-driver review signature under § 396.11(c)(2)(iii). The output is an audit-ready DVIRRecord. A rejection lane below the pipeline shows that a missing identity, a naive timestamp, an unsigned report, or an unknown defect code rejects the payload before it can reach the record. Raw JSON payload driver device STEP 1 Identity & time CDL → hash · UTC ts § 396.11(a) STEP 2 Defect array empty → no_defects § 396.11(a)(3) STEP 3 Signature hash base64 → SHA-256 § 396.11(a) STEP 4 Repair gate state transition § 396.11(c)(2) Audit-ready DVIRRecord signed · hashed · § 396.11-bound · idempotent Reject before persistence — payload never becomes a record Unresolved identity · naive timestamp · offset outside ±12h Unsigned report · unknown defect_code · signature is a URL not base64

Step 1 — Resolve identity and normalize the timestamp

Anchor link to "Step 1 — Resolve identity and normalize the timestamp"

Every payload must resolve to a hashed CDL and a fleet-registered VIN, and its timestamp must be normalized to UTC before persistence. § 396.11(a) requires an unambiguous per-driver, per-vehicle, per-day record, and auditors reject records with unnormalized local offsets.

python
import hashlib
from datetime import datetime, timezone
from dateutil import parser as dtparser


def hash_cdl(cdl_number: str) -> str:
    """Bind driver identity per 49 CFR § 396.11(a) without storing the raw CDL."""
    return hashlib.sha256(cdl_number.strip().upper().encode()).hexdigest()


def normalize_timestamp(raw: str) -> datetime:
    """Parse an ISO 8601 string and convert to UTC. Reject impossible offsets."""
    ts = dtparser.isoparse(raw)  # preserves the incoming tz offset
    if ts.tzinfo is None:
        # § 396.11(a): a naive local time is not audit-defensible.
        raise ValueError("timestamp missing timezone offset")
    offset_hours = ts.utcoffset().total_seconds() / 3600
    if not -12 <= offset_hours <= 12:
        raise ValueError(f"timezone_offset {offset_hours}h outside ±12h range")
    return ts.astimezone(timezone.utc)

Step 2 — Coerce the defect array

Anchor link to "Step 2 — Coerce the defect array"

Map each raw defect to the canonical triple, and turn an empty array into an explicit attestation rather than treating it as missing.

python
from enum import Enum
from pydantic import BaseModel, Field


class SeverityBand(str, Enum):
    MINOR = "minor"        # 0-34   -> scheduled maintenance
    MAJOR = "major"        # 35-69  -> regulated repair window
    CRITICAL = "critical"  # 70-100 -> immediate OOS hold, § 396.11(c)(2)


def band_for(score: int) -> SeverityBand:
    if score >= 70:
        return SeverityBand.CRITICAL
    if score >= 35:
        return SeverityBand.MAJOR
    return SeverityBand.MINOR


class Defect(BaseModel):
    defect_code: str = Field(min_length=1)          # § 396.11(a)(3)
    component_classification: str
    severity_score: int = Field(ge=0, le=100)

    @property
    def severity_band(self) -> SeverityBand:
        return band_for(self.severity_score)


def map_defects(raw_defects: list[dict]) -> tuple[list[Defect], str]:
    """Return (defects, certification_status). Empty -> affirmative no_defects."""
    if not raw_defects:
        # § 396.11(a): "no defects" is a signed attestation, not null data.
        return [], "no_defects"
    return [Defect(**d) for d in raw_defects], "defects_listed"

Step 3 — Validate and hash the signature

Anchor link to "Step 3 — Validate and hash the signature"

The driver certification signature must arrive as a base64 blob paired with a capture method, be size- and MIME-checked, and be SHA-256 hashed for tamper-evidence. A frequent failure is the mapping layer expecting base64 but receiving a raw CDN URL — normalize that before hashing.

python
import base64

MAX_SIGNATURE_BYTES = 2 * 1024 * 1024  # 2 MB ceiling
ALLOWED_METHODS = {"touchscreen", "stylus", "biometric"}


def map_signature(sig: dict) -> dict:
    """Validate, size-check and hash the § 396.11(a) driver attestation."""
    method = sig.get("capture_method")
    if method not in ALLOWED_METHODS:
        raise ValueError(f"unknown signature capture method: {method}")

    blob = sig.get("data", "")
    if blob.startswith(("http://", "https://")):
        # Fetch-and-re-encode belongs in a middleware step; never hash a URL.
        raise ValueError("signature is a URL, not a base64 payload")

    decoded = base64.b64decode(blob, validate=True)
    if len(decoded) > MAX_SIGNATURE_BYTES:
        raise ValueError("signature exceeds 2 MB ceiling")

    return {
        "capture_method": method,
        "signature_sha256": hashlib.sha256(decoded).hexdigest(),
    }

Step 4 — Gate the repair-certification state transition

Anchor link to "Step 4 — Gate the repair-certification state transition"

Carrier acknowledgment is a state machine, not a free-text field. § 396.11©(2) requires the carrier to certify a safety-affecting defect was repaired (or repair was unnecessary) before dispatch, and § 396.11©(2)(iii) requires the next driver to review and sign. Model the transitions explicitly and block dispatch until the certification chain is complete. The band routing here must match the Critical vs Non-Critical Routing Logic engine downstream.

python
class DVIRState(str, Enum):
    PENDING_REVIEW = "pending_review"
    DEFECT_CERTIFIED = "defect_certified"   # driver signed, repair outstanding
    REPAIR_COMPLETED = "repair_completed"    # § 396.11(c)(2) certification present
    RELEASED = "released"                     # next-driver review signed


def can_dispatch(state: DVIRState, has_critical_defect: bool) -> bool:
    """A unit with an unremediated critical defect must not be released."""
    if not has_critical_defect:
        return state in {DVIRState.PENDING_REVIEW, DVIRState.RELEASED}
    # § 396.11(c)(2): repair certified AND § 396.11(c)(2)(iii) review signed.
    return state is DVIRState.RELEASED

Any dispatch attempt against a unit in DEFECT_CERTIFIED with no repair_completed transition must return a 423 Locked (or 409 Conflict) with a machine-readable violation code, and every transition must append to an immutable, idempotent audit table. Derive the idempotency key from sha256(vehicle_vin + inspection_timestamp_utc + driver_cdl_hash) and cache it with a 24-hour TTL so a duplicate submission from a flaky cellular link cannot double-book a record.

Prove the mapping is correct with contract tests that assert each § 396.11 obligation, not just that the code runs. The empty-defect coercion and the dispatch gate are the two behaviors most worth pinning.

python
import pytest


def test_empty_defects_becomes_attestation():
    defects, status = map_defects([])
    assert defects == []
    assert status == "no_defects"  # § 396.11(a) affirmative certification


def test_critical_defect_blocks_dispatch_until_released():
    # A certified-but-unrepaired critical defect must stay held.
    assert can_dispatch(DVIRState.DEFECT_CERTIFIED, has_critical_defect=True) is False
    assert can_dispatch(DVIRState.RELEASED, has_critical_defect=True) is True


def test_naive_timestamp_is_rejected():
    with pytest.raises(ValueError):
        normalize_timestamp("2026-07-01T08:00:00")  # no offset -> reject


def test_idempotency_key_is_stable():
    def key(vin, ts, cdl):
        return hashlib.sha256(f"{vin}{ts}{cdl}".encode()).hexdigest()
    a = key("1FUJGLDR1CLBP8834", "2026-07-01T12:00:00Z", "abc")
    b = key("1FUJGLDR1CLBP8834", "2026-07-01T12:00:00Z", "abc")
    assert a == b  # duplicate submission resolves to one record

Run these under pytest in CI and fail the build on any regression — the dispatch-gate assertion in particular is the guard that keeps a non-compliant vehicle off the road.

Common Failure Modes and Gotchas

Anchor link to "Common Failure Modes and Gotchas"
  • Timezone drift between device and server. Mobile clocks skew, and a device reporting a future or wildly past offset will pass naive parsing. Reject offsets outside ±12h and reconcile offline-queued reports against the server’s receipt time, not the device clock — a report submitted hours after the inspection must still normalize to the inspection instant.
  • Signature is a CDN URL, not base64. When a mobile app uploads the signature image separately and sends only a link, the hashing step silently hashes the URL string. Detect the http(s):// prefix, fetch and re-encode in middleware, and only then hash — otherwise every audit hash is meaningless.
  • Empty array read as missing data. An if defects: guard that skips persistence when the list is empty destroys the § 396.11(a) clean-report attestation. Always branch on presence-of-key, and coerce the empty list to no_defects explicitly.
  • Duplicate submissions on poor connectivity. Without the composite idempotency key, a retried POST creates two records for one inspection, and an auditor sees a conflicting pair. Cache the key before writing, and treat a cache hit as a success no-op returning the original record id.

Part of the FMCSA DVIR Rule 396.11 Breakdown guide. Back to Core DVIR Architecture & FMCSA Compliance Mapping.