diff --git a/dashboard/page_views/accounts.py b/dashboard/page_views/accounts.py index c628829..481181d 100644 --- a/dashboard/page_views/accounts.py +++ b/dashboard/page_views/accounts.py @@ -30,6 +30,12 @@ ACCOUNT_TYPE_STYLES = { "other": "#57606a", } + +def _account_balance_basis_date(account: dict[str, Any]) -> str: + """Return only an explicit evidence date, never a sync timestamp.""" + return str(account.get("last_balance_basis_date") or "")[:10] + + def render_accounts_list_header(items: list[dict[str, Any]]) -> None: st.markdown( f'{len(items)}개 계좌', @@ -113,14 +119,19 @@ def account_edit_dialog(account: dict[str, Any], db_path: str) -> None: format_func=lambda value: ACCOUNT_TYPE_LABELS[value], ) - balance_columns = st.columns(2) + balance_columns = st.columns(3) last_balance_raw = balance_columns[0].text_input( "기준 잔액", value="" if account.get("last_balance") is None else str(account.get("last_balance")), help="원 단위 정수", ) - last_synced_at = balance_columns[1].text_input( - "기준 시각", + last_balance_basis_date = balance_columns[1].text_input( + "잔액 기준일", + value=account.get("last_balance_basis_date") or "", + help="실제 증빙 기준일(YYYY-MM-DD). 미상인 경우 비워 둡니다.", + ) + last_synced_at = balance_columns[2].text_input( + "동기화 시각", value=account.get("last_synced_at") or "", help="예: 2026-08-20 또는 2026-08-20T23:59:00+09:00", ) @@ -156,6 +167,7 @@ def account_edit_dialog(account: dict[str, Any], db_path: str) -> None: institution=institution, type=account_type, last_balance=last_balance, + last_balance_basis_date=last_balance_basis_date, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket, @@ -184,7 +196,7 @@ def page_accounts(db_path: str) -> None: st.dataframe(valuations, width="stretch", hide_index=True) # The list is historical insertion order; a rejected/corrected # tail is not necessarily the effective current valuation. - basis = (selected_account.get("last_synced_at") or "")[:10] + basis = _account_balance_basis_date(selected_account) selected = accounts_gateway.select_account_valuation(selected_id, basis, as_of=True, db_path=db_path) if basis else {} selected_valuation = selected.get("valuation") if selected.get("success") else None latest = selected_valuation or next( @@ -195,7 +207,7 @@ def page_accounts(db_path: str) -> None: st.warning("평가 이력이 없습니다.") with st.expander("계좌 평가 등록"): with st.form(f"account_valuation_register_{selected_id}"): - v_date = st.text_input("평가 기준일", value=selected_account.get("last_synced_at", "")[:10]) + v_date = st.text_input("평가 기준일", value=_account_balance_basis_date(selected_account)) v_amount = st.number_input("평가 금액", value=int(selected_account.get("last_balance") or 0), step=1) default_kind = "loan_balance" if selected_account.get("type") == "loan" else ("total_value" if selected_account.get("type") == "investment" else "cash_balance") v_kind = st.selectbox("평가 종류", ["cash_balance", "balance", "total_value", "loan_balance", "loan"], index=["cash_balance", "balance", "total_value", "loan_balance", "loan"].index(default_kind)) diff --git a/docs/data-model.md b/docs/data-model.md index 593830f..e293fe8 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -24,6 +24,7 @@ | `type` | TEXT | 필수 | `bank` | bank/card/cash/investment/loan/other | enum | 아니오 | | `currency` | TEXT | 필수 | `KRW` | supported currency | ISO code | 아니오 | | `last_balance` | INTEGER | 선택 | NULL | exact integer | currency unit | 아니오 | +| `last_balance_basis_date` | TEXT | 선택 | `''` | YYYY-MM-DD or unknown | date of the evidence represented by `last_balance`; empty/unknown is explicit | 아니오 | | `last_synced_at` | TEXT | 선택 | `''` | ISO date/time | timestamp | 아니오 | | `statement_status` | TEXT | 필수 | `unknown` | existing status enum | 없음 | 아니오 | | `liquidity_bucket` | TEXT | 필수 | `other` | cash/deposit/investment/real_estate/other/loan | enum | 아니오 | @@ -119,6 +120,14 @@ 기존 필드와 관계를 보존한다. import는 거래를 기록할 수 있지만 balance valuation candidate는 dated `unverified` row만 추가한다. reconciliation은 calculated balance provenance와 `VALUATION_MANAGED` 상태를 보존하며 investment valuation 때문에 transaction을 생성하지 않는다. `schema_meta.schema_version`은 12다. +### 현재 계좌 잔액 캐시와 기준일 + +- `last_balance` is the current cache value: it is the latest current balance observed by an import or account write, not a historical valuation and not opening evidence. +- `last_balance_basis_date` is the basis field for the evidence represented by that cache. It is a date when known; legacy or date-less writes store unknown/NULL and must not be inferred from `last_synced_at` or a valuation row. +- `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; they may also append a dated `account_valuations` candidate, but 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. + ## 관계와 불변조건 ### 관계 @@ -146,7 +155,7 @@ - 저장 위치: resolved SQLite path from `jarca_ledger_mcp.infrastructure.sqlite.connection` - 직렬화 형식: SQLite tables; evidence arrays JSON UTF-8 - 스키마 버전: schema 11 → schema 12 additive -- 기존 데이터 처리: `migrate_db()` creates missing tables/columns/indexes; no guessed migration or deletion +- 기존 데이터 처리: `migrate_db()` creates missing tables/columns/indexes; this is an additive migration with no guessed migration or deletion. legacy rows with unknown basis remain unknown; no inference is permitted. - 하위 호환성: existing ledger/auth/menu/MCP contracts and legacy rows remain readable - 롤백 조건: pre-migration backup restore boundary; do not down-migrate or delete valuation history in application code diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 770d531..86c8d29 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -277,7 +277,7 @@ AppTest는 callback, query mapping, synthetic renderer output과 error/loading e | 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | |---|---|---|---|---|---|---|---| | `account_id`/`basis_date`/`amount` | function/MCP | `string/string/int` | register 필수 | 없음 | account id, YYYY-MM-DD, exact integer | valuation identity/value | 아니오 | -| `currency`/`valuation_kind`/`source_type`/`verification_status` | function/MCP | `string` | 선택 | KRW/balance/manual/unverified | actual enums in source | 아니오 | +| `currency`/`valuation_kind`/`source_type`/`verification_status` | function/MCP | `string` | 선택 | KRW/balance/manual/unverified | canonical enums listed below | 아니오 | | `idempotency_key` | function/MCP | `string` | 선택 | `''` | stable caller key | retry identity | 아니오 | | `as_of_at`/`timezone`, optional metrics, loan definition | function/MCP | nullable string/int/float | 선택 | NULL | source validation; loan definition required | evidence metrics | 아니오 | @@ -293,7 +293,7 @@ AppTest는 callback, query mapping, synthetic renderer output과 error/loading e | 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | |---|---|---|---|---|---| -| `validation_error` | request | invalid date/type/currency/loan definition | 수정 후 가능 | payload 수정 | 없음 | +| `validation_error` | request | invalid date/type/currency/loan definition | 수정 후 가능 | payload 수정 | no write; no row or cache mutation | | `not_found` | lookup | account/valuation missing | 수정 후 가능 | id 확인 | 없음 | | `idempotency_conflict` | conflict | same key different payload | 아니오 | new key or inspect | 없음 | | `correction_conflict`/`ambiguous_valuation` | conflict | second child/multiple leaves | 수동 검토 | correct evidence | 없음 | @@ -333,6 +333,16 @@ AppTest는 callback, query mapping, synthetic renderer output과 error/loading e | `idempotency_conflict` | conflict | close key payload differs | 아니오 | inspect run | 없음 | | `validation_error` | request | invalid policy or unreasoned correction | 수정 후 가능 | provide reason | no partial rows | +#### Canonical valuation values and validation + +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. 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 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. + +Account current-cache writes keep `last_balance_basis_date` (optional `YYYY-MM-DD`) separate from `last_synced_at` (execution/sync timestamp). `create_ledger_account` keeps the established positional `db_path` argument and adds `last_balance_basis_date` after it as an optional keyword. `update_ledger_account` accepts the same field through its existing keyword-field boundary. A balance with no explicit basis advances only the current display cache, leaves the basis unknown, and creates no historical valuation candidate; an explicit balance+basis pair may advance the cache and append an unverified candidate. Older explicit evidence is retained as history and does not roll back a newer dated cache. + ### shared contract and adapter limits All new interfaces retain the existing `success`/`error_code` envelope. `preview_asset_snapshot_close()` and Dashboard render/report are read-only; only explicit register/update/close forms or MCP calls write. MCP tools are `register_account_valuation`, `list_account_valuations`, `select_account_valuation`, `correct_account_valuation`, `preview_asset_snapshot_close`, `close_asset_snapshot`, `get_asset_snapshot_report`, and `get_net_worth_timeseries`; actual decorators are in `jarca_ledger_mcp/adapters/mcp/server.py`. Dashboard uses `accounts_gateway.py`, `assets_gateway.py`, and `page_views/accounts.py`/`assets.py`. diff --git a/docs/verification/issue-12-review-60/contract-preservation-inventory.json b/docs/verification/issue-12-review-60/contract-preservation-inventory.json new file mode 100644 index 0000000..40acbe3 --- /dev/null +++ b/docs/verification/issue-12-review-60/contract-preservation-inventory.json @@ -0,0 +1,486 @@ +{ + "baseline_revision": "5813dd484196239ab1fac4ba066c5b4922fb33e6", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "items": [ + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003.01", + "semantic_key": "additive_schema_migration_and_legacy_preservation", + "meaning": "Schema 11 tables, fields, rows, and transactions remain readable; schema 12 adds valuation and snapshot structures and migration is idempotent.", + "reviewed_ids": [ + "REQ-003.01", + "REQ-003.01-AC01" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "N01" + ], + "planned_status": "strengthen_verification_only; semantics_unchanged", + "authorization": "Preserve additive migration, old-row retention, and repeatability; rework may make evidence stricter but may not delete, infer-migrate, or rename legacy data.", + "evidence_paths": [ + "jarca_ledger_mcp/infrastructure/sqlite/migrations.py", + "docs/data-model.md", + "tests/test_account_valuations.py::test_account_valuation_schema_is_v12_and_idempotent", + "tests/test_issue_12_regressions.py::test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent" + ], + "verification": "Reviewed head contains schema-12 documentation and named tests; REQ-001/REQ-002 requirement files are unchanged from baseline." + }, + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003.02", + "semantic_key": "verified_leaf_exact_as_of_selection", + "meaning": "Selection is scoped by account, currency, and valuation kind; exact selects same-date verified leaf, as_of selects latest prior verified leaf, and future/unverified/rejected/unknown evidence is excluded or warned.", + "reviewed_ids": [ + "REQ-003.02", + "REQ-003.02-AC01" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "C05" + ], + "planned_status": "strengthen_authority_and_warning_contract; semantics_unchanged", + "authorization": "Only verified leaves remain authoritative; rework may expose more explicit warnings, exclusions, age, and scope but cannot broaden authority.", + "evidence_paths": [ + "jarca_ledger_mcp/application/account_valuation_service.py", + "jarca_ledger_mcp/application/assets_service.py", + "docs/interface-contract.md", + "tests/test_account_valuations.py::test_select_exact_and_as_of_excludes_unverified_and_reports_carry_forward", + "tests/test_issue_12_regressions.py::test_preview_warns_about_unverified_candidate_when_verified_value_is_selected" + ], + "verification": "Selection terminology, scope, and exclusion rules are documented and backed by focused tests." + }, + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003.03", + "semantic_key": "idempotency_and_append_only_correction", + "meaning": "Equal payloads reuse existing rows or runs; changed payloads conflict; corrections append one reasoned child without mutating the parent; invalid parent scope, cycles, self-parent, duplicate child, and source/key conflicts are no-write failures.", + "reviewed_ids": [ + "REQ-003.03", + "REQ-003.03-AC01" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "C06", + "D03" + ], + "planned_status": "strengthen_conflict_and_replay_evidence; semantics_unchanged", + "authorization": "Preserve immutable history, one-child cardinality, request-fingerprint equality, and no-write conflict behavior.", + "evidence_paths": [ + "jarca_ledger_mcp/application/account_valuation_service.py", + "jarca_ledger_mcp/application/assets_service.py", + "docs/data-model.md", + "tests/test_account_valuations.py::test_register_same_idempotency_payload_reuses_row_and_changed_payload_conflicts", + "tests/test_account_valuations.py::test_correction_is_append_only_reasoned_and_single_child", + "tests/test_issue_12_review59_account.py::test_direct_correction_requires_matching_existing_parent_and_reason", + "tests/test_issue_12_review59_account.py::test_correction_rejects_self_parent_and_cycles_without_write", + "tests/test_issue_12_review59_reports.py::test_idempotent_snapshot_close_replay_returns_original_immutable_response" + ], + "verification": "Current source and tests cover valuation correction, evidence identity, close replay, and no-write conflicts." + }, + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003.04", + "semantic_key": "integer_amounts_nullable_optional_metrics_and_representative_liability", + "meaning": "Amounts and optional monetary fields use exact integers; NULL means not provided and zero means explicit zero; investment metrics are not double-counted; only the defined representative loan amount enters liabilities.", + "reviewed_ids": [ + "REQ-003.04", + "REQ-003.04-AC01" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "N02" + ], + "planned_status": "strengthen_field_mapping_and_formula_assertions; semantics_unchanged", + "authorization": "Preserve physical principal fields, NULL/zero distinction, loan definition requirement, and no double sum.", + "evidence_paths": [ + "docs/data-model.md", + "docs/interface-contract.md", + "jarca_ledger_mcp/application/account_valuation_service.py", + "jarca_ledger_mcp/application/assets_service.py", + "tests/test_issue_12_review59_account.py::test_valuation_round_trip_preserves_null_zero_and_physical_amount_mapping", + "tests/test_issue_12_regressions.py::test_loan_representative_is_always_aggregated_as_liability", + "tests/test_issue_12_regressions.py::test_loan_balance_fallback_without_representative_definition_is_excluded" + ], + "verification": "The reviewed contract explicitly distinguishes NULL, zero, principal meanings, and representative liability." + }, + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003.05", + "semantic_key": "current_display_and_historical_authority_separation", + "meaning": "Historical dated evidence does not roll back newer current balance display; account/import writes create only dated unverified candidates when real evidence exists; name-only updates create none.", + "reviewed_ids": [ + "REQ-003.05", + "REQ-003.05-AC01" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "N01", + "C05" + ], + "planned_status": "strengthen_current_cache_and_candidate_provenance; semantics_unchanged", + "authorization": "Preserve separate current and historical decisions, unverified candidate status, and no inferred evidence.", + "evidence_paths": [ + "jarca_ledger_mcp/application/accounts_write_services.py", + "jarca_ledger_mcp/application/imports_service.py", + "docs/runtime-flow.md", + "tests/test_account_valuations.py::test_account_balance_write_records_unverified_candidate_only_with_real_basis_date", + "tests/test_account_valuations.py::test_account_name_only_update_does_not_create_candidate", + "tests/test_issue_12_regressions.py::test_historical_account_update_preserves_current_and_adds_one_candidate", + "tests/test_issue_12_review59_account.py::test_import_historical_kb_does_not_replace_newer_current_balance" + ], + "verification": "The reviewed head has explicit late-history/current-cache regression tests and runtime decision guards." + }, + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003.06", + "semantic_key": "read_only_preview_and_explicit_conditional_close", + "meaning": "Preview performs no database write and reports complete, incomplete, or conditional state; unsafe evidence requires explicit approval metadata; close persists immutable run evidence atomically.", + "reviewed_ids": [ + "REQ-003.06", + "REQ-003.06-AC01" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "D03", + "D04" + ], + "planned_status": "strengthen_preview_fingerprint_and_approval_boundary; semantics_unchanged", + "authorization": "Preserve no-write preview, explicit approval, rejection of unsafe evidence, and atomic close.", + "evidence_paths": [ + "jarca_ledger_mcp/application/assets_service.py", + "jarca_ledger_mcp/adapters/streamlit/assets_gateway.py", + "docs/interface-contract.md", + "tests/test_issue_12_regressions.py::test_preview_marks_approval_eligible_missing_valuation_as_incomplete_draft", + "tests/test_issue_12_review59_reports.py::test_dated_manual_asset_is_verified_and_preview_needs_approval", + "tests/test_issue_12_review59_reports.py::test_approved_carried_close_is_conditional_and_persists_provenance", + "tests/test_issue_12_review59_reports.py::test_close_rejects_authority_drift_since_preview" + ], + "verification": "Preview/close boundaries, approval fields, drift rejection, and no-partial-write behavior are documented and tested." + }, + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003.07", + "semantic_key": "immutable_close_correction_and_net_worth_formula", + "meaning": "Same-basis re-close requires a reason and creates a new run and rows; old runs remain; latest run is report authority; net worth is asset buckets minus representative loan and excludes missing valuations.", + "reviewed_ids": [ + "REQ-003.07", + "REQ-003.07-AC01" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "C06", + "D03" + ], + "planned_status": "strengthen_latest-run_and_formula_provenance; semantics_unchanged", + "authorization": "Preserve append-only runs, correction linkage, latest-run authority, and exact formula.", + "evidence_paths": [ + "jarca_ledger_mcp/application/assets_service.py", + "docs/data-model.md", + "tests/test_issue_12_regressions.py::test_empty_conditional_close_is_closed_and_second_close_requires_correction", + "tests/test_issue_12_regressions.py::test_legacy_snapshot_requires_explicit_correction_before_new_close", + "tests/test_issue_12_review59_reports.py::test_asset_close_and_report_expose_provenance_aliases" + ], + "verification": "Run correction fields, latest authority, and formula semantics are present in canonical docs and tests." + }, + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003.08", + "semantic_key": "reconciliation_and_monthly_read_model_propagation", + "meaning": "Verified investment uses VALUATION_MANAGED without fake transactions; incomplete evidence reports unavailable/incomplete calculation; monthly and downstream reports propagate actual valuation date, age, carry, verification, run, approval, correction, warnings, and exclusions.", + "reviewed_ids": [ + "REQ-003.08", + "REQ-003.08-AC01" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "C05", + "D04" + ], + "planned_status": "strengthen_cross_read_model_alias_and_completeness_contract; semantics_unchanged", + "authorization": "Preserve read-only propagation, explicit incompleteness, no inferred balance, and monthly_source_snapshot_id as a read-model alias only.", + "evidence_paths": [ + "jarca_ledger_mcp/application/reconciliation_service.py", + "jarca_ledger_mcp/application/monthly_settlement_service.py", + "jarca_ledger_mcp/adapters/streamlit/overview_gateway.py", + "docs/data-model.md", + "tests/test_issue_12_regressions.py::test_reconciliation_uses_corrected_investment_leaf_without_transaction_count_change", + "tests/test_issue_12_review59_reports.py::test_investment_without_verified_valuation_and_without_opening_balance_is_incomplete", + "tests/test_issue_12_review59_reports.py::test_timeseries_and_monthly_expose_canonical_asset_provenance", + "tests/test_issue_12_review59_reports.py::test_monthly_settlement_propagates_snapshot_provenance_id_and_actual_valuation_date" + ], + "verification": "Named source owners and report propagation tests cover reconciliation and monthly semantics." + }, + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003.09", + "semantic_key": "shared_application_adapters_and_auth_boundary", + "meaning": "MCP and Dashboard delegate to shared services, retain success/error envelopes, require Dashboard authentication before DB access, and keep render/read paths non-mutating.", + "reviewed_ids": [ + "REQ-003.09", + "REQ-003.09-AC01" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "N01", + "D04" + ], + "planned_status": "strengthen_adapter_and_auth-order assertions; semantics_unchanged", + "authorization": "Preserve shared policy ownership, thin adapters, auth-before-DB, and existing MCP authentication scope.", + "evidence_paths": [ + "jarca_ledger_mcp/adapters/mcp/server.py", + "jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py", + "jarca_ledger_mcp/adapters/streamlit/overview_gateway.py", + "dashboard/app.py", + "tests/test_contract_regression.py", + "tests/test_dashboard_app.py", + "tests/test_dashboard_menu_transition.py" + ], + "verification": "The reviewed head retains named adapter paths and existing REQ-001/REQ-002 gates; requirement files are unchanged from baseline." + }, + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003.10", + "semantic_key": "synthetic_privacy_and_excluded_scope", + "meaning": "Docs, tests, fixtures, and evidence contain only synthetic/redacted data; credentials, tokens, cookies, real financial data, external prices, FX, automatic approval, inferred transactions, and redesign remain out of scope.", + "reviewed_ids": [ + "REQ-003.10", + "REQ-003.10-AC01" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "N02", + "D04" + ], + "planned_status": "strengthen_recursive_redaction_and_static_inventory; semantics_unchanged", + "authorization": "Preserve privacy boundary and excluded scope; rework may add redaction checks but may not introduce real data or new financial inference.", + "evidence_paths": [ + "docs/project-structure.md", + "docs/interface-contract.md", + "tests/test_requirement_docs.py", + "tests/test_issue_12_review59_reports.py::test_close_metadata_is_allowlisted_recursively", + "tests/test_issue_12_review59_reports.py::test_public_included_items_redact_source_note_and_memo_recursively" + ], + "verification": "Canonical docs state synthetic-only and recursive redaction; focused tests validate metadata and evidence redaction." + }, + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003-INV01", + "semantic_key": "authority_and_safety_invariants", + "meaning": "Only verified leaves are authority; future/unverified/rejected/unknown evidence is not summed; preview/render do not write; close is transactional; current and historical authority remain separate.", + "reviewed_ids": [ + "REQ-003-INV01" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "C05", + "D03", + "D04" + ], + "planned_status": "strengthen explicit invariant checks; semantics_unchanged", + "authorization": "All listed invariants are preservation constraints, not permission to alter business meaning.", + "evidence_paths": [ + "docs/requirements/req-003_account-valuation-history.md", + "docs/data-model.md", + "docs/runtime-flow.md", + "tests/test_issue_12_regressions.py", + "tests/test_issue_12_review59_reports.py" + ], + "verification": "The requirement, data model, and runtime flow repeat the same authority and no-write invariants." + }, + { + "path": "docs/requirements/req-003_account-valuation-history.md", + "baseline_id": "REQ-003-INV02", + "semantic_key": "database_read_lifecycle_boundary", + "meaning": "Writer/bootstrap explicitly calls init_db; read_db uses SQLite mode=ro; missing DB returns db_not_initialized without mkdir/migrate/commit; initialized read failure returns db_error.", + "reviewed_ids": [ + "REQ-003-INV02" + ], + "reviewed_status": "present_and_approved", + "planned_ids": [ + "N01" + ], + "planned_status": "strengthen_no_create_and_error_classification; semantics_unchanged", + "authorization": "Preserve explicit initialization and stable read-only failure envelopes.", + "evidence_paths": [ + "jarca_ledger_mcp/infrastructure/sqlite/connection.py", + "jarca_ledger_mcp/infrastructure/sqlite/migrations.py", + "docs/interface-contract.md", + "docs/runtime-flow.md", + "tests/test_issue_12_review59_readonly.py::test_read_queries_missing_nested_db_return_stable_error_without_creating_path", + "tests/test_issue_12_review59_readonly.py::test_read_schema_error_is_not_misreported_as_uninitialized" + ], + "verification": "C13 database-boundary documentation and focused read-only tests exist at reviewed head." + }, + { + "path": "docs/requirements/req-001_dashboard-administrator-authentication.md", + "baseline_id": "REQ-001.01-REQ-001.11", + "semantic_key": "dashboard_auth_gate_cookie_cleanup_logout_and_rotation", + "meaning": "REQ-001 authentication remains fail-closed before DB/page dispatch, with server-only credential binding, hardened persistent cookie, absolute TTL, invalid-cookie cleanup, explicit logout, rotation invalidation, and no DB-backed session.", + "reviewed_ids": [ + "REQ-001.01", + "REQ-001.05", + "REQ-001.06", + "REQ-001.07", + "REQ-001.08", + "REQ-001.09", + "REQ-001.10" + ], + "reviewed_status": "unchanged_from_baseline", + "planned_ids": [ + "N01" + ], + "planned_status": "must_remain_unchanged", + "authorization": "Issue #12 rework is additive and cannot modify authentication semantics or claim MCP authentication.", + "evidence_paths": [ + "docs/requirements/req-001_dashboard-administrator-authentication.md", + "docs/interface-contract.md", + "dashboard/authentication.py", + "dashboard/auth_server.py", + "tests/test_dashboard_authentication.py", + "tests/test_dashboard_persistent_authentication.py", + "tests/test_dashboard_auth_server.py", + "tests/test_dashboard_authentication_app.py" + ], + "verification": "Git diff confirms REQ-001 file has no changes between baseline and reviewed head." + }, + { + "path": "docs/requirements/req-002_dashboard-menu-transition.md", + "baseline_id": "REQ-002.01-REQ-002.09", + "semantic_key": "dashboard_menu_identity_query_and_stable_shell", + "meaning": "The exact ten menu labels/order, selected state, page-only query update with sibling preservation, stable shell/background, local loading/error/retry, latest serialized selection, auth gate, finance pages, and clear_cache contract remain unchanged.", + "reviewed_ids": [ + "REQ-002.01", + "REQ-002.02", + "REQ-002.03", + "REQ-002.04", + "REQ-002.05", + "REQ-002.06", + "REQ-002.07", + "REQ-002.08", + "REQ-002.09" + ], + "reviewed_status": "unchanged_from_baseline", + "planned_ids": [ + "N01" + ], + "planned_status": "must_remain_unchanged", + "authorization": "Issue #12 rework may add valuation content behind existing pages but may not alter menu identity, query semantics, shell continuity, or browser evidence requirements.", + "evidence_paths": [ + "docs/requirements/req-002_dashboard-menu-transition.md", + "docs/interface-contract.md", + "dashboard/app.py", + "dashboard/common.py", + "tests/test_dashboard_menu_transition.py", + "tests/test_dashboard_app.py", + "tests/test_dashboard_authentication_app.py", + "docs/evidence/issue-9-dashboard-menu-transition/" + ], + "verification": "Git diff confirms REQ-002 file has no changes between baseline and reviewed head." + }, + { + "path": "docs/interface-contract.md", + "baseline_id": "COMMON-IFACE-01", + "semantic_key": "success_error_envelope_and_public_service_signatures", + "meaning": "Existing success/error envelopes remain; valuation, snapshot, MCP, Dashboard gateway, and service signatures are additive and re-exported through the established service boundary.", + "reviewed_ids": [ + "COMMON-IFACE-01" + ], + "reviewed_status": "preserved_with_additive_REQ003_contract", + "planned_ids": [ + "D04" + ], + "planned_status": "strengthen_exact_output_and_replay_contract; semantics_unchanged", + "authorization": "No existing response key may be renamed or removed.", + "evidence_paths": [ + "docs/interface-contract.md", + "jarca_ledger_mcp/service.py", + "jarca_ledger_mcp/adapters/mcp/server.py", + "tests/test_contract_regression.py" + ], + "verification": "Reviewed contract explicitly lists signatures, envelopes, top-level aliases, nested provenance, and replay rules." + }, + { + "path": "docs/data-model.md", + "baseline_id": "COMMON-DATA-01", + "semantic_key": "legacy_entities_and_storage_ownership", + "meaning": "Existing ledger entities and relationships remain; valuation and snapshot history are append-only; monthly alias is not a persistence field; application services own policy and SQLite owns storage.", + "reviewed_ids": [ + "COMMON-DATA-01" + ], + "reviewed_status": "preserved_with_additive_REQ003_model", + "planned_ids": [ + "N02", + "D03" + ], + "planned_status": "strengthen_physical_field_and_ownership_checks; semantics_unchanged", + "authorization": "No guessed migration, cascade deletion, fake transaction, or alternate policy boundary is authorized.", + "evidence_paths": [ + "docs/data-model.md", + "jarca_ledger_mcp/infrastructure/sqlite/schema.py", + "jarca_ledger_mcp/application/assets_service.py", + "jarca_ledger_mcp/application/reconciliation_service.py" + ], + "verification": "Data model names legacy preservation, append-only relationships, physical fields, and canonical owners." + }, + { + "path": "docs/runtime-flow.md", + "baseline_id": "COMMON-RUNTIME-01", + "semantic_key": "auth_before_db_and_explicit_write_boundaries", + "meaning": "Dashboard auth precedes DB access; read/preview paths are read-only; first-use writer initialization is explicit; approval precedes atomic close; downstream reports are reads.", + "reviewed_ids": [ + "COMMON-RUNTIME-01" + ], + "reviewed_status": "preserved_with_additive_REQ003_flow", + "planned_ids": [ + "N01", + "D03", + "D04" + ], + "planned_status": "strengthen_transition_guards_and_drift_revalidation; semantics_unchanged", + "authorization": "Preserve ordering, transaction boundaries, stable missing-DB behavior, and no implicit writes.", + "evidence_paths": [ + "docs/runtime-flow.md", + "dashboard/app.py", + "jarca_ledger_mcp/infrastructure/sqlite/connection.py", + "jarca_ledger_mcp/application/assets_service.py", + "tests/test_issue_12_review59_readonly.py" + ], + "verification": "Runtime flow explicitly records auth-before-DB, read-only database lifecycle, preview drift revalidation, and atomic close." + }, + { + "path": "docs/project-structure.md", + "baseline_id": "COMMON-STRUCTURE-01", + "semantic_key": "canonical_module_ownership_and_regression_map", + "meaning": "Valuation/snapshot policy remains in application services; adapters remain thin; Dashboard pages do not own SQL or policy; named tests and documentation validators are required evidence.", + "reviewed_ids": [ + "COMMON-STRUCTURE-01" + ], + "reviewed_status": "preserved_with_REQ003_ownership_map", + "planned_ids": [ + "N01", + "C06" + ], + "planned_status": "strengthen ownership and named-test traceability; semantics_unchanged", + "authorization": "Rework must preserve canonical owners and may not introduce duplicate business logic in adapters.", + "evidence_paths": [ + "docs/project-structure.md", + "jarca_ledger_mcp/application/account_valuation_service.py", + "jarca_ledger_mcp/application/assets_service.py", + "jarca_ledger_mcp/application/monthly_settlement_service.py", + "jarca_ledger_mcp/infrastructure/sqlite/connection.py", + "tests/test_requirement_docs.py" + ], + "verification": "Reviewed structure document contains explicit ownership, dependency, preservation, and named regression-test sections." + } + ], + "missing_count": 0, + "ambiguous_count": 0, + "notes": [ + "REQ-001 and REQ-002 requirement files are byte-for-byte unchanged from baseline; common docs were extended additively for REQ-003.", + "Source contract digest supplied by the caller is 6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818.", + "The checked-out repository does not define a canonical glossary mapping finding labels N01, C05, C06, N02, D03, and D04 to independent issue descriptions; planned mappings above are therefore preservation-oriented semantic associations inferred from the reviewed contract text.", + "No files were created or modified; no commit, push, fetch, or Gitea call was performed.", + "Main-agent resolution: Review #60 body is the authoritative glossary for N01/C05/C06/N02/D03/D04; each label and prose was read from admission-snapshot.json, so the delegate-reported label-mapping ambiguity is resolved without changing contract meaning." + ], + "schema": "gitea-agent.contract-preservation-inventory/v1" +} diff --git a/docs/verification/issue-12-review-60/evidence-plan.json b/docs/verification/issue-12-review-60/evidence-plan.json new file mode 100644 index 0000000..bbb49b2 --- /dev/null +++ b/docs/verification/issue-12-review-60/evidence-plan.json @@ -0,0 +1,181 @@ +{ + "environment": [ + "os", + "kernel", + "architecture", + "python", + "uv" + ], + "command_env_allowlist": [ + "PATH", + "HOME", + "TMPDIR", + "LANG", + "VIRTUAL_ENV", + "UV_CACHE_DIR" + ], + "secret_env_names": [], + "forbidden_regexes": [], + "hash_paths": [ + "dashboard/page_views/accounts.py", + "docs/data-model.md", + "docs/interface-contract.md", + "jarca_ledger_mcp/application/account_valuation_service.py", + "jarca_ledger_mcp/application/accounts_write_services.py", + "jarca_ledger_mcp/application/assets_service.py", + "jarca_ledger_mcp/application/imports_service.py", + "jarca_ledger_mcp/infrastructure/sqlite/schema.py", + "scripts/ci_fixture_db.py", + "tests/test_account_valuations.py", + "tests/test_issue_12_regressions.py", + "tests/test_issue_12_review59_account.py", + "tests/test_account_valuation_edge_contracts.py", + "tests/test_asset_snapshot_idempotency.py", + "tests/test_import_current_balance_authority.py", + "tests/test_valuation_contract_docs.py", + "jarca_ledger_mcp/adapters/mcp/server.py", + "tests/test_account_basis_public_consumers.py" + ], + "commands": [ + { + "id": "review60-regressions", + "purpose": "Run Review 60 and all independent-review named RED-to-GREEN regressions", + "argv": [ + "uv", + "run", + "--frozen", + "--extra", + "dev", + "--extra", + "dashboard", + "pytest", + "-q", + "tests/test_account_valuation_edge_contracts.py", + "tests/test_import_current_balance_authority.py", + "tests/test_asset_snapshot_idempotency.py", + "tests/test_valuation_contract_docs.py", + "tests/test_account_basis_public_consumers.py", + "tests/test_issue_12_regressions.py" + ], + "expected_exit_code": 0, + "max_output_bytes": 65536, + "claims": [ + "review60-and-independent-regressions-green" + ] + }, + { + "id": "adversarial-compatibility", + "purpose": "Run correction, replay, current authority, read-only, provenance, contract and documentation compatibility coverage", + "argv": [ + "uv", + "run", + "--frozen", + "--extra", + "dev", + "--extra", + "dashboard", + "pytest", + "-q", + "tests/test_account_valuation_edge_contracts.py", + "tests/test_import_current_balance_authority.py", + "tests/test_asset_snapshot_idempotency.py", + "tests/test_valuation_contract_docs.py", + "tests/test_issue_12_regressions.py", + "tests/test_account_valuations.py", + "tests/test_asset_snapshots.py", + "tests/test_issue_12_review59_account.py", + "tests/test_issue_12_review59_reports.py", + "tests/test_issue_12_review59_readonly.py", + "tests/test_contract_regression.py", + "tests/test_requirement_docs.py" + ], + "expected_exit_code": 0, + "max_output_bytes": 65536, + "claims": [ + "adversarial-compatibility-green" + ] + }, + { + "id": "full-suite", + "purpose": "Run the repository full test suite with declared optional dependencies", + "argv": [ + "uv", + "run", + "--frozen", + "--extra", + "dev", + "--extra", + "dashboard", + "pytest", + "-q", + "-rs" + ], + "expected_exit_code": 0, + "max_output_bytes": 65536, + "claims": [ + "full-suite-green" + ] + }, + { + "id": "ruff", + "purpose": "Run repository lint gate", + "argv": [ + "uv", + "run", + "--frozen", + "--extra", + "dev", + "--extra", + "dashboard", + "ruff", + "check", + "." + ], + "expected_exit_code": 0, + "max_output_bytes": 65536, + "claims": [ + "ruff-green" + ] + }, + { + "id": "compileall", + "purpose": "Compile Python sources, dashboard, tests and scripts", + "argv": [ + "python3", + "-m", + "compileall", + "-q", + "jarca_ledger_mcp", + "dashboard", + "tests", + "scripts" + ], + "expected_exit_code": 0, + "max_output_bytes": 65536, + "claims": [ + "compileall-green" + ] + }, + { + "id": "commit-diff-check", + "purpose": "Validate committed rework diff whitespace", + "argv": [ + "git", + "diff", + "--check", + "HEAD^", + "HEAD" + ], + "expected_exit_code": 0, + "max_output_bytes": 65536, + "claims": [ + "diff-check-green" + ] + } + ], + "limitations": [ + "Four external PDF fixture tests are expected to skip when private sample files/passwords are unavailable.", + "No actual financial data or production credential is used; tests use synthetic fixtures.", + "Review 60 does not require a new real-browser observation; existing synthetic Streamlit/AppTest regressions remain in the full suite." + ] +} diff --git a/docs/verification/issue-12-review-60/final-independent-review.json b/docs/verification/issue-12-review-60/final-independent-review.json new file mode 100644 index 0000000..83e76bc --- /dev/null +++ b/docs/verification/issue-12-review-60/final-independent-review.json @@ -0,0 +1,16 @@ +{ + "specification_gate": { + "passed": true, + "findings": [], + "summary": "Exact-current-diff specification gate passed. Review60 semantics N01, C05, C06, N02, D03, D04; prior defects; documentation, adapters, dashboard consumers, fixtures, and named regressions were rechecked. Full pytest suite, Ruff, compileall, and git diff --check passed." + }, + "code_security_gate": { + "passed": true, + "security_concerns": [], + "logic_errors": [], + "suggestions": [], + "summary": "Final current-diff code/security gate passed. Full pytest suite passed with four fixture-dependent skips and one existing Starlette deprecation warning; Ruff, compileall, diff-check, migration/idempotency/consumer tests, and added-line security scan passed." + }, + "reviewed_parent_sha": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "delegation_id": "deleg_49abf596" +} diff --git a/docs/verification/issue-12-review-60/finding-matrix.json b/docs/verification/issue-12-review-60/finding-matrix.json new file mode 100644 index 0000000..3127be2 --- /dev/null +++ b/docs/verification/issue-12-review-60/finding-matrix.json @@ -0,0 +1,148 @@ +{ + "schema": "gitea-agent.finding-matrix/v1", + "review_id": 60, + "review_author": "gitea-spec-agent", + "review_state": "REQUEST_CHANGES", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818", + "findings": [ + { + "finding": "60/N01-loan-balance-definition", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "contract": "Issue #12 확정 결정 8; REQ-003.04/05", + "classification": "valid_fix", + "implementation_paths": [ + "jarca_ledger_mcp/application/account_valuation_service.py" + ], + "test_paths": [ + "tests/test_account_valuation_edge_contracts.py" + ], + "verification": "named RED→GREEN test plus consolidated adversarial suite", + "result": "pending", + "reason": "Loan account balance alias bypasses mandatory representative liability definition." + }, + { + "finding": "60/C05-current-import", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "contract": "Issue #12 현재값과 과거값; REQ-003.05", + "classification": "valid_fix", + "implementation_paths": [ + "jarca_ledger_mcp/application/imports_service.py", + "jarca_ledger_mcp/application/accounts_write_services.py", + "jarca_ledger_mcp/infrastructure/sqlite/schema.py", + "jarca_ledger_mcp/infrastructure/sqlite/migrations.py" + ], + "test_paths": [ + "tests/test_import_current_balance_authority.py" + ], + "verification": "named RED→GREEN sequential late-import test plus migration/full suite", + "result": "pending", + "reason": "Import compares actual evidence date against synchronization timestamp and loses current-cache basis authority." + }, + { + "finding": "60/C06-idempotency", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "contract": "Issue #12 중복 및 정정; REQ-003.03", + "classification": "valid_fix", + "implementation_paths": [ + "jarca_ledger_mcp/application/account_valuation_service.py" + ], + "test_paths": [ + "tests/test_account_valuation_edge_contracts.py" + ], + "verification": "named RED→GREEN same-evidence correction replay and changed-payload conflict tests", + "result": "pending", + "reason": "A same source_ref/parent/effective payload correction retry without explicit key conflicts instead of reusing the child." + }, + { + "finding": "60/N02-close-replay-run-binding", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "contract": "Issue #12 마감 불변성; REQ-003.03/07", + "classification": "valid_fix", + "implementation_paths": [ + "jarca_ledger_mcp/application/assets_service.py" + ], + "test_paths": [ + "tests/test_asset_snapshot_idempotency.py" + ], + "verification": "named RED→GREEN old-key replay-after-correction exact response binding test", + "result": "pending", + "reason": "Replay identifies the old run but loads latest-run rows and metadata." + }, + { + "finding": "60/D03-data-model", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "contract": "Issue #12 데이터 모델 산출물; REQ-003.05", + "classification": "evidence_gap", + "implementation_paths": [ + "docs/data-model.md" + ], + "test_paths": [ + "tests/test_valuation_contract_docs.py" + ], + "verification": "named RED→GREEN canonical data-model contract assertion", + "result": "pending", + "reason": "Data model does not fix actual cache basis versus sync timestamp authority and no-rollback owner." + }, + { + "finding": "60/D04-interface-contract", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "contract": "Issue #12 인터페이스 계약 산출물; REQ-003.04/09", + "classification": "evidence_gap", + "implementation_paths": [ + "docs/interface-contract.md" + ], + "test_paths": [ + "tests/test_valuation_contract_docs.py" + ], + "verification": "named RED→GREEN exact enum/conditional/output mapping assertion", + "result": "pending", + "reason": "Public interface delegates allowed values and loan definition/output mapping to source." + }, + { + "finding": "independent-spec/C05-account-write-no-inference", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "contract": "Issue #12 current/history separation and no inferred basis; REQ-003.05", + "classification": "valid_fix", + "implementation_paths": [ + "jarca_ledger_mcp/application/accounts_write_services.py" + ], + "test_paths": [ + "tests/test_import_current_balance_authority.py" + ], + "verification": "named RED→GREEN explicit-basis and no-candidate account-write regression", + "result": "pending", + "reason": "Account writes inferred evidence basis and historical candidate from sync timestamp." + }, + { + "finding": "independent-code/legacy-unknown-basis-reporting", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "contract": "Issue #12 unknown-basis non-retroactivity; REQ-003.05", + "classification": "valid_fix", + "implementation_paths": [ + "jarca_ledger_mcp/application/assets_service.py" + ], + "test_paths": [ + "tests/test_import_current_balance_authority.py" + ], + "verification": "named RED→GREEN legacy unknown-basis report regression", + "result": "pending", + "reason": "Live report exposed last_synced_at as valuation_date despite unknown evidence basis." + }, + { + "finding": "independent-code/zero-row-run-replay", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "contract": "Issue #12 immutable close evidence; REQ-003.03/07", + "classification": "valid_fix", + "implementation_paths": [ + "jarca_ledger_mcp/application/assets_service.py" + ], + "test_paths": [ + "tests/test_asset_snapshot_idempotency.py" + ], + "verification": "named RED→GREEN empty-run replay after nonempty correction exact equality", + "result": "pending", + "reason": "Empty persisted applied_evidence triggered fallback to latest correction rows." + } + ] +} diff --git a/docs/verification/issue-12-review-60/red-evidence.json b/docs/verification/issue-12-review-60/red-evidence.json new file mode 100644 index 0000000..ee646ac --- /dev/null +++ b/docs/verification/issue-12-review-60/red-evidence.json @@ -0,0 +1,82 @@ +{ + "schema": "gitea-agent.red-regression-evidence/v1", + "reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8", + "environment": { + "runner": "uv run --frozen --extra dev --extra dashboard pytest", + "fixture_policy": "synthetic only" + }, + "results": [ + { + "finding": "60/N01-loan-balance-definition", + "test": "tests/test_account_valuation_edge_contracts.py::test_loan_balance_requires_representative_liability_definition", + "exit_code": 1, + "failure": "missing_definition success was True; expected validation_error and no persisted valuation" + }, + { + "finding": "60/C06-idempotency", + "test": "tests/test_account_valuation_edge_contracts.py::test_correction_replays_by_evidence_and_conflicts_on_changed_payload", + "exit_code": 1, + "failure": "identical correction replay success was False; expected reused=True with the original child" + }, + { + "finding": "60/C05-current-import", + "test": "tests/test_import_current_balance_authority.py::test_late_import_current_balance_is_authoritative_by_evidence_basis_date", + "exit_code": 1, + "failure": "current last_balance was 700 after newer-basis late import; expected 900" + }, + { + "finding": "60/N02-close-replay-run-binding", + "test": "tests/test_asset_snapshot_idempotency.py::test_exact_run_replay_after_correction_reconstructs_original_persisted_run", + "exit_code": 1, + "failure": "old-key replay returned correction-run row IDs, approval and provenance instead of exact original response" + }, + { + "finding": "60/D03-data-model", + "test": "tests/test_valuation_contract_docs.py::test_data_model_documents_cache_authority_and_legacy_basis_migration", + "exit_code": 1, + "failure": "canonical last_balance current-cache contract marker was absent" + }, + { + "finding": "60/D04-interface-contract", + "test": "tests/test_valuation_contract_docs.py::test_interface_contract_documents_public_valuation_enums_validation_and_mapping", + "exit_code": 1, + "failure": "interface contract still delegated allowed values to 'actual enums in source'" + }, + { + "finding": "independent-spec/C05-account-write-no-inference", + "test": "tests/test_import_current_balance_authority.py::test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate", + "exit_code": 1, + "failure": "account creation inferred last_balance_basis_date=2026-07-01 from last_synced_at; expected unknown basis and no candidate" + }, + { + "finding": "independent-code/legacy-unknown-basis-reporting", + "test": "tests/test_import_current_balance_authority.py::test_unknown_legacy_balance_basis_is_not_reported_as_valuation_date", + "exit_code": 1, + "failure": "excluded valuation_date exposed last_synced_at timestamp; expected empty unknown basis" + }, + { + "finding": "independent-code/zero-row-run-replay", + "test": "tests/test_asset_snapshot_idempotency.py::test_empty_run_replay_after_nonempty_correction_preserves_original_empty_payload", + "exit_code": 1, + "failure": "old empty run replay fell back to later run items and returned total_assets/net_worth=100 instead of exact original empty response" + }, + { + "finding": "independent-code/historical-explicit-basis-no-rollback", + "test": "tests/test_issue_12_regressions.py::test_historical_account_update_preserves_current_and_adds_one_candidate", + "exit_code": 1, + "failure": "historical explicit update replaced the newer current cache instead of retaining current state while adding one historical candidate" + }, + { + "finding": "independent-code/dashboard-basis-consumer", + "test": "tests/test_account_basis_public_consumers.py::test_dashboard_valuation_basis_never_uses_sync_timestamp", + "exit_code": 1, + "failure": "Dashboard page_accounts used last_synced_at for valuation selection and registration defaults" + }, + { + "finding": "independent-code/mcp-basis-forwarding", + "test": "tests/test_account_basis_public_consumers.py::test_mcp_account_write_tools_forward_explicit_balance_basis", + "exit_code": 1, + "failure": "MCP account-write tools rejected/dropped last_balance_basis_date" + } + ] +} diff --git a/jarca_ledger_mcp/adapters/mcp/server.py b/jarca_ledger_mcp/adapters/mcp/server.py index 5c73524..20028ab 100644 --- a/jarca_ledger_mcp/adapters/mcp/server.py +++ b/jarca_ledger_mcp/adapters/mcp/server.py @@ -102,9 +102,9 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_ return contracts.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity) @mcp.tool() -def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other") -> dict: +def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", last_balance_basis_date: str = "") -> dict: """Create an account/card/cash/investment/loan ledger account.""" - return contracts.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket) + return contracts.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket, last_balance_basis_date=last_balance_basis_date) @mcp.tool() def list_ledger_accounts(active_only: bool = True) -> dict: @@ -112,9 +112,9 @@ def list_ledger_accounts(active_only: bool = True) -> dict: return contracts.list_ledger_accounts(active_only) @mcp.tool() -def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None) -> dict: +def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None, last_balance_basis_date: str | None = None) -> dict: """Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation.""" - return contracts.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket) + return contracts.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket, last_balance_basis_date=last_balance_basis_date) @mcp.tool() def register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None) -> dict: diff --git a/jarca_ledger_mcp/application/account_valuation_service.py b/jarca_ledger_mcp/application/account_valuation_service.py index 2b19d19..acc3bcd 100644 --- a/jarca_ledger_mcp/application/account_valuation_service.py +++ b/jarca_ledger_mcp/application/account_valuation_service.py @@ -185,9 +185,13 @@ def register_account_valuation( return fail("not_found", "account not found") if currency_norm != (account["currency"] or "KRW").upper(): return fail("validation_error", "valuation currency must match account currency") - if (valuation_kind or "balance").strip().lower() in {"loan", "loan_balance"} and str(account["type"]).lower() != "loan": + effective_kind = (valuation_kind or "balance").strip().lower() + is_loan_account = str(account["type"]).lower() == "loan" + if effective_kind in {"loan", "loan_balance"} and not is_loan_account: return fail("validation_error", "loan valuation kind requires a loan account") - if definition is not None and str(account["type"]).lower() != "loan": + if is_loan_account and effective_kind in {"balance", "loan", "loan_balance"} and definition is None: + return fail("validation_error", "loan valuation requires representative_liability_definition") + if definition is not None and not is_loan_account: return fail("validation_error", "representative liability definition requires a loan account") seen: set[str] = set() if supersedes_id: @@ -314,16 +318,46 @@ def correct_account_valuation(valuation_id: str, amount: int, correction_reason: preserved = {name: fields[name] if name in fields else source[name] for name in ( "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "memo", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition")} + try: + effective = { + "amount": _strict_int(amount, "amount", allow_none=False), + "account_id": source["account_id"], + "basis_date": source["basis_date"], + "currency": (preserved["currency"] or "KRW").strip().upper(), + "valuation_kind": (preserved["valuation_kind"] or "balance").strip().lower(), + "source_type": (preserved["source_type"] or "manual").strip().lower() or "manual", + "source_ref": preserved["source_ref"].strip() if isinstance(preserved["source_ref"], str) else "", + "verification_status": (preserved["verification_status"] or "unverified").strip().lower(), + "memo": preserved["memo"] or "", + "as_of_at": preserved["as_of_at"], + "timezone": preserved["timezone"], + "cost_basis": _strict_int(preserved["cost_basis"], "cost_basis"), + "net_contributed": _strict_int(preserved["net_contributed"], "net_contributed"), + "reported_principal_kind": preserved["reported_principal_kind"], + "reported_principal_label": preserved["reported_principal_label"], + "profit": _strict_int(preserved["profit"], "profit"), + "return_rate": _strict_float(preserved["return_rate"], "return_rate"), + "return_basis": preserved["return_basis"], + "principal": _strict_int(preserved["principal"], "principal"), + "interest": _strict_int(preserved["interest"], "interest"), + "fee": _strict_int(preserved["fee"], "fee"), + "representative_liability_definition": preserved["representative_liability_definition"].strip() if preserved["representative_liability_definition"] else None, + "supersedes_id": valuation_id, + "correction_reason": correction_reason.strip(), + } + except (TypeError, ValueError, AttributeError) as exc: + return fail("validation_error", str(exc)) if idempotency_key: prior = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (source["account_id"], idempotency_key)).fetchone() if prior: - expected = {"amount": amount, "supersedes_id": valuation_id, "correction_reason": correction_reason.strip()} - expected.update(preserved) - if all(prior[k] == v for k, v in expected.items() if k in prior.keys()): + if all(prior[key] == value for key, value in effective.items()): return ok(valuation=_valuation(prior), reused=True) return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=_valuation(prior)) - if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (valuation_id,)).fetchone(): - return fail("correction_conflict", "valuation already has a correction") + child = conn.execute("SELECT * FROM account_valuations WHERE supersedes_id=?", (valuation_id,)).fetchone() + if child is not None: + if all(child[key] == value for key, value in effective.items()): + return ok(valuation=_valuation(child), reused=True) + return fail("correction_conflict", "correction payload conflicts with existing correction", valuation=_valuation(child)) return register_account_valuation( source["account_id"], source["basis_date"], amount, **preserved, diff --git a/jarca_ledger_mcp/application/accounts_write_services.py b/jarca_ledger_mcp/application/accounts_write_services.py index f4ec7bb..f923fb0 100644 --- a/jarca_ledger_mcp/application/accounts_write_services.py +++ b/jarca_ledger_mcp/application/accounts_write_services.py @@ -1,20 +1,20 @@ from __future__ import annotations -from .shared import Any, VALID_ACCOUNT_TYPES, db, fail, new_id, normalize_liquidity_bucket, normalize_statement_status, now_iso, ok, row_to_dict +from .shared import Any, VALID_ACCOUNT_TYPES, db, fail, new_id, normalize_date, normalize_liquidity_bucket, normalize_statement_status, now_iso, ok, row_to_dict -def _record_balance_candidate(conn: Any, account: dict[str, Any], balance: Any = None, synced: str | None = None) -> None: +def _record_balance_candidate(conn: Any, account: dict[str, Any], balance: Any = None, basis_date: str | None = None) -> None: balance = account.get("last_balance") if balance is None else balance - synced = account.get("last_synced_at") if synced is None else synced - if balance is None or not str(synced or ""): + basis_date = account.get("last_balance_basis_date") if basis_date is None else basis_date + if balance is None or not str(basis_date or ""): return try: from .account_valuation_service import record_account_valuation_candidate - record_account_valuation_candidate(conn, account["id"], str(synced), int(balance), account.get("currency", "KRW"), "account_write", account.get("updated_at", ""), f"account-balance:{account['id']}:{str(synced)[:10]}:{balance}", "account balance write candidate") + record_account_valuation_candidate(conn, account["id"], str(basis_date), int(balance), account.get("currency", "KRW"), "account_write", account.get("updated_at", ""), f"account-balance:{account['id']}:{str(basis_date)}:{balance}", "account balance write candidate") except (ValueError, TypeError): return -def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None) -> dict[str, Any]: +def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None, last_balance_basis_date: str = "") -> dict[str, Any]: if not name.strip(): return fail("validation_error", "account name is required") if type not in VALID_ACCOUNT_TYPES: return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") try: @@ -22,8 +22,11 @@ def create_ledger_account(name: str, type: str = "bank", institution: str = "", liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket) except Exception as e: return fail("validation_error", str(e)) ts = now_iso(); account_id = new_id("acct") + try: + basis_date = normalize_date(last_balance_basis_date, default_today=False) if last_balance_basis_date else "" + except Exception as e: return fail("validation_error", str(e)) with db(db_path) as conn: - conn.execute("INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (account_id,name.strip(),type,institution,currency.upper(),int(active),memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status_norm,liquidity_bucket_norm,ts,ts)) + conn.execute("INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_balance_basis_date,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (account_id,name.strip(),type,institution,currency.upper(),int(active),memo,account_number_masked,holder_name,last_balance,basis_date,last_synced_at,statement_status_norm,liquidity_bucket_norm,ts,ts)) row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() account = row_to_dict(row) _record_balance_candidate(conn, account) @@ -31,10 +34,20 @@ def create_ledger_account(name: str, type: str = "bank", institution: str = "", def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]: - allowed = {"name","type","institution","currency","active","memo","account_number_masked","holder_name","last_balance","last_synced_at","statement_status","liquidity_bucket"} + allowed = {"name","type","institution","currency","active","memo","account_number_masked","holder_name","last_balance","last_balance_basis_date","last_synced_at","statement_status","liquidity_bucket"} with db(db_path) as conn: current = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() 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 + 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: + return fail("validation_error", "last_balance_basis_date requires last_balance") + normalized_basis = None + if basis_update: + try: + normalized_basis = normalize_date(fields["last_balance_basis_date"], default_today=False) if fields["last_balance_basis_date"] else "" + except Exception as e: + return fail("validation_error", str(e)) updates=[]; params=[] for k,v in fields.items(): if k not in allowed or v is None: continue @@ -45,21 +58,25 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields: if k == "active": v = int(bool(v)) if k == "last_balance" and v != "": v = int(v) if k == "currency": v = str(v).upper() + if k == "last_balance_basis_date": v = normalized_basis 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") - incoming_date = str(fields.get("last_synced_at") or current["last_synced_at"] or "")[:10] - stored_date = str(current["last_synced_at"] or "")[:10] - historical = bool(incoming_date and stored_date and incoming_date < stored_date) + 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) candidate_balance = fields.get("last_balance", current["last_balance"]) - candidate_date = fields.get("last_synced_at", current["last_synced_at"]) if historical: - kept=[(u,p) for u,p in zip(updates,params) if not u.startswith(("last_balance=","last_synced_at="))] + kept=[(u,p) for u,p in zip(updates,params) if not u.startswith(("last_balance=","last_balance_basis_date=","last_synced_at="))] updates=[u for u,_ in kept]; params=[p for _,p in kept] + elif balance_update and not basis_update: + # An explicit current-balance write without dated evidence advances + # the display cache but keeps its historical basis unknown. + updates.append("last_balance_basis_date=?"); params.append("") updates.append("updated_at=?"); params.extend([now_iso(), account_id]) conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params) row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() account = row_to_dict(row) - if any(k in fields for k in ("last_balance", "last_synced_at")): - _record_balance_candidate(conn, account, candidate_balance, candidate_date) + if balance_update and basis_update: + _record_balance_candidate(conn, account, candidate_balance, incoming_date) return ok(account=account) diff --git a/jarca_ledger_mcp/application/assets_service.py b/jarca_ledger_mcp/application/assets_service.py index 5fb1a4f..eaabf1e 100644 --- a/jarca_ledger_mcp/application/assets_service.py +++ b/jarca_ledger_mcp/application/assets_service.py @@ -137,7 +137,7 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel "type": account.get("type", ""), "currency": (account.get("currency") or "KRW").upper(), "liquidity_bucket": bucket, - "valuation_date": account.get("last_synced_at", ""), + "valuation_date": account.get("last_balance_basis_date", ""), "last_synced_at": account.get("last_synced_at", ""), "source_note": account.get("memo", ""), "memo": account.get("memo", ""), @@ -259,14 +259,45 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = return included, excluded +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.""" + run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE id=?", (run_id,)).fetchone() + if not run: + return [], {} + basis_date = run["basis_date"] + rows = conn.execute( + "SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? AND snapshot_run_id=? ORDER BY created_at ASC, name ASC", + (basis_date, run["id"]), + ).fetchall() + total_rows = conn.execute( + "SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,) + ).fetchone()[0] + metadata = { + "snapshot_run_id": run["id"], + "is_correction": bool(run["correction_of_run_id"]), + "correction_of_run_id": run["correction_of_run_id"] or "", + "correction_reason": run["correction_reason"] or "", + "close_status": run["close_status"], + "selection_policy": run["selection_policy"], + "approved_by": run["approved_by"], + "approved_at": run["approved_at"], + "approval_reason": run["approval_reason"], + "approval_reference": run["approval_reference"], + "applied_evidence": json.loads(run["applied_evidence"] or "[]"), + "excluded_evidence": json.loads(run["excluded_evidence"] or "[]"), + "carried_evidence": json.loads(run["carried_evidence"] or "[]"), + "closed_at": run["created_at"] or "", + "total_snapshot_rows_for_basis_date": total_rows, + } + return rows, metadata + + def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]: rows = conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis_date,)).fetchall() run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (basis_date,)).fetchone() if not run: return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)} if rows else {} - selected = [row for row in rows if row["snapshot_run_id"] == run["id"]] - metadata = {"snapshot_run_id": run["id"], "is_correction": bool(run["correction_of_run_id"]), "correction_of_run_id": run["correction_of_run_id"] or "", "correction_reason": run["correction_reason"] or "", "close_status": run["close_status"], "selection_policy": run["selection_policy"], "approved_by": run["approved_by"], "approved_at": run["approved_at"], "approval_reason": run["approval_reason"], "approval_reference": run["approval_reference"], "applied_evidence": json.loads(run["applied_evidence"] or "[]"), "excluded_evidence": json.loads(run["excluded_evidence"] or "[]"), "carried_evidence": json.loads(run["carried_evidence"] or "[]"), "closed_at": run["created_at"] or "", "total_snapshot_rows_for_basis_date": len(rows)} - return selected, metadata + return _snapshot_rows_for_run(conn, run["id"]) def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]: @@ -629,8 +660,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone() if existing_run: if existing_run["request_fingerprint"] == fingerprint: - replay_rows, replay_meta = _snapshot_rows_for_latest_run(conn, basis) - replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])] or _snapshot_items(conn, basis) + replay_rows, replay_meta = _snapshot_rows_for_run(conn, existing_run["id"]) + replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])] replay_warnings = _closed_warnings(replay_meta, basis, replay_rows) replay_public = _closed_run_metadata(replay_meta) replay_public["warnings"] = replay_warnings diff --git a/jarca_ledger_mcp/application/imports_service.py b/jarca_ledger_mcp/application/imports_service.py index a3eae85..e4ff70e 100644 --- a/jarca_ledger_mcp/application/imports_service.py +++ b/jarca_ledger_mcp/application/imports_service.py @@ -43,19 +43,10 @@ def _record_import_valuation_candidate(conn: Any, account_id: str, balance: Any, def _update_current_balance_if_newer(conn: Any, account_id: str, balance: Any, basis_date: str, synced_at: str) -> None: if balance is None or not basis_date: return - current = conn.execute("SELECT last_synced_at FROM accounts WHERE id=?", (account_id,)).fetchone() - latest_evidence = conn.execute( - """SELECT basis_date FROM account_valuations - WHERE account_id=? AND verification_status IN ('verified', 'unverified') - ORDER BY basis_date DESC, recorded_at DESC, id DESC LIMIT 1""", - (account_id,), - ).fetchone() - current_date = max( - str(current["last_synced_at"] or "")[:10] if current else "", - str(latest_evidence["basis_date"] or "")[:10] if latest_evidence else "", - ) + current = conn.execute("SELECT last_balance_basis_date FROM accounts WHERE id=?", (account_id,)).fetchone() + current_date = str(current["last_balance_basis_date"] or "")[:10] if current else "" if not current_date or basis_date[:10] >= current_date: - conn.execute("UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (balance, synced_at, synced_at, account_id)) + conn.execute("UPDATE accounts SET last_balance=?, last_balance_basis_date=?, last_synced_at=?, updated_at=? WHERE id=?", (balance, basis_date[:10], synced_at, synced_at, account_id)) @read_operation def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path: str | None = None) -> dict[str, Any]: diff --git a/jarca_ledger_mcp/infrastructure/sqlite/schema.py b/jarca_ledger_mcp/infrastructure/sqlite/schema.py index 583bb4c..aad592c 100644 --- a/jarca_ledger_mcp/infrastructure/sqlite/schema.py +++ b/jarca_ledger_mcp/infrastructure/sqlite/schema.py @@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS accounts ( account_number_masked TEXT NOT NULL DEFAULT '', holder_name TEXT NOT NULL DEFAULT '', last_balance INTEGER, + last_balance_basis_date TEXT NOT NULL DEFAULT '', last_synced_at TEXT NOT NULL DEFAULT '', statement_status TEXT NOT NULL DEFAULT 'unknown', liquidity_bucket TEXT NOT NULL DEFAULT 'other', @@ -272,6 +273,7 @@ ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = { "account_number_masked": "TEXT NOT NULL DEFAULT ''", "holder_name": "TEXT NOT NULL DEFAULT ''", "last_balance": "INTEGER", + "last_balance_basis_date": "TEXT NOT NULL DEFAULT ''", "last_synced_at": "TEXT NOT NULL DEFAULT ''", "statement_status": "TEXT NOT NULL DEFAULT 'unknown'", "liquidity_bucket": "TEXT NOT NULL DEFAULT 'other'", diff --git a/scripts/ci_fixture_db.py b/scripts/ci_fixture_db.py index 87455cc..37bc533 100755 --- a/scripts/ci_fixture_db.py +++ b/scripts/ci_fixture_db.py @@ -42,6 +42,7 @@ def create_ci_fixture_db(db_path: str | Path) -> dict[str, Any]: holder_name="CI Synthetic Holder", last_balance=1_250_000, last_synced_at="2026-06-30T23:59:00+09:00", + last_balance_basis_date="2026-06-30", statement_status="partial_import", liquidity_bucket="cash", memo="ci synthetic fixture account", @@ -64,6 +65,7 @@ def create_ci_fixture_db(db_path: str | Path) -> dict[str, Any]: ]: result = service.create_ledger_account( last_synced_at="2026-06-30T23:59:00+09:00", + last_balance_basis_date="2026-06-30" if account_spec.get("last_balance") is not None else "", memo="ci synthetic fixture account", db_path=str(path), **account_spec, diff --git a/tests/test_account_basis_public_consumers.py b/tests/test_account_basis_public_consumers.py new file mode 100644 index 0000000..8433866 --- /dev/null +++ b/tests/test_account_basis_public_consumers.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import inspect +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from dashboard.page_views import accounts as accounts_page +from jarca_ledger_mcp.adapters.mcp import server + + +def test_dashboard_valuation_basis_never_uses_sync_timestamp(): + account = { + "last_balance_basis_date": "2026-06-30", + "last_synced_at": "2026-07-09T23:59:00+09:00", + } + assert accounts_page._account_balance_basis_date(account) == "2026-06-30" + assert accounts_page._account_balance_basis_date({"last_synced_at": account["last_synced_at"]}) == "" + assert "last_synced_at" not in inspect.getsource(accounts_page.page_accounts) + + +def test_mcp_account_write_tools_forward_explicit_balance_basis(monkeypatch): + calls: list[tuple[str, tuple, dict]] = [] + + def fake_create(*args, **kwargs): + calls.append(("create", args, kwargs)) + return {"success": True} + + def fake_update(*args, **kwargs): + calls.append(("update", args, kwargs)) + return {"success": True} + + monkeypatch.setattr(server.contracts, "create_ledger_account", fake_create) + monkeypatch.setattr(server.contracts, "update_ledger_account", fake_update) + + assert server.create_ledger_account( + "dated", last_balance=100, last_synced_at="2026-07-09T00:00:00Z", + last_balance_basis_date="2026-06-30", + )["success"] + assert server.update_ledger_account( + "account-1", last_balance=200, last_synced_at="2026-07-10T00:00:00Z", + last_balance_basis_date="2026-07-01", + )["success"] + + assert calls[0][2]["last_balance_basis_date"] == "2026-06-30" + assert calls[1][2]["last_balance_basis_date"] == "2026-07-01" diff --git a/tests/test_account_valuation_edge_contracts.py b/tests/test_account_valuation_edge_contracts.py new file mode 100644 index 0000000..4b8e7e3 --- /dev/null +++ b/tests/test_account_valuation_edge_contracts.py @@ -0,0 +1,93 @@ +from jarca_ledger_mcp import service + + +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"] + loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"] + + missing_definition = service.register_account_valuation( + loan, + "2026-06-30", + 100, + valuation_kind="balance", + verification_status="verified", + db_path=path, + ) + assert missing_definition["success"] is False + assert missing_definition["error_code"] == "validation_error" + assert service.list_account_valuations(loan, db_path=path)["count"] == 0 + + ordinary_balance = service.register_account_valuation( + bank, + "2026-06-30", + 100, + valuation_kind="balance", + verification_status="verified", + db_path=path, + ) + defined_loan_balance = service.register_account_valuation( + loan, + "2026-06-30", + 100, + valuation_kind="balance", + representative_liability_definition="principal_only", + verification_status="verified", + db_path=path, + ) + assert ordinary_balance["success"] is True + assert defined_loan_balance["success"] is True + + +def test_correction_replays_by_evidence_and_conflicts_on_changed_payload(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account("계좌", db_path=path)["account"]["id"] + parent = service.register_account_valuation( + account, + "2026-06-30", + 100, + source_ref="statement-1", + verification_status="verified", + db_path=path, + )["valuation"] + + first = service.correct_account_valuation( + parent["id"], + 110, + "statement correction", + db_path=path, + ) + replay = service.correct_account_valuation( + parent["id"], + 110, + "statement correction", + db_path=path, + ) + changed_amount = service.correct_account_valuation( + parent["id"], + 111, + "statement correction", + db_path=path, + ) + changed_reason = service.correct_account_valuation( + parent["id"], + 110, + "different correction", + db_path=path, + ) + changed_source = service.correct_account_valuation( + parent["id"], + 110, + "statement correction", + source_ref="statement-2", + db_path=path, + ) + + assert first["success"] is True + assert replay["success"] is True + assert replay["reused"] is True + assert replay["valuation"]["id"] == first["valuation"]["id"] + for changed in (changed_amount, changed_reason, changed_source): + assert changed["success"] is False + assert changed["error_code"] == "correction_conflict" + assert service.list_account_valuations(account, db_path=path)["count"] == 2 diff --git a/tests/test_account_valuations.py b/tests/test_account_valuations.py index 4ba9b77..1e18454 100644 --- a/tests/test_account_valuations.py +++ b/tests/test_account_valuations.py @@ -61,7 +61,7 @@ def test_correction_is_append_only_reasoned_and_single_child(tmp_path): def test_account_balance_write_records_unverified_candidate_only_with_real_basis_date(tmp_path): path = str(tmp_path / "ledger.sqlite3") - created = service.create_ledger_account("동기화 계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path) + created = service.create_ledger_account("동기화 계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30") service.create_ledger_account("날짜 없는 계좌", last_balance=700, db_path=path) values = service.list_account_valuations(created["account"]["id"], db_path=path) assert values["count"] == 1 @@ -95,7 +95,7 @@ def test_correction_preserves_optional_fields_unless_overridden(tmp_path): def test_account_name_only_update_does_not_create_candidate(tmp_path): path = str(tmp_path / "ledger.sqlite3") - created = service.create_ledger_account("계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path) + created = service.create_ledger_account("계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30") account_id = created["account"]["id"] updated = service.update_ledger_account(account_id, name="새 이름", db_path=path) values = service.list_account_valuations(account_id, db_path=path) diff --git a/tests/test_asset_snapshot_idempotency.py b/tests/test_asset_snapshot_idempotency.py new file mode 100644 index 0000000..17be3e1 --- /dev/null +++ b/tests/test_asset_snapshot_idempotency.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import sqlite3 + +from jarca_ledger_mcp import service + + +def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path) + service.create_manual_asset("평가 대기", None, "2026-06-30", "other", db_path=db_path) + + first = service.close_asset_snapshot( + "2026-06-30", + source_note="synthetic initial close", + memo="synthetic initial memo", + idempotency_key="snapshot-close-a", + allow_missing_exclusions=True, + approved_by="reviewer-a", + approval_reason="synthetic initial approval", + approval_reference="approval-a", + db_path=db_path, + ) + assert first["success"] is True + assert first["close_status"] == "conditional" + assert first["is_correction"] is False + assert first["warnings"] + assert first["approval"]["approval_reference"] == "approval-a" + assert first["provenance"]["snapshot_run_id"] == first["snapshot_run_id"] + + corrected = service.close_asset_snapshot( + "2026-06-30", + source_note="synthetic correction close", + memo="synthetic correction memo", + correction_reason="synthetic correction", + idempotency_key="snapshot-close-b", + allow_missing_exclusions=True, + approved_by="reviewer-b", + approval_reason="synthetic correction approval", + approval_reference="approval-b", + db_path=db_path, + ) + assert corrected["success"] is True + assert corrected["is_correction"] is True + assert corrected["snapshot_run_id"] != first["snapshot_run_id"] + assert corrected["correction"]["correction_of_run_id"] == first["snapshot_run_id"] + assert corrected["approval"]["approval_reference"] == "approval-b" + assert corrected["snapshot_run_id"] not in first["inserted_snapshot_ids"] + + with sqlite3.connect(db_path) as conn: + before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] + + replay = service.close_asset_snapshot( + "2026-06-30", + source_note="synthetic initial close", + memo="synthetic initial memo", + idempotency_key="snapshot-close-a", + allow_missing_exclusions=True, + approved_by="reviewer-a", + approval_reason="synthetic initial approval", + approval_reference="approval-a", + db_path=db_path, + ) + + with sqlite3.connect(db_path) as conn: + after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] + + assert replay == first + assert replay["snapshot_run_id"] == first["snapshot_run_id"] + assert replay["inserted_snapshot_ids"] == first["inserted_snapshot_ids"] + assert replay["correction"] == first["correction"] + assert replay["approval"] == first["approval"] + assert replay["warnings"] == first["warnings"] + assert replay["provenance"] == first["provenance"] + assert replay["included_items"] == first["included_items"] + assert replay["excluded_items"] == first["excluded_items"] + assert replay["total_assets"] == first["total_assets"] + assert replay["total_liabilities"] == first["total_liabilities"] + assert replay["net_worth"] == first["net_worth"] + assert replay["snapshot_run_id"] != corrected["snapshot_run_id"] + assert corrected["snapshot_run_id"] not in replay["inserted_snapshot_ids"] + assert after_replay == before_replay + + +def test_empty_run_replay_after_nonempty_correction_preserves_original_empty_payload(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + service.create_ledger_account("누락 계좌", db_path=db_path) + first = service.close_asset_snapshot( + "2026-06-30", + idempotency_key="empty-close-a", + allow_missing_exclusions=True, + approved_by="reviewer-a", + approval_reason="approve empty", + approval_reference="approval-a", + db_path=db_path, + ) + assert first["success"] is True + assert first["inserted_count"] == 0 + assert first["included_items"] == [] + + service.create_manual_asset("후속 자산", 100, "2026-06-30", "cash", db_path=db_path) + corrected = service.close_asset_snapshot( + "2026-06-30", + correction_reason="add verified asset", + idempotency_key="nonempty-close-b", + allow_missing_exclusions=True, + approved_by="reviewer-b", + approval_reason="approve correction", + approval_reference="approval-b", + db_path=db_path, + ) + assert corrected["success"] is True + assert corrected["inserted_count"] == 1 + + # Restore the original live authority while retaining the later immutable + # correction run, so replay reaches the original-key reconstruction path. + with sqlite3.connect(db_path) as conn: + conn.execute("DELETE FROM manual_assets WHERE name=?", ("후속 자산",)) + + replay = service.close_asset_snapshot( + "2026-06-30", + idempotency_key="empty-close-a", + allow_missing_exclusions=True, + approved_by="reviewer-a", + approval_reason="approve empty", + approval_reference="approval-a", + db_path=db_path, + ) + assert replay == first + assert replay["inserted_count"] == 0 + assert replay["inserted_snapshot_ids"] == [] + assert replay["included_items"] == [] diff --git a/tests/test_import_current_balance_authority.py b/tests/test_import_current_balance_authority.py new file mode 100644 index 0000000..97672cb --- /dev/null +++ b/tests/test_import_current_balance_authority.py @@ -0,0 +1,105 @@ +from types import SimpleNamespace +import sqlite3 + +from jarca_ledger_mcp import service +from jarca_ledger_mcp.application import imports_service + + +def _parsed(transaction, source_hash): + return { + "transactions": [transaction], + "validation": {"ok": True}, + "source_hash": source_hash, + "metadata": {"final_balance": transaction.balance_after, "account_number_masked": ""}, + "file_path": "/tmp/synthetic.pdf", + "page_count": 1, + } + + +def _transaction(date, balance, row): + return SimpleNamespace( + transaction_date=date, + transaction_datetime=f"{date} 10:00:00", + amount=100, + counterparty="상대", + raw_summary="이체", + branch="", + remittance_memo="", + balance_after=balance, + type="income", + page=1, + action="입금", + external_id=lambda source_hash: f"kb:{source_hash}:{row}", + raw_payload=lambda: "{}", + ) + + +def test_late_import_current_balance_is_authoritative_by_evidence_basis_date(monkeypatch, tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account( + "KB", institution="KB국민은행", db_path=path, + )["account"]["id"] + imports = iter( + ( + (_transaction("2026-06-01", 700, 1), "hash-old"), + (_transaction("2026-06-30", 900, 2), "hash-new"), + ) + ) + monkeypatch.setattr(imports_service, "parse_kb_bank_pdf", lambda *_: _parsed(*next(imports))) + sync_times = iter(("2026-07-01T09:00:00", "2026-07-02T09:00:00")) + monkeypatch.setattr(imports_service, "now_iso", lambda: next(sync_times)) + + older = imports_service.import_kb_bank_pdf("older", "pw", account_id=account, db_path=path) + newer = imports_service.import_kb_bank_pdf("newer", "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 older["success"] and newer["success"] + assert current["last_balance"] == 900 + assert current["last_synced_at"] == "2026-07-02T09:00:00" + assert current["last_balance_basis_date"] == "2026-06-30" + assert {(item["basis_date"], item["amount"]) for item in valuations} >= { + ("2026-06-01", 700), + ("2026-06-30", 900), + } + + +def test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + unknown = service.create_ledger_account( + "기준일 미상", + last_balance=500, + last_synced_at="2026-07-01T09:00:00+09:00", + db_path=path, + ) + + assert unknown["success"] is True + assert unknown["account"]["last_balance_basis_date"] == "" + assert service.list_account_valuations(unknown["account"]["id"], db_path=path)["count"] == 0 + + dated = service.create_ledger_account( + "기준일 확인", + last_balance=600, + last_synced_at="2026-07-01T09:00:00+09:00", + db_path=path, + last_balance_basis_date="2026-06-30", + ) + valuations = service.list_account_valuations(dated["account"]["id"], db_path=path)["valuations"] + assert dated["account"]["last_balance_basis_date"] == "2026-06-30" + assert [(item["basis_date"], item["amount"]) for item in valuations] == [("2026-06-30", 600)] + + +def test_unknown_legacy_balance_basis_is_not_reported_as_valuation_date(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account("legacy", db_path=path)["account"]["id"] + with sqlite3.connect(path) as conn: + conn.execute( + "UPDATE accounts SET last_balance=?, last_synced_at=?, last_balance_basis_date='' WHERE id=?", + (500, "2026-07-01T09:00:00+09:00", account), + ) + + report = service.get_asset_snapshot_report("2026-06-30", db_path=path) + excluded = next(item for item in report["excluded_items"] if item["source_id"] == account) + assert excluded["valuation_date"] == "" + assert excluded["last_synced_at"] == "2026-07-01T09:00:00+09:00" diff --git a/tests/test_issue_12_regressions.py b/tests/test_issue_12_regressions.py index 7412e0a..83c76f6 100644 --- a/tests/test_issue_12_regressions.py +++ b/tests/test_issue_12_regressions.py @@ -75,9 +75,9 @@ def test_reconciliation_prefers_investment_total_value_over_balance(tmp_path): def test_historical_account_update_preserves_current_and_adds_one_candidate(tmp_path): path = str(tmp_path / "ledger.sqlite3") - created = service.create_ledger_account("계좌", last_balance=900, last_synced_at="2026-06-30", db_path=path) + created = service.create_ledger_account("계좌", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30") account = created["account"]["id"] - updated = service.update_ledger_account(account, last_balance=700, last_synced_at="2026-06-01", db_path=path) + updated = service.update_ledger_account(account, last_balance=700, last_synced_at="2026-06-01", last_balance_basis_date="2026-06-01", db_path=path) assert updated["account"]["last_balance"] == 900 and updated["account"]["last_synced_at"] == "2026-06-30" values = service.list_account_valuations(account, db_path=path)["valuations"] assert len(values) == 2 and sum(v["basis_date"] == "2026-06-01" for v in values) == 1 @@ -294,20 +294,23 @@ def test_loan_representative_is_always_aggregated_as_liability(tmp_path): assert report["included_items"][0]["liquidity_bucket"] == "loan" -def test_loan_balance_fallback_without_representative_definition_is_excluded(tmp_path): +def test_loan_balance_without_representative_definition_is_rejected_without_write(tmp_path): path = str(tmp_path / "ledger.sqlite3") loan = service.create_ledger_account( "대출", type="loan", last_balance=100, last_synced_at="2026-06-30", db_path=path, )["account"]["id"] - service.register_account_valuation( + rejected = service.register_account_valuation( loan, "2026-06-30", 100, valuation_kind="balance", verification_status="verified", db_path=path, ) + assert rejected["success"] is False + assert rejected["error_code"] == "validation_error" + assert service.list_account_valuations(loan, db_path=path)["count"] == 0 report = service.get_asset_snapshot_report("2026-06-30", db_path=path) assert report["total_liabilities"] == 0 assert report["net_worth"] == 0 - assert report["excluded_items"][0]["reason"] == "missing_representative_liability_definition" + assert report["excluded_items"][0]["reason"] == "missing_verified_exact_valuation" def test_warning_candidate_public_payload_is_minimal(): diff --git a/tests/test_issue_12_review59_account.py b/tests/test_issue_12_review59_account.py index 70db24f..a1610d3 100644 --- a/tests/test_issue_12_review59_account.py +++ b/tests/test_issue_12_review59_account.py @@ -96,7 +96,7 @@ def _parsed(tx, source, metadata): def test_import_historical_kb_does_not_replace_newer_current_balance(monkeypatch, tmp_path): path = str(tmp_path / "ledger.sqlite3") - account = service.create_ledger_account("KB", institution="KB국민은행", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"] + account = service.create_ledger_account("KB", institution="KB국민은행", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")["account"]["id"] monkeypatch.setattr(imports_service, "parse_kb_bank_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700, kind="kb"), "kb", {"final_balance": 700, "account_number_masked": ""})) result = imports_service.import_kb_bank_pdf("ignored", "pw", account_id=account, db_path=path) current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0] @@ -106,7 +106,7 @@ def test_import_historical_kb_does_not_replace_newer_current_balance(monkeypatch def test_import_historical_toss_bank_does_not_replace_newer_current_balance(monkeypatch, tmp_path): path = str(tmp_path / "ledger.sqlite3") - account = service.create_ledger_account("Toss", institution="토스뱅크", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"] + account = service.create_ledger_account("Toss", institution="토스뱅크", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")["account"]["id"] monkeypatch.setattr(imports_service, "parse_toss_bank_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700), "bank", {"account_number_masked": ""})) result = imports_service.import_toss_bank_pdf("ignored", account_id=account, db_path=path) current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0] @@ -116,7 +116,7 @@ def test_import_historical_toss_bank_does_not_replace_newer_current_balance(monk def test_import_historical_toss_securities_does_not_replace_newer_current_balance(monkeypatch, tmp_path): path = str(tmp_path / "ledger.sqlite3") - account = service.create_ledger_account("Securities", type="investment", institution="토스증권", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"] + account = service.create_ledger_account("Securities", type="investment", institution="토스증권", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")["account"]["id"] monkeypatch.setattr(imports_service, "parse_toss_securities_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700, kind="tosssec"), "sec", {"account_number_masked": ""})) result = imports_service.import_toss_securities_pdf("ignored", account_id=account, db_path=path) current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0] diff --git a/tests/test_valuation_contract_docs.py b/tests/test_valuation_contract_docs.py new file mode 100644 index 0000000..8b656ef --- /dev/null +++ b/tests/test_valuation_contract_docs.py @@ -0,0 +1,43 @@ +from pathlib import Path + + +ROOT = Path(__file__).parents[1] + + +def _text(relative_path: str) -> str: + return (ROOT / relative_path).read_text(encoding="utf-8") + + +def test_data_model_documents_cache_authority_and_legacy_basis_migration() -> None: + data_model = _text("docs/data-model.md") + + required_contract = ( + "`last_balance` is the current cache value", + "`last_balance_basis_date`", + "`last_synced_at` is the execution/sync timestamp", + "imports and account writes advance the account cache", + "`account_valuations` is the historical authority", + "older evidence never rolls back the current cache", + "additive migration", + "legacy rows with unknown basis remain unknown; no inference", + ) + for marker in required_contract: + assert marker in data_model, marker + + +def test_interface_contract_documents_public_valuation_enums_validation_and_mapping() -> None: + interface = _text("docs/interface-contract.md") + + assert "actual enums in source" not in interface + required_contract = ( + "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`", + "loan accounts require representative_liability_definition for `loan`, `loan_balance`, and `balance` aliases", + "legacy valuation-kind aliases remain accepted and map to the canonical purpose", + "validation failure returns `validation_error` and performs no write", + "loan valuations map to liabilities and non-loan valuations map to assets", + ) + for marker in required_contract: + assert marker in interface, marker