[object Object]

← back to Unclaimed Property Platform

auto-save: 2026-07-31T14:58:42 (8 files) — .gitignore services/claims/claim_workflow.py services/common/normalize.py services/common/sqlite_repo.py services/ingestion/ingest.py

f576a32e814c3de40f6146ce7738ea2c61380756 · 2026-07-31 14:58:44 -0700 · Steve Abrams

Files touched

Diff

commit f576a32e814c3de40f6146ce7738ea2c61380756
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 31 14:58:44 2026 -0700

    auto-save: 2026-07-31T14:58:42 (8 files) — .gitignore services/claims/claim_workflow.py services/common/normalize.py services/common/sqlite_repo.py services/ingestion/ingest.py
---
 .gitignore                           |  16 ++-
 services/claims/claim_workflow.py    |  20 ++-
 services/common/normalize.py         |  18 ++-
 services/common/public_projection.py |  38 ++++++
 services/common/sqlite_repo.py       |  93 +++++++++++---
 services/ingestion/ingest.py         |  32 ++++-
 services/search/search_api.py        |  13 +-
 tests/test_cycle2_hardening.py       | 240 +++++++++++++++++++++++++++++++++++
 8 files changed, 436 insertions(+), 34 deletions(-)

diff --git a/.gitignore b/.gitignore
index 10ada57..49d956a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,18 @@ build/
 *.sqlite3
 data/local/
 
-# NEVER commit real jurisdiction data — the repo is synthetic-only by policy
-data/real/
+# NEVER commit real jurisdiction data — the repo is synthetic-only by policy.
+# Invert the policy: ignore everything under data/, then force-allow ONLY the curated
+# synthetic sample. A real feed dropped anywhere under data/ is untracked by default.
+data/*
+!data/sample/
+!data/sample/**
 raw/
+
+# Real-feed formats must never be tracked ANYWHERE in the repo.
+*.naupa
+*.dat
+*.tab
+data/**/*.txt
+data/**/*.xml
+data/**/*.zip
diff --git a/services/claims/claim_workflow.py b/services/claims/claim_workflow.py
index d818abd..00a9355 100644
--- a/services/claims/claim_workflow.py
+++ b/services/claims/claim_workflow.py
@@ -38,13 +38,22 @@ ALLOWED_TRANSITIONS: dict[ClaimStatus, set[ClaimStatus]] = {
     ClaimStatus.SUBMITTING: {ClaimStatus.SUBMITTED_TO_STATE, ClaimStatus.READY_FOR_SUBMISSION},
     ClaimStatus.SUBMITTED_TO_STATE: {
         ClaimStatus.MORE_INFORMATION_REQUIRED, ClaimStatus.APPROVED, ClaimStatus.DENIED},
-    ClaimStatus.MORE_INFORMATION_REQUIRED: {ClaimStatus.EVIDENCE_PENDING, ClaimStatus.DENIED},
+    # ...also allow the claimant to withdraw when they can't supply more info (M2).
+    ClaimStatus.MORE_INFORMATION_REQUIRED: {
+        ClaimStatus.EVIDENCE_PENDING, ClaimStatus.DENIED, ClaimStatus.CANCELLED},
     ClaimStatus.APPROVED: {ClaimStatus.PAID_BY_STATE},
     ClaimStatus.DENIED: set(),
     ClaimStatus.PAID_BY_STATE: set(),
     ClaimStatus.CANCELLED: set(),
 }
 
+# Entitlement + payment decisions belong to the STATE, never the platform. A transition
+# into any of these requires a state-originated actor (id prefixed 'state:'), so an
+# internal/claimant actor can never self-approve or self-pay. (Security 2.3 / Cody #2.)
+STATE_ONLY_STATUSES = frozenset({
+    ClaimStatus.APPROVED, ClaimStatus.DENIED, ClaimStatus.PAID_BY_STATE,
+})
+
 
 @dataclass
 class Claim:
