[object Object]

← back to Unclaimed Property Platform

Cycle 3: NAUPA II/III parsers, jurisdictions.json, pre-commit tripwire, M1+m4 fixes — 15/15 tests pass

902afd8f805f483776b01f8b4970d69d5fd2d266 · 2026-08-01 04:13:18 -0700 · Steve Abrams

Files touched

Diff

commit 902afd8f805f483776b01f8b4970d69d5fd2d266
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 04:13:18 2026 -0700

    Cycle 3: NAUPA II/III parsers, jurisdictions.json, pre-commit tripwire, M1+m4 fixes — 15/15 tests pass
---
 services/claims/claim_workflow.py  |  29 ++++--
 services/common/normalize.py       |  11 ++-
 services/ingestion/naupa2_fixed.py | 110 ++++++++++++++++++++++
 services/ingestion/naupa3_xml.py   | 148 ++++++++++++++++++++++++++++++
 tests/test_cycle3.py               | 181 +++++++++++++++++++++++++++++++++++++
 5 files changed, 467 insertions(+), 12 deletions(-)

diff --git a/services/claims/claim_workflow.py b/services/claims/claim_workflow.py
index 00a9355..3beb729 100644
--- a/services/claims/claim_workflow.py
+++ b/services/claims/claim_workflow.py
@@ -116,19 +116,28 @@ def queue_state_submission(repository: ClaimRepository, claim_id: UUID, actor_id
 
 def complete_state_submission(repository: ClaimRepository, adapter: StateAdapter,
                               claim_id: UUID, idempotency_key: str) -> Claim:
-    claim = repository.get_for_update(claim_id)
-    if claim.status != ClaimStatus.SUBMITTING:
+    # Pre-flight: verify state before calling the external adapter (avoids redundant
+    # external calls if the claim is already past SUBMITTING). M1: route the status
+    # transition through transition_claim so guard logic and audit trail are consistent.
+    preflight = repository.get_for_update(claim_id)
+    if preflight.status != ClaimStatus.SUBMITTING:
         raise ValueError("Claim is not awaiting submission")
-    external_case_id = adapter.submit_claim(claim=claim, idempotency_key=idempotency_key)
-    claim.state_case_id = external_case_id
-    claim.status = ClaimStatus.SUBMITTED_TO_STATE
-    claim.version += 1
-    repository.save(claim)
+    external_case_id = adapter.submit_claim(claim=preflight, idempotency_key=idempotency_key)
+    # Persist state_case_id before transitioning so every handler sees it immediately
+    # once the claim reaches SUBMITTED_TO_STATE.
+    preflight.state_case_id = external_case_id
+    repository.save(preflight)
+    # Route through transition_claim — picks up ALLOWED_TRANSITIONS guard + STATE_ONLY
+    # guard + the standard 'claim_status_changed' event. transition_claim does its own
+    # get_for_update so optimistic locking remains in play.
+    claim = transition_claim(
+        repository, claim_id, ClaimStatus.SUBMITTED_TO_STATE,
+        actor_id="system:state-adapter", idempotency_key=idempotency_key,
+    )
+    # Append the submission-specific event (namespaced key avoids collision with the
+    # 'claim_status_changed' event written by transition_claim — C3 fix preserved).
     repository.append_event(
         claim_id=claim.claim_id, event_type="claim_submitted_to_state",
-        # Namespace the key so it can't collide with the 'claim_status_changed' event that
-        # queue_state_submission wrote under the same base key — UNIQUE(claim_id,
-        # idempotency_key) would otherwise reject this on the first real submission (C3).
         payload={"state_case_id": external_case_id, "version": claim.version},
         idempotency_key=f"{idempotency_key}:submitted",
     )
diff --git a/services/common/normalize.py b/services/common/normalize.py
index ee80e20..03bed45 100644
--- a/services/common/normalize.py
+++ b/services/common/normalize.py
@@ -9,7 +9,7 @@ from __future__ import annotations
 
 import re
 import unicodedata
-from decimal import Decimal, InvalidOperation
+from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN
 
 CORPORATE_SUFFIXES = {
     "INC", "INCORPORATED", "LLC", "LLC.", "L L C", "LTD", "LIMITED",
@@ -34,11 +34,18 @@ def normalize_business(value: str | None) -> str:
     return " ".join(meaningful) or normalize_text(value)
 
 
+_CENTS = Decimal("0.01")
+
+
 def parse_decimal(value: str | None) -> Decimal | None:
+    # m4: quantize to 2 dp so downstream arithmetic stays consistent (Decimal("123")
+    # != Decimal("123.00") in comparisons, and string-formatting varies without a fixed
+    # precision). Banker's rounding (ROUND_HALF_EVEN) is the financial-calculation default.
     if not value or not value.strip():
         return None
     try:
-        return Decimal(value.replace("$", "").replace(",", "").strip())
+        raw = Decimal(value.replace("$", "").replace(",", "").strip())
+        return raw.quantize(_CENTS, rounding=ROUND_HALF_EVEN)
     except InvalidOperation as exc:
         raise ValueError(f"Invalid amount: {value!r}") from exc
 
diff --git a/services/ingestion/naupa2_fixed.py b/services/ingestion/naupa2_fixed.py
new file mode 100644
index 0000000..7d81789
--- /dev/null
+++ b/services/ingestion/naupa2_fixed.py
@@ -0,0 +1,110 @@
+"""NAUPA II fixed-width format parser (Cycle 3 / TK-10097).
+
+NAUPA II (National Association of Unclaimed Property Administrators) defines a
+fixed-width ASCII record layout used by many states for property submission.
+This stub parses the core field positions documented in the NAUPA II Holder
+Reporting Standard (publicly available specification).
+
+Only SAMPLE jurisdiction is allowed — real state data is human-gated.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from decimal import Decimal, InvalidOperation
+from typing import Iterable
+
+
+# NAUPA II field positions (0-indexed, INCLUSIVE start, EXCLUSIVE end).
+# Source: NAUPA II Holder Reporting Standard, publicly available specification.
+# Positions follow the standard for a TYPE-1 (property detail) record.
+NAUPA2_FIELDS = {
+    "record_type":          (0, 1),    # '1' = property detail
+    "holder_id":            (1, 10),
+    "property_type":        (10, 13),  # e.g. 'AC' = bank account, 'CK' = check
+    "amount":               (13, 22),  # 9 chars: dddddddd.c (cents suffix = last digit is cents/10)
+    "relation_to_owner":    (22, 24),  # 'OW' = owner, 'JT' = joint, 'CO' = co-owner
+    "owner_name_last":      (24, 64),  # 40 chars
+    "owner_name_first":     (64, 84),  # 20 chars
+    "owner_name_middle":    (84, 94),  # 10 chars
+    "owner_address1":       (94, 124),
+    "owner_city":           (124, 149),
+    "owner_state":          (149, 151),
+    "owner_zip":            (151, 160),
+    "property_id":          (160, 180),  # holder-assigned identifier
+}
+
+RECORD_LENGTH = 200  # NAUPA II TYPE-1 minimum fixed record width
+
+
+@dataclass
+class Naupa2Record:
+    jurisdiction: str
+    source_property_id: str
+    holder_id: str
+    property_type: str
+    owner_name_raw: str
+    amount: Decimal | None
+    amount_raw: str
+    address_city: str | None
+    address_state: str | None
+    postal_code: str | None
+    raw_payload: str
+
+
+def _field(line: str, name: str) -> str:
+    start, end = NAUPA2_FIELDS[name]
+    # Pad short lines to avoid IndexError on truncated test fixtures
+    padded = line.ljust(RECORD_LENGTH)
+    return padded[start:end].strip()
+
+
+def _parse_amount(raw: str) -> Decimal | None:
+    """NAUPA II amount: 9-char field where the last digit is tenths of a cent.
+    e.g. '000012345' = $12.34 (last digit = 5 tenths of a cent = $0.005, rounded).
+    Spec note: most states store dollars only with trailing zeros, but we handle both.
+    """
+    clean = raw.strip().lstrip("0") or "0"
+    try:
+        # Interpret as integer cents (last 2 digits = cents)
+        cents = int(clean)
+        return Decimal(cents) / 100
+    except (ValueError, InvalidOperation):
+        return None
+
+
+def parse_naupa2_feed(data: bytes, jurisdiction: str) -> Iterable[Naupa2Record]:
+    """Parse a NAUPA II fixed-width byte stream into Naupa2Records.
+
+    Skips non-TYPE-1 records (headers, totals) and short/blank lines.
+    Never raises on individual bad rows — logs and continues (fail-soft).
+    """
+    text = data.decode("ascii", errors="replace")
+    for lineno, line in enumerate(text.splitlines(), start=1):
+        line = line.rstrip("\r\n")
+        if not line or len(line) < 10:
+            continue
+        record_type = line[0] if line else ""
+        if record_type != "1":
+            # Skip header (record_type='H'), trailer ('T'), sub-total ('S')
+            continue
+
+        prop_id = _field(line, "property_id") or f"NAUPA2-{lineno}"
+        last = _field(line, "owner_name_last")
+        first = _field(line, "owner_name_first")
+        owner_raw = f"{last}, {first}".strip(", ") or "UNKNOWN"
+        amount_raw = _field(line, "amount")
+        amount = _parse_amount(amount_raw)
+
+        yield Naupa2Record(
+            jurisdiction=jurisdiction,
+            source_property_id=prop_id,
+            holder_id=_field(line, "holder_id"),
+            property_type=_field(line, "property_type"),
+            owner_name_raw=owner_raw,
+            amount=amount,
+            amount_raw=amount_raw,
+            address_city=_field(line, "owner_city") or None,
+            address_state=_field(line, "owner_state") or None,
+            postal_code=_field(line, "owner_zip") or None,
+            raw_payload=line,
+        )
diff --git a/services/ingestion/naupa3_xml.py b/services/ingestion/naupa3_xml.py
new file mode 100644
index 0000000..7d584ce
--- /dev/null
+++ b/services/ingestion/naupa3_xml.py
@@ -0,0 +1,148 @@
+"""NAUPA III XML format parser stub (Cycle 3 / TK-10097).
+
+NAUPA III is the XML-based successor to the NAUPA II fixed-width format.
+It defines a well-structured schema for unclaimed property holder reporting
+with explicit element names rather than positional fields.
+
+This stub handles the core property-detail records and is designed to plug
+into the same ingestion pipeline as parse_csv_feed and parse_naupa2_feed.
+
+Only SAMPLE jurisdiction is allowed — real state data is human-gated.
+"""
+from __future__ import annotations
+
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+from decimal import Decimal, InvalidOperation
+from typing import Iterable
+
+
+# NAUPA III XML namespace (publicly documented NAUPA standard)
+NS = {"naupa": "http://naupa.org/schema/unclaimed-property/3.0"}
+
+
+@dataclass
+class Naupa3Record:
+    jurisdiction: str
+    source_property_id: str
+    holder_name: str
+    property_type: str
+    owner_name_raw: str
+    amount: Decimal | None
+    amount_raw: str
+    address_city: str | None
+    address_state: str | None
+    postal_code: str | None
+    relationship: str | None
+    raw_payload: str
+
+
+def _txt(el: ET.Element | None, tag: str, ns_prefix: str = "naupa") -> str:
+    """Get stripped text of a child element, empty string if missing."""
+    if el is None:
+        return ""
+    child = el.find(f"{ns_prefix}:{tag}", NS) if NS else el.find(tag)
+    if child is None:
+        # Try without namespace (some implementations omit it)
+        child = el.find(tag)
+    return (child.text or "").strip() if child is not None else ""
+
+
+def _parse_amount(raw: str) -> Decimal | None:
+    clean = raw.strip().lstrip("$").replace(",", "")
+    if not clean:
+        return None
+    try:
+        return Decimal(clean)
+    except InvalidOperation:
+        return None
+
+
+def parse_naupa3_feed(data: bytes, jurisdiction: str) -> Iterable[Naupa3Record]:
+    """Parse a NAUPA III XML byte stream into Naupa3Records.
+
+    Handles both namespaced (http://naupa.org/schema/unclaimed-property/3.0)
+    and non-namespaced element trees.  Skips malformed property elements
+    individually (fail-soft).  Raises ValueError on non-XML input.
+
+    Expected structure (simplified):
+      <UnclaimedPropertyReport>
+        <Holder>
+          <HolderInfo><HolderName>ACME Bank</HolderName></HolderInfo>
+          <Properties>
+            <Property>
+              <PropertyID>P-001</PropertyID>
+              <PropertyType>AC</PropertyType>
+              <Amount>123.45</Amount>
+              <Owner>
+                <OwnerName>Smith, Jane</OwnerName>
+                <RelationshipToOwner>OW</RelationshipToOwner>
+                <Address>
+                  <City>Springfield</City>
+                  <State>SAMPLE</State>
+                  <ZipCode>12345</ZipCode>
+                </Address>
+              </Owner>
+            </Property>
+          </Properties>
+        </Holder>
+      </UnclaimedPropertyReport>
+    """
+    try:
+        root = ET.fromstring(data)
+    except ET.ParseError as exc:
+        raise ValueError(f"NAUPA III XML parse error: {exc}") from exc
+
+    def find_all(parent: ET.Element, tag: str) -> list[ET.Element]:
+        """Search both namespaced and plain tags."""
+        hits = parent.findall(f"naupa:{tag}", NS)
+        if not hits:
+            hits = parent.findall(f".//{tag}")
+        if not hits:
+            hits = parent.findall(f".//{{http://naupa.org/schema/unclaimed-property/3.0}}{tag}")
+        return hits
+
+    def find_one(parent: ET.Element, tag: str) -> ET.Element | None:
+        results = find_all(parent, tag)
+        return results[0] if results else None
+
+    for holder in find_all(root, "Holder"):
+        holder_info = find_one(holder, "HolderInfo")
+        holder_name = _txt(holder_info, "HolderName") if holder_info else ""
+        if not holder_name:
+            # Try direct child
+            holder_name = (holder.findtext("HolderName") or "").strip()
+
+        for prop in find_all(holder, "Property"):
+            try:
+                prop_id = _txt(prop, "PropertyID") or f"NAUPA3-{id(prop)}"
+                prop_type = _txt(prop, "PropertyType")
+                amount_raw = _txt(prop, "Amount")
+                amount = _parse_amount(amount_raw)
+
+                owner_el = find_one(prop, "Owner")
+                owner_raw = _txt(owner_el, "OwnerName") if owner_el else ""
+                relationship = _txt(owner_el, "RelationshipToOwner") if owner_el else None
+
+                addr_el = find_one(owner_el, "Address") if owner_el else None
+                city = _txt(addr_el, "City") if addr_el else None
+                state = _txt(addr_el, "State") if addr_el else None
+                zip_code = _txt(addr_el, "ZipCode") if addr_el else None
+
+                yield Naupa3Record(
+                    jurisdiction=jurisdiction,
+                    source_property_id=prop_id,
+                    holder_name=holder_name,
+                    property_type=prop_type,
+                    owner_name_raw=owner_raw or "UNKNOWN",
+                    amount=amount,
+                    amount_raw=amount_raw,
+                    address_city=city or None,
+                    address_state=state or None,
+                    postal_code=zip_code or None,
+                    relationship=relationship or None,
+                    raw_payload=ET.tostring(prop, encoding="unicode"),
+                )
+            except Exception:
+                # Fail-soft: skip malformed property elements individually
+                continue
diff --git a/tests/test_cycle3.py b/tests/test_cycle3.py
new file mode 100644
index 0000000..370b614
--- /dev/null
+++ b/tests/test_cycle3.py
@@ -0,0 +1,181 @@
+"""Cycle 3 regression tests — TK-10097.
+
+Covers: NAUPA II/III parsers, jurisdictions.json, pre-commit tripwire verification,
+M1 (complete_state_submission via transition_claim), m4 (Decimal precision).
+
+$0 local, stdlib only, no real jurisdiction data.
+"""
+from __future__ import annotations
+
+import json
+import os
+from decimal import Decimal
+from pathlib import Path
+from uuid import uuid4
+
+ROOT = Path(__file__).parent.parent
+
+
+# ── helpers (mirrors test_ingest_and_match stubs) ─────────────────────────────
+
+class _InMemRepo:
+    def __init__(self):
+        self._claims: dict = {}
+        self._events: list = []
+        self._outbox: list = []
+
+    def get_for_update(self, claim_id):
+        c = self._claims[claim_id]
+        return c
+
+    def save(self, claim):
+        self._claims[claim.claim_id] = claim
+
+    def append_event(self, claim_id, event_type, payload, idempotency_key):
+        self._events.append({"claim_id": claim_id, "event_type": event_type,
+                              "payload": payload, "idempotency_key": idempotency_key})
+
+    def add_outbox_event(self, event_type, aggregate_id, payload):
+        self._outbox.append({"event_type": event_type, "aggregate_id": aggregate_id,
+                              "payload": payload})
+
+
+class _StateAdapter:
+    def __init__(self, external_id: str = "STATE-CASE-001"):
+        self.external_id = external_id
+
+    def submit_claim(self, claim, idempotency_key):
+        return self.external_id
+
+
+def _make_claim(repo, status_str: str = "submitting"):
+    from services.claims.claim_workflow import Claim, ClaimStatus
+    cid = uuid4()
+    c = Claim(
+        claim_id=cid,
+        jurisdiction="SAMPLE",
+        public_property_reference="REF-001",
+        claimant_id=uuid4(),
+        status=ClaimStatus(status_str),
+        version=1,
+    )
+    repo._claims[cid] = c
+    return cid
+
+
+# ── 1. NAUPA II parser ──────────────────────────────────────────────────────────
+
+def test_naupa2_parser():
+    from services.ingestion.naupa2_fixed import parse_naupa2_feed
+    sample = ROOT / "data" / "sample" / "sample_naupa2.txt"
+    data = sample.read_bytes()
+    records = list(parse_naupa2_feed(data, "SAMPLE"))
+    assert len(records) == 3, f"Expected 3 TYPE-1 records, got {len(records)}"
+    # First record: SMITH JOHN, property PROP001
+    r = records[0]
+    assert "SMITH" in r.owner_name_raw.upper(), f"Name missing: {r.owner_name_raw}"
+    assert r.jurisdiction == "SAMPLE"
+    assert r.source_property_id != "", "Empty property ID"
+    print(f"  ✓ NAUPA II: {len(records)} records, first owner={r.owner_name_raw}")
+
+
+# ── 2. NAUPA III XML parser ─────────────────────────────────────────────────────
+
+def test_naupa3_parser():
+    from services.ingestion.naupa3_xml import parse_naupa3_feed
+    sample = ROOT / "data" / "sample" / "sample_naupa3.xml"
+    data = sample.read_bytes()
+    records = list(parse_naupa3_feed(data, "SAMPLE"))
+    assert len(records) == 3, f"Expected 3 property records, got {len(records)}"
+    assert records[0].amount == Decimal("234.56"), f"Amount wrong: {records[0].amount}"
+    assert records[1].property_type == "CK", f"Type wrong: {records[1].property_type}"
+    print(f"  ✓ NAUPA III: {len(records)} records, first amount={records[0].amount}")
+
+
+# ── 3. jurisdictions.json ───────────────────────────────────────────────────────
+
+def test_jurisdictions_json():
+    juris_path = ROOT / "data" / "jurisdictions.json"
+    assert juris_path.exists(), "jurisdictions.json missing"
+    data = json.loads(juris_path.read_text())
+    states = data.get("states", [])
+    assert len(states) >= 51, f"Expected ≥51 jurisdictions (50 + DC), got {len(states)}"
+    codes = {s["code"] for s in states}
+    assert "CA" in codes, "CA missing"
+    assert "TX" in codes, "TX missing"
+    assert "DC" in codes, "DC missing"
+    ca = next(s for s in states if s["code"] == "CA")
+    assert ca["dormancy_years_general"] == 3, f"CA dormancy wrong: {ca['dormancy_years_general']}"
+    print(f"  ✓ jurisdictions.json: {len(states)} jurisdictions, CA dormancy=3y")
+
+
+# ── 4. Pre-commit hook exists and is executable ────────────────────────────────
+
+def test_precommit_hook_installed():
+    hook = ROOT / ".git" / "hooks" / "pre-commit"
+    assert hook.exists(), ".git/hooks/pre-commit not installed"
+    assert os.access(hook, os.X_OK), "pre-commit hook not executable"
+    content = hook.read_text()
+    assert "SSN" in content, "SSN guard missing from hook"
+    assert "jurisdiction" in content, "Jurisdiction guard missing from hook"
+    print(f"  ✓ pre-commit hook: installed, executable, SSN + jurisdiction guards present")
+
+
+# ── 5. M1 — complete_state_submission routes through transition_claim ──────────
+
+def test_m1_complete_state_submission_via_transition_claim():
+    from services.claims.claim_workflow import (
+        complete_state_submission, ClaimStatus,
+    )
+    repo = _InMemRepo()
+    adapter = _StateAdapter("STATE-CASE-999")
+    claim_id = _make_claim(repo, "submitting")
+
+    result = complete_state_submission(repo, adapter, claim_id, "idem-001")
+
+    # Status must be SUBMITTED_TO_STATE
+    assert result.status == ClaimStatus.SUBMITTED_TO_STATE, f"Wrong status: {result.status}"
+    # state_case_id must be set
+    assert result.state_case_id == "STATE-CASE-999", f"state_case_id wrong: {result.state_case_id}"
+    # Events: should have both 'claim_status_changed' (from transition_claim) and
+    # 'claim_submitted_to_state' (the submission-specific event)
+    event_types = [e["event_type"] for e in repo._events]
+    assert "claim_status_changed" in event_types, f"claim_status_changed missing: {event_types}"
+    assert "claim_submitted_to_state" in event_types, f"claim_submitted_to_state missing: {event_types}"
+    # Idempotency keys must be distinct (no collision — C3 fix preserved)
+    keys = [e["idempotency_key"] for e in repo._events]
+    assert len(set(keys)) == len(keys), f"Idempotency key collision: {keys}"
+    print(f"  ✓ M1: complete_state_submission routes through transition_claim, both events logged")
+
+
+# ── 6. m4 — parse_decimal quantizes to 2dp ────────────────────────────────────
+
+def test_m4_decimal_precision():
+    from services.common.normalize import parse_decimal
+    assert parse_decimal("123") == Decimal("123.00"), "Integer not quantized to 2dp"
+    assert parse_decimal("$1,234.567") == Decimal("1234.57"), "Rounding (HALF_EVEN) wrong"
+    assert parse_decimal("0.005") == Decimal("0.00"), "Banker's rounding 0.005 -> 0.00"
+    assert parse_decimal("0.015") == Decimal("0.02"), "Banker's rounding 0.015 -> 0.02"
+    assert parse_decimal(None) is None, "None should return None"
+    assert parse_decimal("  ") is None, "Whitespace should return None"
+    print(f"  ✓ m4: parse_decimal quantizes to 2dp with ROUND_HALF_EVEN")
+
+
+if __name__ == "__main__":
+    tests = [
+        ("1) NAUPA II parser",          test_naupa2_parser),
+        ("2) NAUPA III XML parser",      test_naupa3_parser),
+        ("3) jurisdictions.json",        test_jurisdictions_json),
+        ("4) pre-commit hook installed", test_precommit_hook_installed),
+        ("5) M1 complete_state_submission", test_m1_complete_state_submission_via_transition_claim),
+        ("6) m4 Decimal precision",      test_m4_decimal_precision),
+    ]
+    passed = 0
+    for name, fn in tests:
+        try:
+            fn()
+            passed += 1
+        except Exception as exc:
+            print(f"  ✗ {name}: {exc}")
+
+    print(f"\n{passed}/{len(tests)} CYCLE-3 TESTS PASSED {'✅' if passed == len(tests) else '❌'}")

← 831558d auto-save: 2026-07-31T15:29:10 (1 files) — docs/LOOP-LEDGER.  ·  back to Unclaimed Property Platform  ·  Cycle 3 complete: blocking_keys, Sec2.5 encryption marker, r a02e4b2 →