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.

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.

ACORD 125 payload validated by JSON Schema then cross-field predicates A mapped ACORD 125 payload enters two stages: a JSON Schema Draft 2020-12 validator that checks structure, required properties, types, patterns and enumerations, followed by a cross-field predicate stage that checks the date window, coverage-limit consistency, and the renewal prior-policy rule. Both stages append reason codes to one ordered list; a clean list yields a validated application while any codes route the application to manual review, and the outcome plus manifest version is written to the audit log. ACORD 125 mapped payload applicant · producer · line JSON Schema 2020-12 required properties types · patterns enum membership structure it can express Cross-field predicates date window coverage ⇒ limit renewal ⇒ prior policy rules it cannot clean Validated application canonical store codes Manual review ordered reason codes Append-only audit outcome · reason codes · manifest version · payload hash
JSON Schema owns structure; explicit Python predicates own the relational rules; both feed one ordered reason-code list and one audit record.

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 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.

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.

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.

  • Date format never rejected. Symptom: "2026-13-40" passes structure validation. Cause: the format keyword is annotation-only unless a format checker is attached. Fix: construct the validator with format_checker=Draft202012Validator.FORMAT_CHECKER, as shown, so format: date is actually asserted.
  • Reason codes in different order across runs. Symptom: audit replay comparisons fail on identical payloads. Cause: iter_errors yields errors in an unspecified order. Fix: sort by absolute_path before 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: additionalProperties defaults to permitting extra keys. Fix: set additionalProperties: false at every object level so drift becomes ACORD125.STRUCT.UNEXPECTED_PROPERTY.
  • Cross-field predicate raises TypeError. Symptom: a malformed limit crashes the date or coverage check. Cause: the predicate assumed a clean value the schema already flagged. Fix: coerce through the guarded _as_date/_as_decimal helpers 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.