@@ -74,6 +83,10 @@ def transition_claim(repository: ClaimRepository, claim_id: UUID, target: ClaimS
     claim = repository.get_for_update(claim_id)
     if target not in ALLOWED_TRANSITIONS[claim.status]:
         raise ValueError(f"Invalid transition from {claim.status} to {target}")
+    if target in STATE_ONLY_STATUSES and not actor_id.startswith("state:"):
+        raise PermissionError(
+            f"{target.value} is a state entitlement decision; actor {actor_id!r} may not set it"
+        )
     previous = claim.status
     claim.status = target
     claim.version += 1
@@ -113,7 +126,10 @@ def complete_state_submission(repository: ClaimRepository, adapter: StateAdapter
     repository.save(claim)
     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=idempotency_key,
+        idempotency_key=f"{idempotency_key}:submitted",
     )
     return claim
diff --git a/services/common/normalize.py b/services/common/normalize.py
index a7ef97a..ee80e20 100644
--- a/services/common/normalize.py
+++ b/services/common/normalize.py
@@ -85,18 +85,24 @@ def normalize_postal(value: str | None) -> str | None:
     return digits or None
 
 
+# Constant mask width — MUST NOT leak the true token length. Revealing first-initial +
+# exact length turns anonymous search into a per-person CONFIRMATION oracle (a third party
+# who already knows a name+city can confirm that person has property, and the amount band).
+# A fixed-width mask lets a rightful owner recognize their own record without letting a
+# stranger confirm it about someone else. (Security audit finding 1.1.)
+_MASK_WIDTH = 3
+
+
 def mask_name(raw_name: str | None) -> str:
-    """Public-search masking: reveal first char of each token, mask the rest.
+    """Public-search masking: first char of each token + a CONSTANT-width mask.
 
-    'CATHERINE ONEILL' -> 'C******* O*****'  — enough to recognize, not to enumerate.
+    'CATHERINE ONEILL' -> 'C••• O•••'  (length is NOT revealed).
+    Per-jurisdiction policy may mask even more coarsely via jurisdiction_policy.config_json.
     """
     norm = normalize_text(raw_name)
     if not norm:
         return ""
-    masked_tokens = []
-    for tok in norm.split():
-        masked_tokens.append(tok[0] + ("*" * (len(tok) - 1)) if len(tok) > 1 else tok)
-    return " ".join(masked_tokens)
+    return " ".join(tok[0] + ("•" * _MASK_WIDTH) for tok in norm.split())
 
 
 # Coarse public amount bands — never expose the exact figure through anonymous search.
diff --git a/services/common/public_projection.py b/services/common/public_projection.py
new file mode 100644
index 0000000..bc9efeb
--- /dev/null
+++ b/services/common/public_projection.py
@@ -0,0 +1,38 @@
+"""Single source of truth for what an ANONYMOUS search response may contain.
+
+Both search paths (services/search/search_api.py and SqliteRepository.masked_search) must
+return exactly this field set and nothing else. `assert_public_safe` fails CLOSED — a
+projection that leaks a restricted field raises instead of shipping. This turns the
+public/restricted boundary from a convention (a hand-maintained SELECT list) into an
+enforced invariant covered by tests. (Security audit findings 1.4/1.5/1.6.)
+"""
+from __future__ import annotations
+
+PUBLIC_SEARCH_FIELDS = frozenset({
+    "public_reference",
+    "jurisdiction",
+    "owner_name_masked",
+    "owner_city",
+    "holder_name",
+    "property_type",
+    "amount_band",
+})
+
+# Substrings that must never appear in a public projection key. Catches the exact
+# divergence the audit found (masked_search returning `holder_name_raw`) and blocks the
+# obvious future leaks (ssn/dob/full address/exact amount/postal/claim evidence/normalized).
+_FORBIDDEN_SUBSTRINGS = (
+    "ssn", "dob", "birth", "raw", "address", "amount_exact",
+    "postal", "evidence", "_normalized", "tax",
+)
+
+
+def assert_public_safe(row: dict) -> dict:
+    """Raise if `row` contains any non-allowlisted or restricted-looking field."""
+    extra = set(row) - PUBLIC_SEARCH_FIELDS
+    if extra:
+        raise ValueError(f"projection leaked non-public fields: {sorted(extra)}")
+    bad = [k for k in row if any(s in k.lower() for s in _FORBIDDEN_SUBSTRINGS)]
+    if bad:
+        raise ValueError(f"projection field name looks restricted: {bad}")
+    return row
diff --git a/services/common/sqlite_repo.py b/services/common/sqlite_repo.py
index a39cc5b..efb651d 100644
--- a/services/common/sqlite_repo.py
+++ b/services/common/sqlite_repo.py
@@ -62,8 +62,12 @@ class SqliteRepository:
 
     # --- ingestion Repository protocol -------------------------------------
     def batch_exists(self, jurisdiction: str, checksum: str) -> bool:
+        # Only a SUCCESSFULLY-completed batch blocks a re-run. A 'failed'/'running' row must
+        # NOT gate a retry, or a partially-ingested file could never be reprocessed (C1).
         row = self.conn.execute(
-            "SELECT 1 FROM ingestion_batch WHERE jurisdiction_id=? AND checksum=?",
+            """SELECT 1 FROM ingestion_batch
+               WHERE jurisdiction_id=? AND checksum=?
+                 AND status IN ('completed', 'completed_with_errors')""",
             (jurisdiction, checksum),
         ).fetchone()
         return row is not None
@@ -111,27 +115,45 @@ class SqliteRepository:
                 (property_id, jur, spid, record.holder_name_raw, record.property_type, amount),
             )
 
-        # Non-destructive version row (one per batch delivery).
+        # Non-destructive version history: close the prior open version, then append.
+        # Setting effective_to makes point-in-time reconstruction possible (M3).
+        now = _now()
+        self.conn.execute(
+            "UPDATE property_version SET effective_to=? WHERE property_id=? AND effective_to IS NULL",
+            (now, property_id),
+        )
         self.conn.execute(
             """INSERT INTO property_version
                (property_version_id, property_id, batch_id, effective_from, raw_payload,
                 raw_record_hash)
                VALUES (?,?,?,?,?,?)""",
-            (str(uuid.uuid4()), property_id, batch_id, _now(),
+            (str(uuid.uuid4()), property_id, batch_id, now,
              record.raw_payload, record.raw_record_hash),
         )
 
-        # Owner: replace the current owner row for this property (prototype simplification).
-        self.conn.execute("DELETE FROM owner WHERE property_id=?", (property_id,))
-        self.conn.execute(
-            """INSERT INTO owner
-               (owner_id, property_id, owner_type, owner_name_raw, owner_name_normalized,
-                city_normalized, region, postal_code)
-               VALUES (?,?,?,?,?,?,?,?)""",
-            (str(uuid.uuid4()), property_id, record.owner_type, record.owner_name_raw,
-             record.owner_name_normalized, record.city_normalized, record.region,
-             record.postal_code),
-        )
+        # Owner: UPDATE the existing row in place, preserving owner_id so entity_link
+        # references survive a re-delivery. DELETE+INSERT would (a) break the entity_link
+        # FK and (b) throw away entity-resolution work every refresh (C4 / Cody #3).
+        existing_owner = self.conn.execute(
+            "SELECT owner_id FROM owner WHERE property_id=?", (property_id,)
+        ).fetchone()
+        if existing_owner:
+            self.conn.execute(
+                """UPDATE owner SET owner_type=?, owner_name_raw=?, owner_name_normalized=?,
+                   city_normalized=?, region=?, postal_code=? WHERE property_id=?""",
+                (record.owner_type, record.owner_name_raw, record.owner_name_normalized,
+                 record.city_normalized, record.region, record.postal_code, property_id),
+            )
+        else:
+            self.conn.execute(
+                """INSERT INTO owner
+                   (owner_id, property_id, owner_type, owner_name_raw, owner_name_normalized,
+                    city_normalized, region, postal_code)
+                   VALUES (?,?,?,?,?,?,?,?)""",
+                (str(uuid.uuid4()), property_id, record.owner_type, record.owner_name_raw,
+                 record.owner_name_normalized, record.city_normalized, record.region,
+                 record.postal_code),
+            )
 
         # Search publication state (masked projection only).
         self.conn.execute(
@@ -162,21 +184,54 @@ class SqliteRepository:
         return self.conn.execute("SELECT COUNT(*) FROM property").fetchone()[0]
 
     def count_versions(self, jurisdiction: str | None = None) -> int:
+        if jurisdiction:
+            return self.conn.execute(
+                """SELECT COUNT(*) FROM property_version pv
+                   JOIN property p ON p.property_id = pv.property_id
+                   WHERE p.jurisdiction_id=?""",
+                (jurisdiction,),
+            ).fetchone()[0]
         return self.conn.execute("SELECT COUNT(*) FROM property_version").fetchone()[0]
 
     def masked_search(self, name_query: str, limit: int = 20) -> list[dict]:
-        """In-DB fallback for the OpenSearch masked search (prototype only)."""
+        """In-DB fallback for the OpenSearch masked search (prototype only).
+
+        Returns the SAME allowlisted projection as the production API and runs each row
+        through assert_public_safe — so a raw-PII leak fails a test instead of shipping.
+        """
         from services.common.normalize import normalize_text
+        from services.common.public_projection import assert_public_safe
+
         norm = normalize_text(name_query)
+        # Reject empty / all-punctuation queries — an empty normalized query would LIKE '%'
+        # and walk the whole table, a mass-enumeration primitive (m2).
+        if not norm:
+            raise ValueError("search query must contain at least one alphanumeric token")
+
         rows = self.conn.execute(
-            """SELECT s.owner_name_masked, s.amount_band, p.jurisdiction_id, p.holder_name_raw,
-                      p.property_type
+            """SELECT s.owner_name_masked, s.amount_band, p.jurisdiction_id,
+                      p.property_id, p.holder_name_raw, p.property_type, o.city_normalized
                FROM owner o
                JOIN property p ON p.property_id = o.property_id
                JOIN search_document_state s ON s.property_id = p.property_id
                WHERE s.is_public=1 AND s.is_suppressed=0
                  AND o.owner_name_normalized LIKE ?
                LIMIT ?""",
-            (f"%{norm.split()[0]}%" if norm else "%", limit),
+            (f"%{norm.split()[0]}%", limit),
         ).fetchall()
