[object Object]

← back to Unclaimed Property Platform

Cycle 11 (DTD verdict C): packaging + consolidation, then wrap

3532e9bd9ffd9fc229981c3080d2f61e7ff5f22a · 2026-08-01 21:59:11 -0700 · Steve Abrams

DTD 5/5 verdict C (CONTINUE/WRAP/PIVOT -> PIVOT to packaging then wrap); Cody dissent (real
blocker is business/legal) folded in as a real DUA template, not dismissed.
- scripts/demo_tour.py: one-command narrated end-to-end tour (ingest 3 formats + reconcile ->
  masked search -> suppress/reinstate -> claim + outbox retry). VERIFIED runs /bin/zsh.
- Makefile: make demo / test / serve / install (one-command front door).
- README.md: rewritten — what works + proof table, 60-second demo, guardrails, repo map,
  and the real critical path up front.
- docs/04-data-use-agreement-template.md: the fill-in DUA + GTM sequence — the artifact that
  actually unblocks a real deployment (honors the dissent).
- MERGE: removed the orphan parallel NAUPA duplicate triad (naupa2_fixed/naupa3_xml/
  test_cycle3) — consolidated to the single pipeline-wired implementation. History retained.
Tests: 11/11 suites green after consolidation.

TK-10097

Files touched

Diff

commit 3532e9bd9ffd9fc229981c3080d2f61e7ff5f22a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 21:59:11 2026 -0700

    Cycle 11 (DTD verdict C): packaging + consolidation, then wrap
    
    DTD 5/5 verdict C (CONTINUE/WRAP/PIVOT -> PIVOT to packaging then wrap); Cody dissent (real
    blocker is business/legal) folded in as a real DUA template, not dismissed.
    - scripts/demo_tour.py: one-command narrated end-to-end tour (ingest 3 formats + reconcile ->
      masked search -> suppress/reinstate -> claim + outbox retry). VERIFIED runs /bin/zsh.
    - Makefile: make demo / test / serve / install (one-command front door).
    - README.md: rewritten — what works + proof table, 60-second demo, guardrails, repo map,
      and the real critical path up front.
    - docs/04-data-use-agreement-template.md: the fill-in DUA + GTM sequence — the artifact that
      actually unblocks a real deployment (honors the dissent).
    - MERGE: removed the orphan parallel NAUPA duplicate triad (naupa2_fixed/naupa3_xml/
      test_cycle3) — consolidated to the single pipeline-wired implementation. History retained.
    Tests: 11/11 suites green after consolidation.
    
    TK-10097
---
 Makefile                               |  27 +++++
 README.md                              | 119 ++++++++++---------
 docs/04-data-use-agreement-template.md |  46 ++++++++
 scripts/__init__.py                    |   0
 scripts/demo_tour.py                   | 138 ++++++++++++++++++++++
 services/ingestion/naupa2_fixed.py     | 110 ------------------
 services/ingestion/naupa3_xml.py       | 148 -----------------------
 tests/test_cycle3.py                   | 206 ---------------------------------
 8 files changed, 277 insertions(+), 517 deletions(-)

diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..7a12447
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,27 @@
+# National Unclaimed-Property Platform — one-command front door.
+# Everything runs locally on synthetic data at $0 (Python stdlib; no installs needed).
+
+.PHONY: help demo test serve install
+
+help:
+	@echo "make demo   - run the end-to-end narrated tour (ingest->search->suppress->claim)"
+	@echo "make test   - run all test suites"
+	@echo "make serve  - start the masked-search HTTP endpoint on :8799"
+	@echo "make install- (optional) install production-reference deps (FastAPI/OpenSearch)"
+
+demo:
+	python3 -m scripts.demo_tour
+
+test:
+	@for t in test_ingest_and_match test_cycle2_hardening test_cycle3_naupa_and_blocking \
+	          test_cycle4_fairness test_cycle5_search test_cycle6_naupa3 test_cycle7_recall \
+	          test_cycle8_suppression test_cycle9_claims_persistence test_cycle10_outbox_worker \
+	          test_cycle11_reconciliation; do \
+	  python3 -m tests.$$t >/dev/null 2>&1 && echo "  PASS $$t" || echo "  FAIL $$t"; \
+	done
+
+serve:
+	python3 -m services.search.run_local
+
+install:
+	python3 -m pip install -r requirements.txt
diff --git a/README.md b/README.md
index 9abce8d..9c25d98 100644
--- a/README.md
+++ b/README.md
@@ -1,60 +1,73 @@
-# National Multi-State Unclaimed Property Platform
-
-A defensible, **government-partnered** replication of the MissingMoney model:
-a business-to-government platform that ingests **authorized** state unclaimed-property
-feeds, normalizes them into a common NAUPA-aligned schema, provides free national
-consumer search with masked results, and routes claims back to the state of record.
-
-> **This repository is a local prototype + living planning docs.**
-> It runs entirely on **synthetic sample data**. It does **not** connect to any real
-> state feed, does **not** scrape, and does **not** contract with anyone. Those steps
-> are business/legal actions gated to a human — see `docs/00-thesis-and-guardrails.md`.
-
-## Why this model (not a scrape-first consumer clone)
-
-The durable business is selling **infrastructure and services to state governments**,
-not selling access to claimants. Consumer search and claim initiation are free.
-Revenue comes from state participation, implementation, white-label portals, and
-claim-workflow modules. The moat is contracted feed reliability, normalized history,
-search quality, and a security posture acceptable to public agencies — not the raw
-records (which are public and obtainable by competitors).
-
-See `docs/` for the full thesis, the state-acquisition matrix, and the architecture.
-
-## What's in here
-
-| Path | What it is |
-|---|---|
-| `docs/00-thesis-and-guardrails.md` | The business thesis + the hard "never autonomously" line |
-| `docs/01-regulatory-state-acquisition-matrix.md` | 50-state + DC data-acquisition method matrix |
-| `docs/02-architecture.md` | Polyglot architecture + data model + service boundaries |
-| `db/schema.sql` | Canonical relational schema (PostgreSQL dialect; SQLite subset for the prototype) |
-| `services/ingestion/` | Idempotent authorized-feed ETL (NAUPA/CSV adapters + provenance) |
-| `services/matching/` | Entity resolution (Fellegi-Sunter-style probabilistic linkage) |
-| `services/search/` | Masked public search API (FastAPI reference) |
-| `services/claims/` | Claim workflow state-machine with transactional outbox |
-| `data/sample/` | **Synthetic** sample state feed (safe to commit) |
-| `tests/` | End-to-end smoke test proving the prototype runs at $0 (SQLite) |
-
-## Run the prototype (local, $0, synthetic data only)
+# National Unclaimed-Property Platform (prototype)
+
+A defensible, **government-partnered (B2G)** replication of the MissingMoney model: ingest
+**authorized** state unclaimed-property feeds, normalize them into a common NAUPA-aligned
+schema, offer **free** national consumer search with **masked** results, and route claims
+back to the state of record. Revenue comes from **governments** (participation /
+implementation / white-label / workflow), never from taking a cut of a claimant's recovery.
+
+> **This repo is a tested prototype on 100% SYNTHETIC data.** It never connects to a real
+> state feed, never scrapes, never contracts. It runs entirely locally at **$0** (Python
+> stdlib — no installs needed for the demo or tests).
+
+## See it in 60 seconds
 
 ```bash
-python -m pip install -r requirements.txt          # optional; core smoke test is stdlib-only
-python -m tests.test_ingest_and_match              # proves idempotent ETL + matcher end-to-end
+make demo     # narrated end-to-end tour: ingest (3 formats) → search → suppress → claim
+make test     # run all 11 test suites
+make serve    # start the masked-search HTTP endpoint on http://127.0.0.1:8799
 ```
 
