Fix Review 64 valuation authority regressions

This commit is contained in:
2026-10-07 13:39:26 +00:00
parent c7a9cfa50d
commit e41db959e4
6 changed files with 220 additions and 7 deletions
+7
View File
@@ -60,6 +60,13 @@
| `return_rate` | REAL | 선택 | NULL | finite number | ratio | 아니오 | | `return_rate` | REAL | 선택 | NULL | finite number | ratio | 아니오 |
| `representative_liability_definition` | TEXT | loan 필수 | NULL | principal_only/principal_interest_fees | enum | 아니오 | | `representative_liability_definition` | TEXT | loan 필수 | NULL | principal_only/principal_interest_fees | enum | 아니오 |
#### `select_account_valuation` 선택 규칙과 기존 인덱스 매핑
- `select_account_valuation`은 `account_id is mandatory`인 단일 평가 선택 query다. `currency and valuation_kind/purpose are optional filters`이며, `basis_date exact match or <= upper bound` 조건을 지원한다.
- 선택 결과는 `verified leaf valuations only`다. 즉 superseded parent가 아닌 leaf만 권위로 사용하며, `future-dated and unverified valuations are excluded`한다.
- `idx_account_valuations_account_date supports account/date selection`; `idx_account_valuations_status_date supports status/date selection`. `neither index covers account + currency + purpose`이므로 해당 조합의 전체 선택 조건을 기존 인덱스가 대신 보장한다고 해석하지 않는다.
- `idempotency and single_correction indexes enforce write integrity, not selection`이다. 이 인덱스들은 재기록 방지와 correction 단일 child 불변조건을 위한 것이며 `select_account_valuation`의 후보 검색 인덱스가 아니다.
### manual-assets — 수동 자산/부채 ### manual-assets — 수동 자산/부채
- 책임: transaction이 아닌 평가 source - 책임: transaction이 아닌 평가 source
@@ -42,7 +42,7 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields:
if current is None: return fail("not_found", "account not found") if current is None: return fail("not_found", "account not found")
balance_update = "last_balance" in fields and fields.get("last_balance") is not None balance_update = "last_balance" in fields and fields.get("last_balance") is not None
basis_update = "last_balance_basis_date" in fields and fields.get("last_balance_basis_date") is not None basis_update = "last_balance_basis_date" in fields and fields.get("last_balance_basis_date") is not None
if basis_update and not balance_update: if basis_update and (not balance_update or fields.get("last_balance") == ""):
return fail("validation_error", "last_balance_basis_date requires last_balance") return fail("validation_error", "last_balance_basis_date requires last_balance")
normalized_basis = None normalized_basis = None
if basis_update: if basis_update:
+34 -6
View File
@@ -259,6 +259,16 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool =
return included, excluded return included, excluded
def _persisted_evidence_array(value: Any) -> list[dict[str, Any]] | None:
try:
decoded = json.loads(value or "[]")
except (TypeError, json.JSONDecodeError):
return None
if not isinstance(decoded, list) or any(not isinstance(item, dict) for item in decoded):
return None
return decoded
def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]: def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
"""Load one immutable close run and only the rows persisted by that run.""" """Load one immutable close run and only the rows persisted by that run."""
run = conn.execute( run = conn.execute(
@@ -287,6 +297,11 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[
)""", )""",
(basis_date, run["_run_rowid"]), (basis_date, run["_run_rowid"]),
).fetchone()[0] ).fetchone()[0]
evidence = {
field: _persisted_evidence_array(run[field])
for field in ("applied_evidence", "excluded_evidence", "carried_evidence")
}
integrity_error = any(value is None for value in evidence.values())
metadata = { metadata = {
"snapshot_run_id": run["id"], "snapshot_run_id": run["id"],
"is_correction": bool(run["correction_of_run_id"]), "is_correction": bool(run["correction_of_run_id"]),
@@ -298,9 +313,10 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[
"approved_at": run["approved_at"], "approved_at": run["approved_at"],
"approval_reason": run["approval_reason"], "approval_reason": run["approval_reason"],
"approval_reference": run["approval_reference"], "approval_reference": run["approval_reference"],
"applied_evidence": json.loads(run["applied_evidence"] or "[]"), "applied_evidence": evidence["applied_evidence"] or [],
"excluded_evidence": json.loads(run["excluded_evidence"] or "[]"), "excluded_evidence": evidence["excluded_evidence"] or [],
"carried_evidence": json.loads(run["carried_evidence"] or "[]"), "carried_evidence": evidence["carried_evidence"] or [],
"evidence_integrity_error": integrity_error,
"closed_at": run["created_at"] or "", "closed_at": run["created_at"] or "",
"previous_snapshot_row_count": previous_rows, "previous_snapshot_row_count": previous_rows,
"total_snapshot_rows_for_basis_date": total_rows, "total_snapshot_rows_for_basis_date": total_rows,
@@ -372,7 +388,10 @@ def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
for item in exclusions: for item in exclusions:
if item.get("candidate"): if item.get("candidate"):
key = (item.get("source_type", ""), item.get("source_id", "")) key = (item.get("source_type", ""), item.get("source_id", ""))
warning = candidate_warnings.setdefault(key, {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []}) warning_shape = {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []}
if item.get("blocking") is False:
warning_shape["blocking"] = False
warning = candidate_warnings.setdefault(key, warning_shape)
warning["candidates"].append(item["candidate"]) warning["candidates"].append(item["candidate"])
else: else:
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")}) warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")})
@@ -667,6 +686,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy): if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy):
return fail("authority_drift", "asset authority changed since preview", basis_date=basis) return fail("authority_drift", "asset authority changed since preview", basis_date=basis)
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis) latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
if latest_meta.get("evidence_integrity_error"):
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=latest_meta.get("snapshot_run_id", ""))
legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id")) legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id"))
if legacy_snapshot_rows and not correction_reason.strip(): if legacy_snapshot_rows and not correction_reason.strip():
return fail( return fail(
@@ -734,6 +755,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
or immutable_replay_run or immutable_replay_run
): ):
replay_rows, replay_meta = _snapshot_rows_for_run(conn, existing_run["id"]) replay_rows, replay_meta = _snapshot_rows_for_run(conn, existing_run["id"])
if replay_meta.get("evidence_integrity_error"):
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=existing_run["id"])
replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])] replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])]
replay_warnings = _closed_warnings(replay_meta, basis, replay_rows) replay_warnings = _closed_warnings(replay_meta, basis, replay_rows)
replay_public = _closed_run_metadata(replay_meta) replay_public = _closed_run_metadata(replay_meta)
@@ -816,7 +839,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
), ),
) )
snapshot_ids.append(snapshot_id) snapshot_ids.append(snapshot_id)
applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "last_synced_at", "currency", "valuation_id", "valuation_kind", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind") if item.get(k) is not None} for item in included] applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "last_synced_at", "currency", "valuation_id", "valuation_kind", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind", "unverified_candidates") if item.get(k) is not None} 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 = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])]
excluded_evidence.extend( excluded_evidence.extend(
{ {
@@ -824,6 +847,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
"source_id": warning.get("source_id", ""), "source_id": warning.get("source_id", ""),
"reason": "unverified_candidate", "reason": "unverified_candidate",
"candidate": candidate, "candidate": candidate,
"blocking": warning.get("blocking", True),
"state_hash": next( "state_hash": next(
state["state_hash"] state["state_hash"]
for state in preview["_candidate_state_hashes"] for state in preview["_candidate_state_hashes"]
@@ -922,6 +946,8 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None,
return fail("validation_error", f"invalid date: {basis_date}") return fail("validation_error", f"invalid date: {basis_date}")
with read_db(db_path) as conn: with read_db(db_path) as conn:
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis) rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
if close_metadata.get("evidence_integrity_error"):
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=close_metadata.get("snapshot_run_id", ""))
has_close_run = bool(close_metadata.get("snapshot_run_id")) has_close_run = bool(close_metadata.get("snapshot_run_id"))
snapshots = _snapshot_items(conn, basis) if rows else [] snapshots = _snapshot_items(conn, basis) if rows else []
legacy_snapshots = snapshots if rows and not has_close_run else [] legacy_snapshots = snapshots if rows and not has_close_run else []
@@ -1032,8 +1058,10 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
points = [] points = []
for row in basis_rows: for row in basis_rows:
basis = row["basis_date"] basis = row["basis_date"]
items = _snapshot_items(conn, basis)
_, metadata = _snapshot_rows_for_latest_run(conn, basis) _, metadata = _snapshot_rows_for_latest_run(conn, basis)
if metadata.get("evidence_integrity_error"):
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=metadata.get("snapshot_run_id", ""))
items = _snapshot_items(conn, basis)
run_public = _closed_run_metadata(metadata) run_public = _closed_run_metadata(metadata)
run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall()) run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall())
run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis) run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis)
+123
View File
@@ -1,10 +1,133 @@
from __future__ import annotations from __future__ import annotations
import json
import sqlite3 import sqlite3
from jarca_ledger_mcp import service from jarca_ledger_mcp import service
def test_replay_preserves_redacted_unverified_candidates_after_correction(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("투자 계좌", type="investment", db_path=db_path)
account_id = account["account"]["id"]
verified = service.register_account_valuation(
account_id, "2026-06-30", 100, verification_status="verified",
idempotency_key="valuation-verified", db_path=db_path,
)
candidate = service.register_account_valuation(
account_id, "2026-06-30", 110, verification_status="unverified",
idempotency_key="valuation-candidate", db_path=db_path,
)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
)
assert first["success"] is True
assert first["included_items"][0]["unverified_candidates"] == [{
"id": candidate["valuation"]["id"],
"basis_date": "2026-06-30",
"currency": "KRW",
"valuation_kind": "balance",
"verification_status": "unverified",
}]
assert {warning["blocking"] for warning in first["warnings"] if warning["code"] == "unverified_candidate"} == {False}
assert first["included_items"][0]["valuation_id"] == verified["valuation"]["id"]
with sqlite3.connect(db_path) as conn:
persisted = conn.execute("SELECT applied_evidence, excluded_evidence FROM asset_snapshot_runs WHERE id=?", (first["snapshot_run_id"],)).fetchone()
applied_evidence, excluded_evidence = map(json.loads, persisted)
assert applied_evidence[0]["unverified_candidates"] == first["included_items"][0]["unverified_candidates"]
candidate_evidence = [item for item in excluded_evidence if item.get("reason") == "unverified_candidate"]
assert candidate_evidence[0]["candidate"] == first["included_items"][0]["unverified_candidates"][0]
assert candidate_evidence[0]["blocking"] is False
assert candidate_evidence[0]["state_hash"]
corrected = service.close_asset_snapshot(
"2026-06-30", correction_reason="preserve evidence", idempotency_key="candidate-close-b",
db_path=db_path,
)
assert corrected["success"] is True
with sqlite3.connect(db_path) as conn:
before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
)
with sqlite3.connect(db_path) as conn:
after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
after_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
assert replay == first
assert replay["included_items"] == first["included_items"]
assert replay["warnings"] == first["warnings"]
assert after_replay == before_replay
assert after_runs == before_runs
def test_replay_preserves_blocking_candidate_warning_shape(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
account_id = service.create_ledger_account(
"미확인 계좌", type="investment", db_path=db_path,
)["account"]["id"]
service.register_account_valuation(
account_id, "2026-06-30", 110, verification_status="unverified",
idempotency_key="only-candidate", db_path=db_path,
)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="blocking-candidate-close",
allow_missing_exclusions=True, approved_by="reviewer",
approval_reason="exclude unverified evidence",
approval_reference="synthetic-approval", db_path=db_path,
)
assert first["success"] is True
warning = next(item for item in first["warnings"] if item["code"] == "unverified_candidate")
assert "blocking" not in warning
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="blocking-candidate-close",
allow_missing_exclusions=True, approved_by="reviewer",
approval_reason="exclude unverified evidence",
approval_reference="synthetic-approval", db_path=db_path,
)
assert replay == first
replay_warning = next(item for item in replay["warnings"] if item["code"] == "unverified_candidate")
assert "blocking" not in replay_warning
def test_malformed_persisted_evidence_fails_closed_without_snapshot_writes(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="malformed-evidence-close", db_path=db_path,
)
assert first["success"] is True
with sqlite3.connect(db_path) as conn:
conn.execute(
"UPDATE asset_snapshot_runs SET excluded_evidence=? WHERE id=?",
("{malformed", first["snapshot_run_id"]),
)
before_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
report = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="malformed-evidence-close", db_path=db_path,
)
timeseries = service.get_net_worth_timeseries(
"2026-06-01", "2026-06-30", db_path=db_path,
)
for result in (report, replay, timeseries):
assert result["success"] is False
assert result["error_code"] == "data_integrity_error"
with sqlite3.connect(db_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == before_rows
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == before_runs
def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(tmp_path): def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3") db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path) service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
@@ -65,6 +65,42 @@ def test_late_import_current_balance_is_authoritative_by_evidence_basis_date(mon
} }
def test_empty_balance_with_basis_date_is_rejected_without_mutation_before_import(tmp_path, monkeypatch):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("KB", institution="KB국민은행", db_path=path)["account"]["id"]
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
before_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
rejected = service.update_ledger_account(
account,
last_balance="",
last_balance_basis_date="2026-07-01",
db_path=path,
)
after = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
after_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert after == before
assert after_valuations == before_valuations
monkeypatch.setattr(
imports_service,
"parse_kb_bank_pdf",
lambda *_: _parsed(_transaction("2026-06-30", 900, 1), "hash-valid"),
)
monkeypatch.setattr(imports_service, "now_iso", lambda: "2026-07-02T09:00:00")
imported = imports_service.import_kb_bank_pdf("synthetic", "pw", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
valuations = service.list_account_valuations(account, db_path=path)["valuations"]
assert imported["success"] is True
assert current["last_balance"] == 900
assert current["last_balance_basis_date"] == "2026-06-30"
assert {(item["basis_date"], item["amount"]) for item in valuations} == {("2026-06-30", 900)}
def test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate(tmp_path): def test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate(tmp_path):
path = str(tmp_path / "ledger.sqlite3") path = str(tmp_path / "ledger.sqlite3")
unknown = service.create_ledger_account( unknown = service.create_ledger_account(
+19
View File
@@ -43,6 +43,25 @@ def test_interface_contract_documents_public_valuation_enums_validation_and_mapp
assert marker in interface, marker assert marker in interface, marker
def test_data_model_documents_valuation_selection_contract_and_index_mapping() -> None:
data_model = _text("docs/data-model.md")
required_contract = (
"select_account_valuation",
"account_id is mandatory",
"currency and valuation_kind/purpose are optional filters",
"basis_date exact match or <= upper bound",
"verified leaf valuations only",
"future-dated and unverified valuations are excluded",
"idx_account_valuations_account_date supports account/date selection",
"idx_account_valuations_status_date supports status/date selection",
"neither index covers account + currency + purpose",
"idempotency and single_correction indexes enforce write integrity, not selection",
)
for marker in required_contract:
assert marker in data_model, marker
def test_loan_cache_writers_document_no_inferred_valuation_history() -> None: def test_loan_cache_writers_document_no_inferred_valuation_history() -> None:
data_model = _text("docs/data-model.md") data_model = _text("docs/data-model.md")
interface = _text("docs/interface-contract.md") interface = _text("docs/interface-contract.md")