-        return [dict(r) for r in rows]
+
+        # Build ONLY the public projection — holder_name_raw/property_id/city_normalized
+        # are read internally but never returned; note holder name is a masked-off public
+        # field per the schema design, so we surface it under the allowlisted key.
+        out = []
+        for r in rows:
+            out.append(assert_public_safe({
+                "public_reference": r["property_id"],
+                "jurisdiction": r["jurisdiction_id"],
+                "owner_name_masked": r["owner_name_masked"],
+                "owner_city": (r["city_normalized"] or None),
+                "holder_name": r["holder_name_raw"],
+                "property_type": r["property_type"],
+                "amount_band": r["amount_band"],
+            }))
+        return out
diff --git a/services/ingestion/ingest.py b/services/ingestion/ingest.py
index 77de406..642bbfc 100644
--- a/services/ingestion/ingest.py
+++ b/services/ingestion/ingest.py
@@ -63,6 +63,30 @@ class FeedDefinition:
     parser_version: str = "2026.07.1"
 
 
+# --- No-scrape / synthetic-only red line, ENFORCED IN CODE (Security finding 2.1) ---------
+# Crossing the line now requires editing THIS allowlist — a greppable, reviewable, human-
+# gateable change — not just passing a different URI or plugging in a networked ObjectStore.
+ALLOWED_SOURCE_PREFIXES = ("raw/", "incoming/", "data/sample/")
+ALLOWED_JURISDICTIONS = frozenset({"SAMPLE"})
+
+
+def _assert_authorized(feed: "FeedDefinition") -> None:
+    if feed.jurisdiction not in ALLOWED_JURISDICTIONS:
+        raise PermissionError(
+            f"jurisdiction {feed.jurisdiction!r} is not an authorized synthetic feed; "
+            f"real-feed ingestion is human-gated, not autonomous"
+        )
+    if "://" in feed.source_uri:
+        raise PermissionError(
+            "URL/network sources are forbidden — the platform ingests files handed to it "
+            "under a data-use agreement; it never scrapes or fetches from a portal"
+        )
+    if not any(feed.source_uri.startswith(p) for p in ALLOWED_SOURCE_PREFIXES):
+        raise PermissionError(
+            f"source_uri {feed.source_uri!r} is outside the synthetic-only allowlist"
+        )
+
+
 def _looks_like_business(name: str) -> bool:
     from services.common.normalize import CORPORATE_SUFFIXES
     return any(tok in CORPORATE_SUFFIXES for tok in normalize_text(name).split())
