fix: harden valuation evidence selection
This commit is contained in:
@@ -48,6 +48,13 @@ def _evidence_state_hash(item: dict[str, Any]) -> str:
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _valuation_row_state_hash(row: sqlite3.Row) -> str:
|
||||
"""Bind redacted candidate evidence to its complete immutable row."""
|
||||
return hashlib.sha256(
|
||||
json.dumps(row_to_dict(row), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", selection_policy: str = "exact") -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall()
|
||||
included: list[dict[str, Any]] = []
|
||||
@@ -116,6 +123,13 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
|
||||
excluded.append({**base, "reason": "missing_verified_exact_valuation" if selection_policy == "exact" else "missing_verified_valuation", "unverified_candidates": [_public_candidate(v) for v in candidate_rows]})
|
||||
continue
|
||||
valuation = selected
|
||||
candidate_rows.extend(
|
||||
v for v in leaves
|
||||
if v["valuation_kind"] == selected_purpose
|
||||
and v["verification_status"] != "verified"
|
||||
and v["basis_date"] <= basis_date
|
||||
and (v["basis_date"] == basis_date if selection_policy == "exact" else True)
|
||||
)
|
||||
if account_type == "loan" and (
|
||||
selected_purpose not in {"loan", "loan_balance"}
|
||||
or not valuation["representative_liability_definition"]
|
||||
@@ -363,6 +377,8 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_
|
||||
if item.get("unverified_candidates"):
|
||||
warnings.append({"code": "unverified_candidate", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "candidates": [{k: candidate.get(k) for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")} for candidate in item["unverified_candidates"]]})
|
||||
for item in included:
|
||||
if item.get("unverified_candidates"):
|
||||
warnings.append({"code": "unverified_candidate", "blocking": False, "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "candidates": [{k: candidate.get(k) for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")} for candidate in item["unverified_candidates"]]})
|
||||
item_date = str(item.get("valuation_date") or "")[:10]
|
||||
if item_date and item_date < basis:
|
||||
warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis})
|
||||
@@ -370,6 +386,23 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_
|
||||
warnings.append({"code": "invalid_liquidity_bucket", **item})
|
||||
if existing_rows:
|
||||
warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"})
|
||||
candidate_ids = sorted({
|
||||
candidate.get("id", "")
|
||||
for warning in warnings
|
||||
if warning.get("code") == "unverified_candidate"
|
||||
for candidate in warning.get("candidates", [])
|
||||
if candidate.get("id")
|
||||
})
|
||||
candidate_state_hashes = []
|
||||
for candidate_id in candidate_ids:
|
||||
candidate_row = conn.execute(
|
||||
"SELECT * FROM account_valuations WHERE id=?", (candidate_id,),
|
||||
).fetchone()
|
||||
if candidate_row is not None:
|
||||
candidate_state_hashes.append({
|
||||
"id": candidate_id,
|
||||
"state_hash": _valuation_row_state_hash(candidate_row),
|
||||
})
|
||||
return {
|
||||
"basis_date": basis,
|
||||
"source": "live_authority",
|
||||
@@ -378,6 +411,7 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_
|
||||
"existing_snapshot_row_count": int(existing_rows),
|
||||
"would_be_correction": bool(existing_rows),
|
||||
"warnings": warnings,
|
||||
"_candidate_state_hashes": candidate_state_hashes,
|
||||
**summary,
|
||||
}
|
||||
|
||||
@@ -392,8 +426,9 @@ def preview_asset_snapshot_close(basis_date: str = "", selection_policy: str = "
|
||||
with db(db_path) as conn:
|
||||
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
|
||||
preview["selection_policy"] = selection_policy
|
||||
blocking = [w for w in preview["warnings"] if w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_", "stale_"))]
|
||||
blocking = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_", "stale_"))]
|
||||
preview["close_status"] = "complete_exact" if selection_policy == "exact" and not blocking else "incomplete_draft"
|
||||
preview.pop("_candidate_state_hashes", None)
|
||||
return ok(**preview)
|
||||
|
||||
|
||||
@@ -409,10 +444,10 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
|
||||
missing_exclusions = [w for w in preview["warnings"] if w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
|
||||
missing_exclusions = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
|
||||
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
|
||||
conditional = bool(missing_exclusions or carried_items)
|
||||
fingerprint_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "allow_missing_exclusions": allow_missing_exclusions, "allow_as_of_carry_forward": allow_as_of_carry_forward, "approved_by": approved_by, "approval_reason": approval_reason, "approval_reference": approval_reference, "included": [{**{k: i.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["included_items"]], "excluded": [{**{k: i.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["excluded_items"]]}
|
||||
fingerprint_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "allow_missing_exclusions": allow_missing_exclusions, "allow_as_of_carry_forward": allow_as_of_carry_forward, "approved_by": approved_by, "approval_reason": approval_reason, "approval_reference": approval_reference, "included": [{**{k: i.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["included_items"]], "excluded": [{**{k: i.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["excluded_items"]], "warnings": [warning for warning in preview["warnings"] if warning.get("code") != "existing_snapshot_rows"], "candidate_state_hashes": preview["_candidate_state_hashes"]}
|
||||
fingerprint = hashlib.sha256(json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
||||
if missing_exclusions and (not allow_missing_exclusions or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()):
|
||||
return fail("conditional_approval_required", "missing exclusions require explicit approval metadata and allow_missing_exclusions")
|
||||
@@ -469,6 +504,22 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
summary = _summarize_valuation_items(included, [])
|
||||
applied_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")} for item in included]
|
||||
excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])]
|
||||
excluded_evidence.extend(
|
||||
{
|
||||
"source_type": warning.get("source_type", ""),
|
||||
"source_id": warning.get("source_id", ""),
|
||||
"reason": "unverified_candidate",
|
||||
"candidate": candidate,
|
||||
"state_hash": next(
|
||||
state["state_hash"]
|
||||
for state in preview["_candidate_state_hashes"]
|
||||
if state["id"] == candidate.get("id")
|
||||
),
|
||||
}
|
||||
for warning in preview["warnings"]
|
||||
if warning.get("code") == "unverified_candidate"
|
||||
for candidate in warning.get("candidates", [])
|
||||
)
|
||||
carried_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")} for item in included if item.get("carried_forward")]
|
||||
try:
|
||||
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (run_id, basis, "conditional" if conditional else "complete_exact", selection_policy, idempotency_key, approved_by, ts if approved_by else "", approval_reason, approval_reference, correction_of_run_id, correction_reason.strip(), json.dumps(applied_evidence, ensure_ascii=False, sort_keys=True), json.dumps(excluded_evidence, ensure_ascii=False, sort_keys=True), json.dumps(carried_evidence, ensure_ascii=False, sort_keys=True), fingerprint, ts))
|
||||
|
||||
Reference in New Issue
Block a user