← Files Investment BankingARCHIVED FILE

scripts/validate_handoff_payload.py

15.4 KB · Oct 2, 2026 · 00:27 UTC

↓ Download file

#!/usr/bin/env python3
"""Validate Investment Banking handoff payloads without third-party deps.

The shared Markdown contract remains the human-readable authority. The JSON
schemas in ../schemas provide machine-readable required fields; this script
checks required field presence, nested support records, arrays, enums, consts,
and simple regex patterns while allowing extra banker notes and local workflow
fields to travel with the package.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any

CONTRACT_SHAPES: dict[str, dict[str, Any]] = {
    "buyer_investor_list_to_deal_process_tracker": {
        "schema": "buyer_investor_list_to_deal_process_tracker.schema.json",
        "record_def": "record",
        "package_keys": ("records",),
        "secondary": {"holds_and_exclusions": "holds_and_exclusions_record"},
    },
    "meeting_prep_to_deal_process_tracker": {
        "schema": "meeting_prep_to_deal_process_tracker.schema.json",
        "record_def": "record",
        "package_keys": ("tracker_deltas",),
    },
    "company_tearsheet_to_memo_builder": {
        "schema": "company_tearsheet_to_memo_builder.schema.json",
        "record_def": "record",
        "package_keys": ("memo_package",),
    },
    "meeting_prep_to_memo_builder": {
        "schema": "meeting_prep_to_memo_builder.schema.json",
        "record_def": "record",
        "package_keys": ("memo_package",),
    },
    "cim_teardown_to_memo_builder": {
        "schema": "cim_teardown_to_memo_builder.schema.json",
        "record_def": "record",
    },
    "cim_builder_to_ib_deck_qc": {
        "schema": "cim_builder_to_ib_deck_qc.schema.json",
        "record_def": "record",
    },
    "pitch_deck_builder_to_ib_deck_qc": {
        "schema": "pitch_deck_builder_to_ib_deck_qc.schema.json",
        "record_def": "record",
    },
    "cim_teardown_to_model_builder": {
        "schema": "cim_teardown_to_model_builder.schema.json",
        "record_def": "record",
    },
    "style_guide_adapter_style_profile": {
        "schema": "style_guide_adapter_style_profile.schema.json",
        "record_def": "record",
    },
    "style_guide_adapter_change_log": {
        "schema": "style_guide_adapter_change_log.schema.json",
        "record_def": "record",
    },
    "capital_markets_issuance_to_private_credit_underwriting": {
        "schema": "capital_markets_issuance_to_private_credit_underwriting.schema.json",
        "record_def": "record",
    },
    "capital_markets_issuance_to_covenant_package_analyzer": {
        "schema": "capital_markets_issuance_to_covenant_package_analyzer.schema.json",
        "record_def": "record",
    },
    "private_credit_underwriting_to_covenant_package_analyzer": {
        "schema": "private_credit_underwriting_to_covenant_package_analyzer.schema.json",
        "record_def": "record",
    },
    "private_credit_underwriting_to_distressed_recovery_waterfall": {
        "schema": "private_credit_underwriting_to_distressed_recovery_waterfall.schema.json",
        "record_def": "record",
    },
    "distressed_recovery_waterfall_to_memo_builder": {
        "schema": "distressed_recovery_waterfall_to_memo_builder.schema.json",
        "record_def": "record",
        "requires_distressed_economics_split": True,
    },
    "distressed_recovery_waterfall_to_pitch_deck_builder": {
        "schema": "distressed_recovery_waterfall_to_pitch_deck_builder.schema.json",
        "record_def": "record",
        "requires_distressed_economics_split": True,
    },
    "distressed_recovery_waterfall_to_ib_deck_qc": {
        "schema": "distressed_recovery_waterfall_to_ib_deck_qc.schema.json",
        "record_def": "record",
        "requires_distressed_economics_split": True,
    },
}

SOFT_EMPTY_VALUES = {"", "unknown", "not_provided", "not provided", "n/a", "na", "none"}
DISTRESSED_ECONOMICS_SPLIT_FIELDS = [
    "strict_legal_entitlement_economics",
    "negotiated_plan_economics",
    "collateral_liquidation_waterfall",
    "enterprise_value_waterfall",
]


def load_json(path: Path) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise SystemExit(f"invalid json in {path}: {exc}") from exc


class SchemaResolver:
    def __init__(self, schema_dir: Path) -> None:
        self.schema_dir = schema_dir
        self.cache: dict[str, dict[str, Any]] = {}

    def load(self, filename: str) -> dict[str, Any]:
        path = self.schema_dir / filename
        if filename not in self.cache:
            data = load_json(path)
            if not isinstance(data, dict):
                raise SystemExit(f"schema must be an object: {path}")
            self.cache[filename] = data
        return self.cache[filename]

    def resolve_ref(
        self, ref: str, current_schema: dict[str, Any], current_file: str
    ) -> tuple[dict[str, Any], str]:
        if "#" in ref and not ref.startswith("#"):
            filename, pointer = ref.split("#", 1)
            schema = self.load(filename)
            return self._resolve_pointer(schema, pointer or ""), filename
        return self._resolve_pointer(
            current_schema, ref[1:] if ref.startswith("#") else ref
        ), current_file

    def _resolve_pointer(self, schema: dict[str, Any], pointer: str) -> dict[str, Any]:
        if not pointer:
            return schema
        current: Any = schema
        for raw_part in pointer.strip("/").split("/"):
            part = raw_part.replace("~1", "/").replace("~0", "~")
            if not isinstance(current, dict) or part not in current:
                raise SystemExit(f"schema reference not found: {pointer}")
            current = current[part]
        if not isinstance(current, dict):
            raise SystemExit(f"schema reference does not point to an object: {pointer}")
        return current


def load_schema(contract: str, resolver: SchemaResolver) -> tuple[dict[str, Any], str]:
    shape = CONTRACT_SHAPES[contract]
    filename = str(shape["schema"])
    return resolver.load(filename), filename


def ensure_records(value: Any, label: str) -> list[dict[str, Any]]:
    if isinstance(value, dict):
        return [value]
    if not isinstance(value, list):
        raise SystemExit(f"{label} must be an object or list of objects")
    records: list[dict[str, Any]] = []
    for index, record in enumerate(value, start=1):
        if not isinstance(record, dict):
            raise SystemExit(f"{label}[{index}] must be an object")
        records.append(record)
    return records


def normalize_collections(
    contract: str, payload: Any
) -> list[tuple[str, str, list[dict[str, Any]]]]:
    shape = CONTRACT_SHAPES[contract]
    package_keys = shape.get("package_keys", ())

    if isinstance(payload, list):
        return [("records", str(shape["record_def"]), ensure_records(payload, "payload"))]
    if not isinstance(payload, dict):
        raise SystemExit("payload must be an object or list of objects")

    collections: list[tuple[str, str, list[dict[str, Any]]]] = []
    for key in package_keys:
        if key in payload:
            collections.append(
                ("records", str(shape["record_def"]), ensure_records(payload[key], key))
            )
            break
    if not collections:
        collections.append(("records", str(shape["record_def"]), [payload]))

    for key, def_name in dict(shape.get("secondary", {})).items():
        if key in payload:
            collections.append((key, str(def_name), ensure_records(payload[key], key)))
    return collections


def is_soft_empty(value: Any) -> bool:
    if value is None:
        return True
    if isinstance(value, str) and value.strip().lower() in SOFT_EMPTY_VALUES:
        return True
    if isinstance(value, list) and not value:
        return True
    if isinstance(value, dict) and not value:
        return True
    return False


def type_matches(value: Any, expected: str) -> bool:
    if expected == "object":
        return isinstance(value, dict)
    if expected == "array":
        return isinstance(value, list)
    if expected == "string":
        return isinstance(value, str)
    if expected == "number":
        return isinstance(value, (int, float)) and not isinstance(value, bool)
    if expected == "integer":
        return isinstance(value, int) and not isinstance(value, bool)
    if expected == "boolean":
        return isinstance(value, bool)
    if expected == "null":
        return value is None
    return True


def validate_value(
    value: Any,
    schema: dict[str, Any],
    resolver: SchemaResolver,
    current_schema: dict[str, Any],
    current_file: str,
    path: str,
    errors: list[str],
    warnings: list[str],
) -> None:
    if "$ref" in schema:
        resolved, resolved_file = resolver.resolve_ref(
            str(schema["$ref"]), current_schema, current_file
        )
        validate_value(
            value,
            resolved,
            resolver,
            resolver.load(resolved_file),
            resolved_file,
            path,
            errors,
            warnings,
        )
        return

    if "const" in schema and value != schema["const"]:
        errors.append(f"{path} must equal {schema['const']!r}; got {value!r}")
    if "enum" in schema and not is_soft_empty(value) and value not in schema["enum"]:
        allowed = ", ".join(str(v) for v in schema["enum"])
        errors.append(f"{path} invalid value {value!r}; allowed: {allowed}")
    if "pattern" in schema and not is_soft_empty(value):
        if not isinstance(value, str) or not re.search(str(schema["pattern"]), value):
            errors.append(f"{path} must match pattern {schema['pattern']!r}; got {value!r}")

    expected_type = schema.get("type")
    if expected_type:
        expected_types = expected_type if isinstance(expected_type, list) else [expected_type]
        if not any(type_matches(value, str(item)) for item in expected_types):
            errors.append(f"{path} must be {expected_type}; got {type(value).__name__}")
            return

    if isinstance(value, dict):
        required = [str(field) for field in schema.get("required", [])]
        for field in required:
            field_path = f"{path}.{field}"
            if field not in value:
                errors.append(f"{field_path} missing field")
            elif is_soft_empty(value[field]):
                warnings.append(f"{field_path} has placeholder/empty value")
        properties = schema.get("properties", {})
        if isinstance(properties, dict):
            for field, spec in properties.items():
                if field in value and isinstance(spec, dict):
                    validate_value(
                        value[field],
                        spec,
                        resolver,
                        current_schema,
                        current_file,
                        f"{path}.{field}",
                        errors,
                        warnings,
                    )
    elif isinstance(value, list):
        min_items = schema.get("minItems")
        if isinstance(min_items, int) and len(value) < min_items:
            errors.append(f"{path} must contain at least {min_items} item(s)")
        item_schema = schema.get("items")
        if isinstance(item_schema, dict):
            for index, item in enumerate(value, start=1):
                validate_value(
                    item,
                    item_schema,
                    resolver,
                    current_schema,
                    current_file,
                    f"{path}[{index}]",
                    errors,
                    warnings,
                )


def special_contract_checks(
    contract: str, records: list[dict[str, Any]], errors: list[str], warnings: list[str]
) -> None:
    if contract == "cim_teardown_to_model_builder":
        for index, record in enumerate(records, start=1):
            for field, prefix in {
                "claim_id": "C-",
                "red_flag_id": "RF-",
                "question_id": "Q-",
                "task_id": "T-",
            }.items():
                value = record.get(field)
                if not is_soft_empty(value) and not str(value).startswith(prefix):
                    errors.append(
                        f"records[{index}].{field} must preserve {prefix} identifier; got {value!r}"
                    )

    if CONTRACT_SHAPES[contract].get("requires_distressed_economics_split"):
        for index, record in enumerate(records, start=1):
            for field in DISTRESSED_ECONOMICS_SPLIT_FIELDS:
                if field not in record:
                    errors.append(
                        f"records[{index}].{field} missing field; distressed handoffs must not collapse recovery economics"
                    )
                elif is_soft_empty(record[field]):
                    warnings.append(f"records[{index}].{field} has placeholder/empty value")


def validate(contract: str, payload: Any, schema_dir: Path) -> tuple[list[str], list[str]]:
    resolver = SchemaResolver(schema_dir)
    schema, schema_file = load_schema(contract, resolver)
    shape = CONTRACT_SHAPES[contract]
    collections = normalize_collections(contract, payload)

    errors: list[str] = []
    warnings: list[str] = []
    if (
        isinstance(payload, dict)
        and "contract_name" in payload
        and payload["contract_name"] != contract
    ):
        errors.append(f"contract_name must equal {contract!r}; got {payload['contract_name']!r}")

    defs = schema.get("$defs", {})
    if not isinstance(defs, dict):
        raise SystemExit(f"schema missing $defs: {schema_file}")

    main_records: list[dict[str, Any]] = []
    for label, def_name, records in collections:
        if def_name not in defs:
            raise SystemExit(f"schema {schema_file} missing $defs.{def_name}")
        record_schema = defs[def_name]
        if not isinstance(record_schema, dict):
            raise SystemExit(f"schema $defs.{def_name} must be an object")
        if not records:
            errors.append(f"{label}: no records found")
        for index, record in enumerate(records, start=1):
            validate_value(
                record,
                record_schema,
                resolver,
                schema,
                schema_file,
                f"{label}[{index}]",
                errors,
                warnings,
            )
        if def_name == shape["record_def"]:
            main_records.extend(records)

    special_contract_checks(contract, main_records, errors, warnings)
    return errors, warnings


def main() -> int:
    parser = argparse.ArgumentParser(description="Validate an Investment Banking handoff payload")
    parser.add_argument(
        "contract", choices=sorted(CONTRACT_SHAPES), help="Canonical handoff contract name"
    )
    parser.add_argument("input_json", type=Path, help="Path to payload JSON")
    parser.add_argument(
        "--schema-dir",
        type=Path,
        default=Path(__file__).resolve().parents[1] / "schemas",
        help="Directory containing *.schema.json files",
    )
    parser.add_argument(
        "--strict", action="store_true", help="Treat placeholder/empty values as failures"
    )
    args = parser.parse_args()

    payload = load_json(args.input_json)
    errors, warnings = validate(args.contract, payload, args.schema_dir)

    for message in errors:
        print(f"ERROR: {message}")
    for message in warnings:
        print(f"WARNING: {message}")

    if errors or (args.strict and warnings):
        return 1
    print("validation passed")
    return 0


if __name__ == "__main__":
    sys.exit(main())

SHA-256: e40404cfbe0cc723985cd93ddb43db494021cf3d60b36a7ceae2b9849fa6c67d