Validating ACORD 125 Fields Against JSON Schema
This guide is a focused implementation under ACORD and NAIC Field Validation Pipelines: it shows how to express a subset of the ACORD 125 commercial insurance application as a JSON Schema, validate real payloads with the jsonschema library, and layer the cross-field checks that JSON Schema structurally cannot express — mapping every violation to an actionable reason code and an audit record.
Problem Statement
Permalink to "Problem Statement"ACORD 125 is the commercial insurance application, and the payload that reaches validation is a nested object: an applicant block, a producer block carrying NAIC identifiers, and one or more policy-line blocks with coverage and limit fields. Two failure modes make an ad-hoc if cascade untenable. First, the shape itself is unstable — a mapping change upstream can drop the producer object, nest the applicant one level too deep, or send a limit as "1,000,000" with a thousands separator — and hand-written presence checks drift out of sync with the real payloads coming from Field Mapping Strategies. Second, the interesting rules are relational: the expiration date must follow the effective date, a declared coverage must carry a limit, and a renewal application must reference a prior policy number. JSON Schema is excellent at the first class of problem and cannot express the second at all.
The correct division of labor is to let a JSON Schema (Draft 2020-12) own structure, types, patterns, and enumerations declaratively, then run a small, explicit set of Python cross-field predicates for the relational rules. Both feed one ordered list of reason codes so a rejection is deterministic and replayable — the same contract the parent validation pipeline demands, applied to one concrete form.
Prerequisites
Permalink to "Prerequisites"This pattern targets Python 3.10+ and the jsonschema library with Draft 2020-12 support. Pin it so validation-keyword semantics cannot drift under you, and pin structlog for the audit line:
python -m venv .venv && source .venv/bin/activate
pip install "jsonschema==4.*" "structlog==24.*"
The payload arriving here has already been normalized onto canonical ACORD field names by Field Mapping Strategies; this guide validates that normalized shape, it does not parse raw carrier documents. Monetary limits are expected as strings or integers and are coerced to Decimal inside the cross-field stage — never as float.
Step-by-Step Implementation
Permalink to "Step-by-Step Implementation"Step 1 — Express the ACORD 125 subset as a JSON Schema
Permalink to "Step 1 — Express the ACORD 125 subset as a JSON Schema"Declare the structure, required properties, types, patterns, and enumerations the form must satisfy. Keep the enumerations (line-of-business codes, state codes) referencing values that in production come from the versioned manifest, so a standard revision is a data change rather than a schema rewrite.
from __future__ import annotations
ACORD_125_SCHEMA: dict = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://insurance-claims.org/schemas/acord-125.json",
"type": "object",
"required": ["applicant", "producer", "policy_line",
"effective_date", "expiration_date"],
"additionalProperties": False,
"properties": {
"applicant": {
"type": "object",
"required": ["name", "state"],
"additionalProperties": False,
"properties": {
"name": {"type": "string", "minLength": 1},
"state": {"type": "string", "pattern": "^[A-Z]{2}$"},
},
},
"producer": {
"type": "object",
"required": ["producer_id", "naic_company_code"],
"additionalProperties": False,
"properties": {
"producer_id": {"type": "string", "minLength": 1},
# NAIC company code: exactly five digits.
"naic_company_code": {"type": "string", "pattern": "^[0-9]{5}$"},
},
},
"policy_line": {
"type": "object",
"required": ["lob_code", "coverage"],
"additionalProperties": False,
"properties": {
"lob_code": {"type": "string", "enum": ["CGL", "PROP", "AUTOB", "UMBR"]},
"coverage": {"type": "boolean"},
# Limit is a stringified integer; Decimal coercion happens later.
"limit": {"type": ["string", "null"], "pattern": "^[0-9]+$"},
},
},
"is_renewal": {"type": "boolean"},
"prior_policy_number": {"type": ["string", "null"]},
"effective_date": {"type": "string", "format": "date"},
"expiration_date": {"type": "string", "format": "date"},
},
}
additionalProperties: false at each level is deliberate: an unexpected key from a mapping drift becomes a hard schema error rather than a silently ignored field, the same discipline Policy Schema Design enforces with extra="forbid".
Step 2 — Validate structure with the jsonschema library
Permalink to "Step 2 — Validate structure with the jsonschema library"Use a Draft 2020-12 validator and collect all errors in one pass with iter_errors, rather than raising on the first. Enable format checking so the date format is actually asserted. Each schema error is translated into the same stable reason-code vocabulary the pipeline uses everywhere.
from dataclasses import dataclass, field
from jsonschema import Draft202012Validator
from jsonschema.validators import validator_for
import structlog
log = structlog.get_logger("acord125.validator")
@dataclass(slots=True)
class ValidationResult:
manifest_version: str
reason_codes: list[str] = field(default_factory=list)
details: list[str] = field(default_factory=list)
def add(self, code: str, detail: str) -> None:
self.reason_codes.append(code)
self.details.append(detail)
@property
def is_valid(self) -> bool:
return not self.reason_codes
def _schema_path(error) -> str:
return ".".join(str(p) for p in error.absolute_path) or "<root>"
def validate_structure(payload: dict, result: ValidationResult) -> None:
validator = Draft202012Validator(
ACORD_125_SCHEMA,
format_checker=Draft202012Validator.FORMAT_CHECKER,
)
for error in sorted(validator.iter_errors(payload), key=lambda e: list(e.absolute_path)):
if error.validator == "required":
code = "ACORD125.STRUCT.MISSING_PROPERTY"
elif error.validator in ("type", "pattern", "format", "minLength", "maxLength"):
code = "ACORD125.TYPE.INVALID"
elif error.validator == "enum":
code = "ACORD125.CODE.NOT_IN_ENUM"
elif error.validator == "additionalProperties":
code = "ACORD125.STRUCT.UNEXPECTED_PROPERTY"
else:
code = "ACORD125.SCHEMA.OTHER"
result.add(code, f"{_schema_path(error)}: {error.message}")
Sorting iter_errors by JSON path is what makes the output deterministic: the jsonschema library does not guarantee error order, so two identical payloads could otherwise produce differently ordered code lists and break audit replay.
Step 3 — Layer the cross-field rules JSON Schema cannot express
Permalink to "Step 3 — Layer the cross-field rules JSON Schema cannot express"Now apply the relational predicates. These read multiple fields, use Decimal for the limit, and are guarded so a value the schema already flagged as the wrong type cannot raise here.
from datetime import date
from decimal import Decimal, InvalidOperation
def _as_date(value: object) -> date | None:
if not isinstance(value, str):
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _as_decimal(value: object) -> Decimal | None:
if value is None or isinstance(value, bool):
return None
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return None
def validate_cross_field(payload: dict, result: ValidationResult) -> None:
eff = _as_date(payload.get("effective_date"))
exp = _as_date(payload.get("expiration_date"))
if eff is not None and exp is not None and eff >= exp:
result.add("ACORD125.RULE.DATE_WINDOW",
"expiration_date must strictly follow effective_date")
line = payload.get("policy_line") or {}
if line.get("coverage") is True:
limit = _as_decimal(line.get("limit"))
if limit is None or limit <= 0:
result.add("ACORD125.RULE.COVERAGE_NO_LIMIT",
"coverage declared without a positive limit")
if payload.get("is_renewal") is True and not payload.get("prior_policy_number"):
result.add("ACORD125.RULE.RENEWAL_NO_PRIOR",
"renewal application missing prior_policy_number")
def validate_acord_125(payload: dict, manifest_version: str) -> ValidationResult:
result = ValidationResult(manifest_version=manifest_version)
validate_structure(payload, result)
validate_cross_field(payload, result)
log.info("acord125.validated", manifest=manifest_version,
valid=result.is_valid, codes=result.reason_codes)
return result
Running structure first is not cosmetic: a payload whose limit is a malformed string is caught once by the schema as ACORD125.TYPE.INVALID, and the cross-field guard then treats the unparseable limit as absent rather than raising — the reviewer sees both the type defect and the coverage-consistency defect in one deterministic list.
Verification & Testing
Permalink to "Verification & Testing"Prove each defect class independently, and prove that a fully valid application produces an empty code list. Exact assertions on the reason codes are what make the verdict replayable.
import pytest
VALID = {
"applicant": {"name": "Acme LLC", "state": "TX"},
"producer": {"producer_id": "P-1001", "naic_company_code": "12345"},
"policy_line": {"lob_code": "CGL", "coverage": True, "limit": "1000000"},
"effective_date": "2026-01-01",
"expiration_date": "2026-12-31",
}
def test_valid_application_has_no_codes() -> None:
assert validate_acord_125(VALID, "2026.06.0").is_valid
def test_bad_naic_code_is_type_invalid() -> None:
payload = {**VALID, "producer": {"producer_id": "P-1", "naic_company_code": "ABC"}}
assert "ACORD125.TYPE.INVALID" in validate_acord_125(payload, "2026.06.0").reason_codes
def test_unknown_lob_is_enum_violation() -> None:
payload = {**VALID, "policy_line": {"lob_code": "XYZ", "coverage": False}}
assert "ACORD125.CODE.NOT_IN_ENUM" in validate_acord_125(payload, "2026.06.0").reason_codes
def test_inverted_dates_caught_by_cross_field() -> None:
payload = {**VALID, "effective_date": "2026-12-31", "expiration_date": "2026-01-01"}
assert validate_acord_125(payload, "2026.06.0").reason_codes == ["ACORD125.RULE.DATE_WINDOW"]
def test_coverage_without_limit() -> None:
payload = {**VALID, "policy_line": {"lob_code": "CGL", "coverage": True, "limit": None}}
assert "ACORD125.RULE.COVERAGE_NO_LIMIT" in validate_acord_125(payload, "2026.06.0").reason_codes
def test_error_order_is_deterministic() -> None:
payload = {"applicant": {"name": "", "state": "tx"}} # multiple defects
first = validate_acord_125(payload, "2026.06.0").reason_codes
second = validate_acord_125(payload, "2026.06.0").reason_codes
assert first == second
Run the suite with python -m pytest -q. The decisive signal is test_error_order_is_deterministic: because structural errors are sorted by JSON path and cross-field predicates run in a fixed sequence, the reason-code list is a pure function of payload plus manifest version.
Compliance & Audit Note
Permalink to "Compliance & Audit Note"The naic_company_code pattern and the producer required-properties block are not arbitrary — they encode the NAIC data elements a commercial application must carry, and expressing them in the schema means a missing or malformed identifier is caught at the boundary rather than surfacing as a data-quality finding during a market-conduct exam. Persist the ValidationResult — the ordered reason codes, the manifest_version, and a hash of the canonical payload — to the append-only store modeled in Audit Log Schema Design before the application proceeds or is diverted. Because the codes are stable enum-style strings and the ordering is deterministic, a regulator asking why a specific ACORD 125 submission was rejected can be answered by replaying the record against the exact schema and manifest version in force at the time, and the jurisdiction-specific enumerations trace back to State Regulation Mapping.
Troubleshooting Checklist
Permalink to "Troubleshooting Checklist"- Date format never rejected. Symptom:
"2026-13-40"passes structure validation. Cause: theformatkeyword is annotation-only unless a format checker is attached. Fix: construct the validator withformat_checker=Draft202012Validator.FORMAT_CHECKER, as shown, soformat: dateis actually asserted. - Reason codes in different order across runs. Symptom: audit replay comparisons fail on identical payloads. Cause:
iter_errorsyields errors in an unspecified order. Fix: sort byabsolute_pathbefore recording, and keep cross-field predicates in a fixed sequence. - Unexpected carrier field silently accepted. Symptom: a new field from a mapping change never triggers a review. Cause:
additionalPropertiesdefaults to permitting extra keys. Fix: setadditionalProperties: falseat every object level so drift becomesACORD125.STRUCT.UNEXPECTED_PROPERTY. - Cross-field predicate raises TypeError. Symptom: a malformed
limitcrashes the date or coverage check. Cause: the predicate assumed a clean value the schema already flagged. Fix: coerce through the guarded_as_date/_as_decimalhelpers so a prior type defect degrades to a recorded code, not an exception. - Valid new LOB code rejected. Symptom: a legitimately added line-of-business code fails as
NOT_IN_ENUM. Cause: the enum is stale. Fix: source the enum from a new manifest version rather than editing the schema literal in place, preserving replayability of older records.
Related
Permalink to "Related"- ACORD and NAIC Field Validation Pipelines — the parent guide whose layered validator this form-level check plugs into
- Field Mapping Strategies — normalizes carrier payloads into the ACORD 125 shape validated here
- Policy Schema Design — the typed contract a validated application becomes
- Audit Log Schema Design — the append-only store the ValidationResult is written to
- State Regulation Mapping — supplies the jurisdiction and code-list enumerations the schema references