@@ -80,7 +104,12 @@ def parse_csv_feed(data: bytes, jurisdiction: str) -> Iterable[CanonicalProperty
         raw_hash = hashlib.sha256(serialized.encode()).hexdigest()
         owner_name = (row.get("owner_name") or "").strip()
         is_business = _looks_like_business(owner_name)
-        amount = parse_decimal(row.get("amount"))
+        # A malformed amount must NOT abort the whole batch (C2). Bad amount -> None; the
+        # raw value is preserved in raw_payload for later correction.
+        try:
+            amount = parse_decimal(row.get("amount"))
+        except ValueError:
+            amount = None
         owner_norm = (
             normalize_business(owner_name) if is_business else normalize_text(owner_name)
         )
@@ -105,6 +134,7 @@ def parse_csv_feed(data: bytes, jurisdiction: str) -> Iterable[CanonicalProperty
 
 def ingest_authorized_feed(feed: FeedDefinition, object_store: ObjectStore,
                            repository: Repository) -> dict:
+    _assert_authorized(feed)   # fail closed before any read (no-scrape red line)
     data = object_store.read_bytes(feed.source_uri)
     checksum = hashlib.sha256(data).hexdigest()
 
diff --git a/services/search/search_api.py b/services/search/search_api.py
index 02c2c8f..cf30083 100644
--- a/services/search/search_api.py
+++ b/services/search/search_api.py
@@ -52,11 +52,16 @@ if _DEPS:
         total_relation: str
         next_page_token: str | None = None
 
-    def require_rate_limit(
+    def require_rate_limit_STUB(
         forwarded_for: Annotated[str | None, Header()] = None,
     ) -> None:
-        """Replace with an atomic Redis limiter keyed by IP + device + session.
-        Raise 429 when anonymous-enumeration thresholds are exceeded."""
+        """SECURITY: THIS IS NOT A RATE LIMITER. It is a placeholder.
+
+        Anti-enumeration REQUIRES a real limiter keyed off the TRUSTED-proxy IP (never the
+        spoofable X-Forwarded-For header) plus per-session + global-velocity limits and
+        anomaly detection. Until that exists, this endpoint MUST NOT be exposed to the
+        internet with real data. The name ends in _STUB so no reviewer mistakes it for a
+        control. (Security finding 1.3.)"""
         if forwarded_for and len(forwarded_for) > 500:
             raise HTTPException(status_code=400, detail="Invalid forwarding header")
 
@@ -67,7 +72,7 @@ if _DEPS:
         jurisdiction: Annotated[str | None, Query(min_length=2, max_length=3)] = None,
         city: Annotated[str | None, Query(max_length=100)] = None,
         limit: Annotated[int, Query(ge=1, le=50)] = 20,
-        _: None = Depends(require_rate_limit),
+        _: None = Depends(require_rate_limit_STUB),
     ) -> "SearchResponse":
         must: list[dict[str, Any]] = [{
             "multi_match": {
diff --git a/tests/test_cycle2_hardening.py b/tests/test_cycle2_hardening.py
new file mode 100644
index 0000000..0ea4d18
--- /dev/null
+++ b/tests/test_cycle2_hardening.py
@@ -0,0 +1,240 @@
+"""Cycle 2 hardening tests — each asserts a specific finding from the adversarial review
+(Cody + security-auditor + code-reviewer) is actually fixed. Stdlib only, $0.
+
+Run:  python -m tests.test_cycle2_hardening
+"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from uuid import UUID, uuid4
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from services.common.normalize import mask_name, phonetic_key
+from services.common.public_projection import assert_public_safe
+from services.common.sqlite_repo import FileObjectStore, SqliteRepository
+from services.ingestion.ingest import FeedDefinition, ingest_authorized_feed
+from services.claims.claim_workflow import (
+    Claim, ClaimStatus, complete_state_submission, queue_state_submission, transition_claim,
+)
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+SAMPLE = REPO_ROOT / "data" / "sample" / "sample_state_feed.csv"
+
+
+def ok(msg: str) -> None:
+    print(f"  ✓ {msg}")
+
+
+def _fresh_repo(tmp: Path):
+    store = FileObjectStore(tmp / "objstore")
+    (tmp / "objstore").mkdir(parents=True, exist_ok=True)
+    store.write_bytes("incoming/sample.csv", SAMPLE.read_bytes())
+    repo = SqliteRepository(str(tmp / "proto.db"))
+    return store, repo
+
+
+# --- 1. Masking: constant width, no length leak (Security 1.1) --------------------------
+def test_mask_no_length_leak() -> None:
+    print("1) Masking hides token length (no confirmation oracle)")
+    a = mask_name("Jo Li")
+    b = mask_name("Catherine Oneill")
+    # both tokens masked to identical width regardless of true length
+    assert a == "J••• L•••", a
+    assert b == "C••• O•••", b
+    # you cannot infer length: a short and a long surname produce the same mask shape
+    assert len(a.split()[0]) == len(b.split()[0]), (a, b)
+    ok(f"'{a}' and '{b}' — identical mask width, length not revealed")
+
+
+# --- 2. Projection allowlist fails closed (Security 1.4/1.6) ----------------------------
+def test_projection_fails_closed() -> None:
+    print("2) Public projection is fail-closed")
+    assert_public_safe({"public_reference": "x", "jurisdiction": "SAMPLE",
+                        "owner_name_masked": "C•••", "owner_city": "SPRINGFIELD",
+                        "holder_name": "Bank", "property_type": "check", "amount_band": "Under $50"})
+    for leak in ({"owner_name_raw": "Catherine Oneill"},
+                 {"postal_code": "00001"},
+                 {"amount": "42.50"},
+                 {"owner_ssn": "x"}):
+        try:
+            assert_public_safe({"public_reference": "x", **leak})
+            raise AssertionError(f"assert_public_safe FAILED to catch leak: {leak}")
+        except ValueError:
+            pass
+    ok("allowlisted row passes; raw/postal/amount/ssn leaks all rejected")
+
+
+# --- 3. masked_search rejects enumeration + returns only public fields (m2, 1.4) ---------
+def test_masked_search_guards(tmp: Path) -> None:
+    print("3) masked_search: no empty-query enumeration, only public fields")
+    _, repo = _fresh_repo(tmp)
+    ingest_authorized_feed(FeedDefinition("SAMPLE", "incoming/sample.csv"),
+                           FileObjectStore(tmp / "objstore"), repo)
+    for bad in ("", "   ", "!!!"):
+        try:
+            repo.masked_search(bad)
+            raise AssertionError(f"empty/punct query {bad!r} should have been rejected")
+        except ValueError:
+            pass
+    hits = repo.masked_search("Oneill")
+    assert hits, "expected a masked hit"
+    for h in hits:
+        assert_public_safe(h)  # would raise if a raw field leaked
+    ok(f"empty queries rejected; {len(hits)} hit(s), all pass assert_public_safe")
+
+
+# --- 4. Idempotent RE-DELIVERY preserves owner_id + versions (C4/Cody#3, M3, m1) --------
+def test_redelivery_preserves_history(tmp: Path) -> None:
+    print("4) Re-delivery: owner_id survives, version history grows, count stable")
+    store, repo = _fresh_repo(tmp)
+    ingest_authorized_feed(FeedDefinition("SAMPLE", "incoming/sample.csv"), store, repo)
+
+    # find SP-0001's property + owner, and simulate an entity_link on it
+    prop = repo.conn.execute(
+        "SELECT property_id FROM property WHERE source_property_id='SP-0001'").fetchone()["property_id"]
+    owner_id = repo.conn.execute(
+        "SELECT owner_id FROM owner WHERE property_id=?", (prop,)).fetchone()["owner_id"]
+    repo.conn.execute("INSERT INTO canonical_entity(entity_id, entity_type) VALUES (?, 'person')",
+                      ("E1",))
+    repo.conn.execute(
+        "INSERT INTO entity_link(entity_link_id, owner_id, entity_id, score, model_version) "
+        "VALUES (?,?,?,?,?)", ("L1", owner_id, "E1", 0.99, "test"))
+    repo.conn.commit()
+
+    count_before = repo.count_properties("SAMPLE")
+
+    # a NEW file (different checksum) with SP-0001's amount changed 42.50 -> 99.99
+    changed = SAMPLE.read_bytes().replace(b"42.50", b"99.99", 1)
+    store.write_bytes("incoming/sample_v2.csv", changed)
+    r = ingest_authorized_feed(FeedDefinition("SAMPLE", "incoming/sample_v2.csv"), store, repo)
+    assert r["status"] in ("completed", "completed_with_errors"), r
+
+    # property count did NOT grow (upsert in place)
+    assert repo.count_properties("SAMPLE") == count_before, "re-delivery duplicated a property!"
+    # owner_id preserved -> entity_link still valid (C4 fixed: no DELETE+FK break)
+    still = repo.conn.execute(
+        "SELECT owner_id FROM owner WHERE property_id=?", (prop,)).fetchone()["owner_id"]
+    assert still == owner_id, "owner_id changed on re-delivery — entity links would break"
+    link = repo.conn.execute("SELECT 1 FROM entity_link WHERE owner_id=?", (owner_id,)).fetchone()
+    assert link is not None, "entity_link destroyed by re-delivery"
+    # two versions now, old one closed with effective_to (M3)
+    versions = repo.conn.execute(
+        "SELECT effective_to FROM property_version WHERE property_id=? ORDER BY effective_from",
+        (prop,)).fetchall()
+    assert len(versions) == 2, f"expected 2 versions, got {len(versions)}"
+    assert versions[0]["effective_to"] is not None, "prior version not closed (effective_to null)"
+    assert versions[1]["effective_to"] is None, "current version should be open"
+    ok(f"owner_id preserved, entity_link survived, {len(versions)} versions (prior closed)")
+
+
+# --- 5. No-scrape red line enforced in code (Security 2.1) ------------------------------
+def test_ingestion_source_allowlist(tmp: Path) -> None:
+    print("5) Ingestion refuses network/non-synthetic sources")
+    store, repo = _fresh_repo(tmp)
+    for bad in (FeedDefinition("SAMPLE", "https://ca.gov/portal/data.csv"),   # network
+                FeedDefinition("CA", "incoming/real_ca.csv"),                  # real jurisdiction
+                FeedDefinition("SAMPLE", "/etc/passwd")):                      # outside allowlist
+        try:
+            ingest_authorized_feed(bad, store, repo)
+            raise AssertionError(f"ingestion should have refused {bad}")
+        except PermissionError:
+            pass
+    ok("URL source, non-SAMPLE jurisdiction, and out-of-allowlist path all refused")
+
+
+# --- 6. Bad amount does not kill the batch (C2) ----------------------------------------
+def test_bad_amount_tolerated(tmp: Path) -> None:
+    print("6) A malformed amount is tolerated, not batch-fatal")
+    store = FileObjectStore(tmp / "obj6")
+    (tmp / "obj6").mkdir(parents=True, exist_ok=True)
+    csv = (b"property_id,holder_name,owner_name,address,city,state,zip,property_type,amount\n"
+           b"BAD-1,Bank,Test Owner,1 St,Town,SAMPLE,00001,check,not-a-number\n"
+           b"OK-1,Bank,Other Owner,2 St,Town,SAMPLE,00001,check,50.00\n")
+    store.write_bytes("incoming/bad.csv", csv)
+    repo = SqliteRepository(str(tmp / "p6.db"))
+    r = ingest_authorized_feed(FeedDefinition("SAMPLE", "incoming/bad.csv"), store, repo)
+    assert r["accepted"] == 2, f"expected both rows accepted, got {r}"
+    amt = repo.conn.execute(
+        "SELECT amount FROM property WHERE source_property_id='BAD-1'").fetchone()["amount"]
+    assert amt is None, f"malformed amount should store NULL, got {amt}"
+    ok("bad-amount row accepted with NULL amount; batch not failed")
+
+
+# --- 7. Claim workflow: state-only guard + idempotency non-collision (C3, 2.3) ----------
+class InMemoryClaimRepo:
+    """Minimal ClaimRepository that ENFORCES UNIQUE(claim_id, idempotency_key) so the C3
+    collision would actually raise if unfixed."""
+    def __init__(self, claim: Claim) -> None:
+        self._claim = claim
+        self._event_keys: set[tuple] = set()
+        self.outbox: list[dict] = []
+
+    def get_for_update(self, claim_id: UUID) -> Claim:
+        return self._claim
+
+    def save(self, claim: Claim) -> None:
+        self._claim = claim
+
+    def append_event(self, claim_id, event_type, payload, idempotency_key) -> None:
+        key = (claim_id, idempotency_key)
+        if key in self._event_keys:
+            raise ValueError(f"UNIQUE(claim_id, idempotency_key) violation: {key}")
+        self._event_keys.add(key)
+
+    def add_outbox_event(self, event_type, aggregate_id, payload) -> None:
+        self.outbox.append({"event_type": event_type, "payload": payload})
+
+
+class FakeStateAdapter:
+    def submit_claim(self, claim, idempotency_key) -> str:
+        return "STATE-CASE-123"
+
+
+def test_claim_workflow() -> None:
+    print("7) Claim workflow: state-only guard + idempotency non-collision")
+    claim = Claim(claim_id=uuid4(), jurisdiction="SAMPLE", public_property_reference="ref",
+                  claimant_id=uuid4(), status=ClaimStatus.SUBMITTED_TO_STATE, version=1)
+    repo = InMemoryClaimRepo(claim)
+
+    # a non-state actor must NOT be able to approve
+    try:
+        transition_claim(repo, claim.claim_id, ClaimStatus.APPROVED,
+                         actor_id="claimant:self", idempotency_key="ik-approve")
+        raise AssertionError("claimant self-approval should be refused")
+    except PermissionError:
+        pass
+    # a state actor CAN approve
+    transition_claim(repo, claim.claim_id, ClaimStatus.APPROVED,
+                     actor_id="state:CA-reviewer", idempotency_key="ik-approve")
+    assert repo._claim.status == ClaimStatus.APPROVED
+    ok("claimant approval refused; state actor approval allowed")
+
+    # idempotency non-collision across the two-step submission (C3)
+    claim2 = Claim(claim_id=uuid4(), jurisdiction="SAMPLE", public_property_reference="ref",
+                   claimant_id=uuid4(), status=ClaimStatus.READY_FOR_SUBMISSION, version=1)
+    repo2 = InMemoryClaimRepo(claim2)
+    queue_state_submission(repo2, claim2.claim_id, actor_id="worker", idempotency_key="ik-sub")
+    complete_state_submission(repo2, FakeStateAdapter(), claim2.claim_id, idempotency_key="ik-sub")
+    assert repo2._claim.status == ClaimStatus.SUBMITTED_TO_STATE
+    assert repo2._claim.state_case_id == "STATE-CASE-123"
+    ok("queue+complete submission used distinct idempotency keys (no collision)")
+
+
+def main() -> int:
+    import tempfile
+    tmp = Path(tempfile.mkdtemp(prefix="upp-cycle2-"))
+    test_mask_no_length_leak()
+    test_projection_fails_closed()
+    test_masked_search_guards(tmp / "a")
+    test_redelivery_preserves_history(tmp / "b")
+    test_ingestion_source_allowlist(tmp / "c")
+    test_bad_amount_tolerated(tmp / "d")
+    test_claim_workflow()
+    print("\nALL CYCLE-2 HARDENING ASSERTIONS PASSED ✅")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

← 9819a89 Cycle 1: scaffold national unclaimed-property platform (B2G,  ·  back to Unclaimed Property Platform  ·  docs(marker): Cycle 2 = adversarial-review fixes (see f576a3 527cc26 →