Fix loan account conversion invariant

This commit is contained in:
2026-10-07 03:58:52 +00:00
parent b8b8811a2a
commit 1b9eda683c
9 changed files with 219 additions and 2 deletions
+1
View File
@@ -127,6 +127,7 @@
- `last_synced_at` is the execution/sync timestamp: when the import or account write advanced the cache, not the evidence basis date.
- imports and account writes advance the account cache; non-loan writes may also append a dated `account_valuations` candidate. loan cache writes without an explicit liability definition do not append historical evidence, and updating the cache does not rewrite historical rows.
- `account_valuations` is the historical authority. A dated valuation remains append-only evidence even when it is older than the current cache; older evidence never rolls back the current cache.
- Every valuation attached to a `loan` account has an explicit `principal_only` or `principal_interest_fees` `representative_liability_definition`. A non-loan to loan conversion with any undefined or invalid attached valuation is rejected without changing the account or history; conversion with no valuations is allowed.
## 관계와 불변조건
+1 -1
View File
@@ -337,7 +337,7 @@ AppTest는 callback, query mapping, synthetic renderer output과 error/loading e
The public contract explicitly supports these exact values: source_type: `manual`, `import`, `account_write`, `reconciliation`, `snapshot`; verification_status: `verified`, `unverified`, `rejected`; valuation_kind: `balance`, `cash_balance`, `total_value`, `loan`, `loan_balance`; representative_liability_definition: `principal_only`, `principal_interest_fees`. These are the currently supported enums, not a delegation to source code.
loan accounts require representative_liability_definition for `loan`, `loan_balance`, and `balance` aliases. Those are the only accepted valuation kinds for loan accounts; `cash_balance` and `total_value` do not represent a loan liability and are rejected. legacy valuation-kind aliases remain accepted and map to the canonical purpose; alias acceptance does not broaden the canonical enum set. Invalid values, missing loan definitions, and incompatible definitions return `validation_error` and perform no write. A validation failure returns `validation_error` and performs no write.
loan accounts require representative_liability_definition for `loan`, `loan_balance`, and `balance` aliases. Those are the only accepted valuation kinds for loan accounts; `cash_balance` and `total_value` do not represent a loan liability and are rejected. legacy valuation-kind aliases remain accepted and map to the canonical purpose; alias acceptance does not broaden the canonical enum set. Invalid values, missing loan definitions, and incompatible definitions return `validation_error` and perform no write. A validation failure returns `validation_error` and performs no write. Converting an existing non-loan account to `loan` is rejected with `validation_error` if any attached valuation lacks an explicit valid definition; the account and valuation history remain unchanged. Conversion with no valuations remains allowed.
loan valuations map to liabilities and non-loan valuations map to assets. The output mapping preserves the existing envelopes and signatures: `success` remains the outcome field, failures include `error_code` and `error`, and validation or conflict failures do not partially write.
@@ -100,7 +100,7 @@ amount와 principal/interest/fee/cost_basis/net_contributed/profit은 exact inte
### REQ-003.05 — current와 historical authority
current latest display와 historical authority를 분리한다. `last_balance`는 opening evidence가 아니라 external current comparison이며, balance_after/opening evidence가 모두 없으면 `INCOMPLETE_CALCULATION`이다. 늦게 도착한 historical row는 current를 과거로 되돌리지 않으며 cannot roll back newer current cache. account/import writes는 real dated evidence가 있을 때만 dated `unverified` candidate를 만든다. 대출 cache write는 대표 부채 정의가 없으면 이력 후보를 추가하지 않는다. 이때 current cache와 import 처리는 유지하고 정의를 추정하지 않는다.
current latest display와 historical authority를 분리한다. `last_balance`는 opening evidence가 아니라 external current comparison이며, balance_after/opening evidence가 모두 없으면 `INCOMPLETE_CALCULATION`이다. 늦게 도착한 historical row는 current를 과거로 되돌리지 않으며 cannot roll back newer current cache. account/import writes는 real dated evidence가 있을 때만 dated `unverified` candidate를 만든다. 대출 cache write는 대표 부채 정의가 없으면 이력 후보를 추가하지 않는다. 이때 current cache와 import 처리는 유지하고 정의를 추정하지 않는다. 기존 valuation이 있는 계좌를 loan으로 변환할 때는 모든 valuation에 유효한 대표 부채 정의가 있어야 하며, 위반 시 계좌와 이력을 변경하지 않고 `validation_error`를 반환한다. valuation이 없는 변환은 허용한다.
**영향 범위**
- 인터페이스: account update/import candidate, list/select/report
@@ -0,0 +1,49 @@
{
"schema": "gitea-agent.contract-preservation-inventory/v1",
"owner": "jsfamily",
"repository": "jarca-ledger-mcp",
"issue": 12,
"pull_request": 13,
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"review_id": 61,
"reviewed_head": "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8",
"preserved_semantics": [
{
"criterion": "N01-loan-balance-definition",
"required": "Every valuation attached to a loan account explicitly records principal_only or principal_interest_fees; no writer or account-type transition may infer or bypass it.",
"evidence": [
"tests/test_account_valuation_edge_contracts.py",
"tests/test_issue_12_review59_account.py"
]
},
{
"criterion": "C05-current-import",
"required": "Loan cache and import operations remain successful without creating definition-less history, and late evidence never rolls back newer current cache.",
"evidence": [
"test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations",
"test_loan_import_preserves_transactions_and_current_cache_without_valuation_candidate"
]
},
{
"criterion": "C13-read-only-and-no-partial-write",
"required": "Validation failure changes neither account state nor append-only valuation history.",
"evidence": [
"test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation"
]
},
{
"criterion": "compatibility",
"required": "A non-loan account with no valuation history may still be converted to loan; non-loan candidate insertion and replay are unchanged.",
"evidence": [
"test_bank_to_loan_conversion_without_valuations_remains_allowed",
"test_non_loan_candidate_still_succeeds_and_reuses_idempotently"
]
}
],
"excluded_scope": [
"No inferred or automatic liability-definition migration.",
"No schema change.",
"No changes to snapshot, reconciliation, dashboard, authentication, or menu behavior.",
"No merge, Issue close, or release operation."
]
}
@@ -0,0 +1,60 @@
{
"schema": "gitea-agent.evidence-plan/v1",
"owner": "jsfamily",
"repository": "jarca-ledger-mcp",
"issue": 12,
"pull_request": 13,
"review_id": 61,
"reviewed_head": "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8",
"implementation_parent": "b8b8811a2a50371795a02e4fa65f097c9d9508ac",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"manifest_generator": {
"path": "/opt/data/gitea-agent/.run/i12-r61/run_final_evidence.py",
"execution": "python3 /opt/data/gitea-agent/.run/i12-r61/run_final_evidence.py"
},
"commands": [
{
"id": "conversion-regression",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-q", "-rs", "tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation", "tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_without_valuations_remains_allowed"]
},
{
"id": "review61-regressions",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-q", "-rs", "tests/test_account_valuation_edge_contracts.py", "tests/test_issue_12_review59_account.py", "tests/test_valuation_contract_docs.py"]
},
{
"id": "full-suite",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-q", "-rs"]
},
{
"id": "ruff",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "ruff", "check", "."]
},
{
"id": "compileall",
"argv": ["python3", "-m", "compileall", "-q", "jarca_ledger_mcp", "dashboard", "tests", "scripts"]
},
{
"id": "diff-check",
"argv": ["git", "diff", "--check", "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8..<SOURCE_SHA>"]
}
],
"bound_paths": [
"jarca_ledger_mcp/application/account_valuation_service.py",
"jarca_ledger_mcp/application/accounts_write_services.py",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_issue_12_review59_account.py",
"tests/test_valuation_contract_docs.py",
"docs/data-model.md",
"docs/interface-contract.md",
"docs/requirements/req-003_account-valuation-history.md",
"docs/verification/issue-12-review-61/red-evidence.json",
"docs/verification/issue-12-review-61/finding-matrix.json",
"docs/verification/issue-12-review-61/contract-preservation-inventory.json",
"docs/verification/issue-12-review-61/evidence-plan.json"
],
"redaction": "Synthetic fixtures only; no credentials, real account identifiers, financial documents, or environment secrets.",
"limitations": [
"External private PDF fixtures may be skipped when unavailable.",
"Gitea commit status is readiness metadata, not functional acceptance evidence."
]
}
@@ -0,0 +1,55 @@
{
"schema": "gitea-agent.finding-matrix/v1",
"owner": "jsfamily",
"repository": "jarca-ledger-mcp",
"issue": 12,
"pull_request": 13,
"review_id": 61,
"reviewed_head": "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8",
"implementation_parent": "b8b8811a2a50371795a02e4fa65f097c9d9508ac",
"findings": [
{
"id": "N01-loan-balance-definition",
"classification": "implementation_defect",
"status": "resolved_at_implementation_parent",
"root_cause": "Internal candidate writers could persist loan balance evidence without an explicit representative liability definition, and unsupported loan purposes could bypass the definition rule.",
"tests": [
"test_direct_loan_candidate_requires_liability_definition_and_writes_nothing",
"test_import_loan_candidate_requires_liability_definition_and_writes_nothing",
"test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations",
"test_loan_cash_balance_and_total_value_are_rejected_without_writes",
"test_loan_import_preserves_transactions_and_current_cache_without_valuation_candidate"
],
"changed_paths": [
"jarca_ledger_mcp/application/account_valuation_service.py",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_issue_12_review59_account.py",
"docs/data-model.md",
"docs/interface-contract.md",
"docs/requirements/req-003_account-valuation-history.md"
]
},
{
"id": "N01-account-type-conversion-bypass",
"classification": "implementation_defect",
"status": "resolved",
"root_cause": "update_ledger_account allowed a non-loan account with definition-less historical valuations to become a loan account without validating the persisted history.",
"named_regression": "test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"positive_regression": "test_bank_to_loan_conversion_without_valuations_remains_allowed",
"red_evidence": "docs/verification/issue-12-review-61/red-evidence.json",
"minimal_fix": "Reject non-loan to loan conversion before mutation when any attached valuation lacks one of the two explicit valid representative liability definitions.",
"changed_paths": [
"jarca_ledger_mcp/application/accounts_write_services.py",
"tests/test_account_valuation_edge_contracts.py",
"docs/data-model.md",
"docs/interface-contract.md",
"docs/requirements/req-003_account-valuation-history.md"
],
"risks": [
"Do not infer or backfill a liability definition.",
"Do not reject conversion when no valuation history exists.",
"Do not partially mutate account or valuation history on rejection."
]
}
]
}
@@ -0,0 +1,9 @@
{
"gate": "independent-bank-to-loan-conversion",
"source_sha": "b8b8811a2a50371795a02e4fa65f097c9d9508ac",
"test": "tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"command": "uv run --frozen --extra dev --extra dashboard pytest tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation -q",
"exit_code": 1,
"result": "RED",
"output": "F [100%]\n=================================== FAILURES ===================================\n_ test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation _\n\n assert rejected[\"success\"] is False\nE assert True is False\n=========================== short test summary info ============================\nFAILED tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation"
}
@@ -62,6 +62,16 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields:
except Exception as e: return fail("validation_error", str(e))
updates.append(f"{k}=?"); params.append(v)
if not updates: return fail("validation_error", "no fields to update")
target_type = fields.get("type", current["type"])
if str(current["type"]).lower() != "loan" and target_type == "loan":
invalid_valuation = conn.execute(
"SELECT 1 FROM account_valuations "
"WHERE account_id=? AND (representative_liability_definition IS NULL "
"OR representative_liability_definition NOT IN (?, ?)) LIMIT 1",
(account_id, "principal_only", "principal_interest_fees"),
).fetchone()
if invalid_valuation is not None:
return fail("validation_error", "loan account requires a valid representative liability definition for every valuation")
stored_date = str(current["last_balance_basis_date"] or "")[:10]
incoming_date = normalized_basis if basis_update else ""
historical = bool(balance_update and incoming_date and stored_date and incoming_date < stored_date)
@@ -5,6 +5,39 @@ from jarca_ledger_mcp.application.account_valuation_service import record_accoun
from jarca_ledger_mcp.application.imports_service import _record_import_valuation_candidate
def test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account(
"은행", type="bank", last_balance=100, last_synced_at="2026-06-30",
last_balance_basis_date="2026-06-30", db_path=path,
)
account_id = created["account"]["id"]
before = service.list_account_valuations(account_id, db_path=path)
assert before["count"] == 1
assert before["valuations"][0]["representative_liability_definition"] is None
rejected = service.update_ledger_account(account_id, type="loan", db_path=path)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
accounts = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"]
assert next(account for account in accounts if account["id"] == account_id)["type"] == "bank"
after = service.list_account_valuations(account_id, db_path=path)
assert after["count"] == 1
assert after["valuations"] == before["valuations"]
def test_bank_to_loan_conversion_without_valuations_remains_allowed(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
converted = service.update_ledger_account(account_id, type="loan", db_path=path)
assert converted["success"] is True
assert converted["account"]["type"] == "loan"
assert service.list_account_valuations(account_id, db_path=path)["count"] == 0
def test_loan_balance_requires_representative_liability_definition(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
bank = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]