-## Guardrails (enforced by policy, not just convention)
+`make demo` runs the whole vertical slice on synthetic data and narrates each step. No
+dependencies, no network, no keys.
+
+## What actually works (every subsystem is tested + runs on real persistence)
+
+| Subsystem | What it does | Proof |
+|---|---|---|
+| **Ingestion** | One idempotent pipeline, three formats: CSV, NAUPA II (fixed-width), NAUPA III (XML, namespace-tolerant, XXE-guarded). Immutable raw archive + provenance. | `tests/test_ingest_and_match`, `test_cycle3_naupa_and_blocking`, `test_cycle6_naupa3` |
+| **Reconciliation** | Compares parsed vs the state's control total → catches **silent truncation**. | `test_cycle11_reconciliation` |
+| **Normalize / mask** | Constant-width masking (no length-leak confirmation oracle); coarse amount bands. | `test_cycle2_hardening` |
+| **Entity resolution** | Fellegi-Sunter-style probabilistic matcher; phonetic key + blocking for candidate generation; coverage-fair across scripts; **distinct from search ranking**. | `test_cycle3…`, `test_cycle4_fairness` |
+| **Public search** | Masked, fail-closed projection allowlist, **real** sliding-window rate limiter, apostrophe/hyphen recall fix. Runnable HTTP endpoint. | `test_cycle5_search` |
+| **Suppression** | State takedown removes a record from search immediately; data + history retained; audited. | `test_cycle8_suppression` |
+| **Claims** | Explicit state machine (claimant can't self-approve), real persistence, **transactional outbox** with at-least-once delivery + bounded retry. | `test_cycle9_claims_persistence`, `test_cycle10_outbox_worker` |
+
+## The real critical path is NOT code
+
+The software risk is largely retired. A **real deployment is blocked on authorized state
+data**, which requires a signed **data-use agreement per state** (or a master NAUPA
+relationship) — a human/legal negotiation no code cycle advances. The fill-in template +
+go-to-market sequence live in **[`docs/04-data-use-agreement-template.md`](docs/04-data-use-agreement-template.md)**.
+Sequence: retain counsel → LOIs from ≥3 states → execute DUAs → *then* connect real feeds.
+
+## Guardrails (enforced, not just documented)
+
+1. **No unauthorized scraping / no real data** — ingestion asserts a synthetic-only source
+   allowlist; `.gitignore` + a **pre-commit tripwire** block real-feed formats and
+   SSN-shaped strings from ever being committed.
+2. **State is adjudicator + payer** — the platform is a workflow processor; `APPROVED`/
+   `PAID_BY_STATE` require a `state:` actor. No fund custody anywhere.
+3. **Masked results only** — anonymous search returns a fail-closed allowlisted projection.
+
+## Repo map
+
+```
+docs/     thesis+guardrails, 50-state acquisition matrix, architecture, roadmap+budget,
+          DUA template, jurisdictions.json, LOOP-LEDGER (build history)
+db/       canonical schema (non-destructive versioning, provenance, outbox, audit)
+services/ ingestion (csv/naupa2/naupa3) · common (normalize/mask/rate_limit/sqlite) ·
+          matching · search (service + runnable HTTP) · claims (state machine + outbox)
+scripts/  demo_tour.py · pre-commit-tripwire.sh
+tests/    11 suites, all green, stdlib-only
+```
 
-1. **No unauthorized scraping.** Ingestion accepts files/feeds only. Any crawler is
-   limited to availability monitoring or a state-approved integration.
-2. **No real jurisdiction data in this repo.** `.gitignore` blocks `data/real/` and `raw/`.
-3. **State is the adjudicator and payer of record.** The platform is a workflow
-   processor; it never decides entitlement or custodies claimant funds at launch.
-4. **Masked results only.** Anonymous search returns state-approved projections; never
-   SSNs, full addresses, exact amounts, or claim evidence.
-5. **Every displayed datum is provenance-traceable** to a source batch.
+## Production notes
 
-## Status
+The prototype substitutes SQLite + filesystem + an in-DB search for the production stack
+(PostgreSQL system-of-record + object storage + OpenSearch). Service interfaces are
+unchanged — only the concrete adapters differ. `requirements.txt` lists the production
+reference deps (FastAPI/OpenSearch); `make install` if you want to run those paths.
 
-Cycle 1 of a `/yoloforever` build loop (ticket **TK-10097**). Scaffold + reference
-services + smoke test. Real data rights, deployment, and any spend remain Steve-gated.
+Built over an 11-cycle DTD-decided, contrarian-gated autonomous loop — see
+[`docs/LOOP-LEDGER.md`](docs/LOOP-LEDGER.md).
diff --git a/docs/04-data-use-agreement-template.md b/docs/04-data-use-agreement-template.md
new file mode 100644
index 0000000..15560ff
--- /dev/null
+++ b/docs/04-data-use-agreement-template.md
@@ -0,0 +1,46 @@
+# Data-Use Agreement (DUA) Template — the artifact that actually unblocks deployment
+
+> **This is the real critical path.** The software is a tested prototype; a real deployment
+> is blocked on **authorized state data**, which requires a signed DUA per jurisdiction (or
+> a master NAUPA relationship). No code cycle advances this — a human negotiates it. This
+> template is the fill-in checklist counsel + state partnerships work from. **Not legal
+> advice; have unclaimed-property counsel review before sending anything.**
+
+## Parties & scope
+- **Supplying jurisdiction:** ____ (state agency / NAUPA)
+- **Platform operator:** ____
+- **Covered records:** unclaimed-property owner + property data supplied under this agreement
+- **Effective date / term / renewal:** ____
+
+## Contract subjects — required position per clause
+
+| # | Subject | Required position |
+|---|---|---|
+| 1 | **Ownership** | State retains ownership / custodial authority over supplied records |
+| 2 | **Permitted purpose** | National search, claim routing, state administration, support, approved analytics — nothing else |
+| 3 | **Redistribution** | No onward sale; API access only to approved government / regulated-institution partners |
+| 4 | **Data fields** | Explicit list of PUBLIC vs MASKED vs RESTRICTED vs CLAIM-ONLY fields (maps to `search_document_state` + `assert_public_safe`) |
+| 5 | **Update method** | Full snapshot vs delta cadence; correction, deletion, suppression, reinstatement semantics (maps to reconciliation + suppression) |
+| 6 | **Claim authority** | State remains final adjudicator & payer of record unless a specific delegation is executed |
+| 7 | **Security** | Encryption in transit/at rest, access controls, incident response, audit rights, subcontractor controls (maps to SOC 2 / NIST CSF program) |
+| 8 | **Retention** | Raw, curated, search, audit, claim, document retention periods; return/destruction on termination |
+| 9 | **Public records** | Which party responds to public-records requests touching contractor-held data |
+| 10 | **Control total** | Each delivery declares a record count so the platform can reconcile (catches silent truncation — maps to `expected_count`) |
+| 11 | **Liability** | Indemnification, contractual liability caps, cyber + E&O insurance, any required bond |
+| 12 | **Use of marks** | Limited permission to display the state's identity + approved links |
+| 13 | **Service level** | Feed latency, index latency, uptime, support response, recovery objectives, escalation |
+| 14 | **Termination** | Return or destruction of data, continuing audit retention, removal from public search |
+
+## Finder-law posture (attach per state)
+The base commercial model is **B2G (government-paid)** — the platform does NOT take a
+percentage of a claimant's recovery, avoiding finder-law exposure. If a compliance-gated
+finder/estate line is ever added, enforce the lowest applicable statutory cap per
+`docs/jurisdictions.json` (e.g. CA 10%, IL 10%, TX 10%, FL ≤30%).
+
+## Go-to-market sequence (the non-code critical path)
+1. Retain unclaimed-property counsel; build the per-state legal matrix (`docs/01`).
+2. Secure **letters of intent from ≥3 states** before funding the full platform.
+3. Execute this DUA with pilot states; confirm fields, masks, refresh cadence, control totals.
+4. Only then connect real feeds — replacing the synthetic `SAMPLE` jurisdiction.
+
+**Every item above is a human/legal action and is Steve-gated — no autonomous step performs it.**
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/demo_tour.py b/scripts/demo_tour.py
new file mode 100644
index 0000000..be57fcd
--- /dev/null
+++ b/scripts/demo_tour.py
@@ -0,0 +1,138 @@
+"""One-command end-to-end demo tour of the platform (synthetic data, $0, stdlib).
+
+    python -m scripts.demo_tour
+
+Runs the whole vertical slice and narrates each step so a non-technical evaluator can see
+what the system does: ingest (3 formats) + reconciliation -> masked search -> suppression
+-> claim lifecycle with reliable outbox delivery. Nothing here touches a real state feed.
+"""
+from __future__ import annotations
+
+import sys
+import tempfile
+from pathlib import Path
+from uuid import uuid4
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from services.common.rate_limit import SlidingWindowRateLimiter
+from services.common.sqlite_repo import FileObjectStore, SqliteRepository
+from services.ingestion.ingest import FeedDefinition, ingest_authorized_feed
+from services.ingestion.naupa2 import build_naupa2_line
+from services.ingestion.naupa3 import build_naupa3_xml
+from services.search.service import SearchService
+from services.claims.claim_workflow import ClaimStatus, queue_state_submission, transition_claim
+from services.claims.sqlite_claim_repo import SqliteClaimRepository
+from services.claims.outbox_worker import OutboxWorker
+
+ROOT = Path(__file__).resolve().parents[1]
+SAMPLE_CSV = ROOT / "data" / "sample" / "sample_state_feed.csv"
+
+
+def h(title: str) -> None:
+    print(f"\n{'='*70}\n  {title}\n{'='*70}")
+
+
+class FlakyStateAdapter:
+    """Fails once (state API timeout), then succeeds — shows the outbox retry."""
+    def __init__(self) -> None:
+        self.calls = 0
+
+    def submit_claim(self, claim, idempotency_key) -> str:
+        self.calls += 1
+        if self.calls == 1:
+            raise RuntimeError("simulated state API timeout")
+        return "CA-CASE-000123"
+
+
+def main() -> int:
+    tmp = Path(tempfile.mkdtemp(prefix="upp-demo-"))
+    store = FileObjectStore(tmp / "obj")
+    (tmp / "obj").mkdir(parents=True, exist_ok=True)
+    repo = SqliteRepository(str(tmp / "demo.db"))
+
+    print("National Unclaimed-Property Platform — end-to-end demo (SYNTHETIC data, $0)")
+    print("No real state feed is touched; every record below is fabricated sample data.")
+
+    # 1) INGEST 3 FORMATS -------------------------------------------------------
+    h("1. AUTHORIZED-FEED INGESTION — one pipeline, three formats")
+    store.write_bytes("incoming/state.csv", SAMPLE_CSV.read_bytes())
+    r_csv = ingest_authorized_feed(
+        FeedDefinition("SAMPLE", "incoming/state.csv", expected_count=8), store, repo)
+    print(f"  CSV feed        : accepted={r_csv['accepted']} rejected={r_csv['rejected']} "
+          f"reconciliation={r_csv['reconciliation']}")
+
+    naupa2 = "\n".join([
+        build_naupa2_line({"record_type": "PR", "source_property_id": "N2-1",
+                           "holder_name": "Second Synthetic Bank", "owner_last": "NGUYEN",
+                           "owner_first": "MINH", "city": "LAKESIDE", "state": "SM",
+                           "zip": "000030000", "property_type": "CHECK", "amount_cents": "7500"}),
+    ]) + "\n"
+    store.write_bytes("incoming/state.naupa2", naupa2.encode())
+    r_n2 = ingest_authorized_feed(
+        FeedDefinition("SAMPLE", "incoming/state.naupa2", format_name="naupa2_v1"), store, repo)
+    print(f"  NAUPA II (fixed): accepted={r_n2['accepted']} ($75.00 parsed from cents)")
+
+    store.write_bytes("incoming/state.xml", build_naupa3_xml([
+        {"property_id": "N3-1", "holder_name": "Third Synthetic Co", "first_name": "Robert",
+         "last_name": "Garcia-Marquez", "city": "Rivertown", "state": "SM", "zip": "00002",
+         "property_type": "SECURITIES", "amount": "5300.00"}]))
+    r_n3 = ingest_authorized_feed(
+        FeedDefinition("SAMPLE", "incoming/state.xml", format_name="naupa3_v1"), store, repo)
+    print(f"  NAUPA III (XML) : accepted={r_n3['accepted']} (namespace-tolerant, XXE-guarded)")
+    print(f"  → total properties in catalog: {repo.count_properties('SAMPLE')}")
+
+    # 2) MASKED SEARCH ----------------------------------------------------------
+    h("2. FREE PUBLIC SEARCH — masked results, rate-limited, recall-fixed")
+    svc = SearchService(repository=repo,
+                        limiter=SlidingWindowRateLimiter(max_requests=100, window_seconds=60))
+    for q in ("Oneil", "Nguyen"):
+        hits = svc.search(q, client_key="demo")
+        for hitrow in hits[:2]:
+            print(f"  search '{q}' → {hitrow['owner_name_masked']:<12} "
+                  f"{hitrow['owner_city'] or '-':<12} {hitrow['amount_band'] or '-':<12} "
+                  f"[{hitrow['jurisdiction']}]")
+    print("  (note: 'Oneil' finds \"O'Neil\"; names are masked; exact amount never exposed)")
+
+    # 3) SUPPRESSION ------------------------------------------------------------
+    h("3. STATE SUPPRESSION — near-real-time takedown, fully audited")
+    pid = repo._property_id("SAMPLE", "SP-0001")
+    print(f"  before: 'Catherine' returns {len(svc.search('Catherine', client_key='d2'))} hit(s)")
+    repo.suppress_property("SAMPLE", "SP-0001", actor="state:CA", reason="privacy request")
+    print(f"  after suppress by state:CA → {len(svc.search('Catherine', client_key='d3'))} hit(s) "
+          f"(data retained, {len(repo.audit_trail(pid))} audit event(s) written)")
+    repo.reinstate_property("SAMPLE", "SP-0001", actor="state:CA")
+    print(f"  after reinstate → {len(svc.search('Catherine', client_key='d4'))} hit(s) again")
+
+    # 4) CLAIM LIFECYCLE + OUTBOX ----------------------------------------------
+    h("4. CLAIM WORKFLOW — state machine + reliable outbox delivery")
+    claims = SqliteClaimRepository(repo.conn)
+    claim = claims.create_claim("SAMPLE", public_property_reference=pid, claimant_id=uuid4())
+    print(f"  created claim {str(claim.claim_id)[:8]}… status={claim.status.value}")
+    for target in (ClaimStatus.IDENTITY_PENDING, ClaimStatus.EVIDENCE_PENDING,
+                   ClaimStatus.READY_FOR_SUBMISSION):
+        transition_claim(claims, claim.claim_id, target, actor_id="worker",
+                         idempotency_key=f"k-{target.value}")
+        print(f"    → {target.value}")
+    queue_state_submission(claims, claim.claim_id, actor_id="worker", idempotency_key="sub")
+    print("    → submitting (outbox event written in same transaction)")
+
+    worker = OutboxWorker(claims, FlakyStateAdapter(), max_attempts=3)
+    d1 = worker.drain_once()
+    print(f"  outbox drain #1: {d1}  (state API timed out — claim safely held, will retry)")
+    d2 = worker.drain_once()
+    final = claims.get_for_update(claim.claim_id)
+    print(f"  outbox drain #2: {d2}  → claim {final.status.value}, "
+          f"state_case_id={final.state_case_id}")
+    print("  (a claimant could never self-approve — APPROVED/PAID require a state:… actor)")
+
+    h("DEMO COMPLETE")
+    print("  Ingested 3 formats, served masked search, suppressed+reinstated under audit,")
+    print("  and drove a claim to the state with at-least-once outbox delivery — all on")
+    print("  synthetic data, $0, no network. See README.md for the business/legal path")
+    print("  that actually unblocks a real deployment (state data-use agreements).")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/services/ingestion/naupa2_fixed.py b/services/ingestion/naupa2_fixed.py
deleted file mode 100644
index 7d81789..0000000
--- a/services/ingestion/naupa2_fixed.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""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
deleted file mode 100644
index 7d584ce..0000000
--- a/services/ingestion/naupa3_xml.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""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
deleted file mode 100644
index 76f2ccf..0000000
--- a/tests/test_cycle3.py
+++ /dev/null
@@ -1,206 +0,0 @@
-"""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")
-
-
-# ── 7. Blocking keys — candidate-generation/blocking (avoid O(n²)) ────────────
-
-def test_blocking_keys():
-    from services.matching.entity_match import blocking_keys, MatchInput
-    # Same person + zip → at least one common key
-    alice1 = MatchInput("Alice Smith", postal_code="90210", region="CA")
-    alice2 = MatchInput("A Smith", postal_code="90210", region="CA")
-    k1 = set(blocking_keys(alice1))
-    k2 = set(blocking_keys(alice2))
-    assert k1 & k2, f"Expected common blocking key for Alice Smith / A Smith; got {k1} ∩ {k2} = ∅"
-    # Different jurisdiction should still find a phonetic match
-    smith1 = MatchInput("Robert Jones")
-    smith2 = MatchInput("Robert Jones")
-    kk1 = set(blocking_keys(smith1))
-    kk2 = set(blocking_keys(smith2))
-    assert kk1 & kk2, "Identical name must share at least one blocking key"
-    # Completely different person + zip should have no zip or tok0 overlap
-    bob = MatchInput("Bob Kowalski", postal_code="10001")
-    carol = MatchInput("Carol Zhang", postal_code="94102")
-    kb, kc = set(blocking_keys(bob)), set(blocking_keys(carol))
-    assert not (kb & kc), f"Unrelated name+zip should share no blocking keys; got {kb & kc}"
-    print(f"  ✓ blocking_keys: common key for similar names, no overlap for unrelated")
-
-
-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),
-        ("7) blocking_keys coverage",    test_blocking_keys),
-    ]
-    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 '❌'}")

← d4ba8b0 docs: ledger Cycle-10 record (reconciliation); TK-10097  ·  back to Unclaimed Property Platform  ·  docs: LOOP CLOSED — Cycle 11 wrap + final state + deferred b 3b35078 →