24 Commits
Author SHA1 Message Date
gitea-release-agent 68212e773b chore(release): prepare v0.1.5
Merge the independently reviewed v0.1.5 release preparation.
2026-10-08 07:28:04 +00:00
genichin f5de4118d7 [verified] chore(release): prepare v0.1.5 2026-10-08 16:27:20 +09:00
genichin 2ab68f9278 Merge pull request 'Issue #12: 계좌 기준일 평가 이력과 월정산 연동 구현' (#13) from gitea-agent/issue-12-account-valuation-history into main
Reviewed-on: #13
Reviewed-by: gitea-spec-agent <gitea-spec-agent@mail.jsfamily.quest>
Reviewed-by: gitea-review-agent <gitea-review-agent@mail.jsfamily.quest>
2026-10-08 07:14:07 +00:00
gitea-impl-agent 4b8b74ba2a fix: preserve migrated legacy snapshot closes 2026-10-08 03:30:59 +00:00
gitea-impl-agent 432e6f851a Add Review 66 evidence manifest 2026-10-07 22:13:30 +00:00
gitea-impl-agent 63fba3670c Document Review 66 verification plan 2026-10-07 22:09:38 +00:00
gitea-impl-agent 31d1c11dcf Reject invalid balances on account updates 2026-10-07 22:09:02 +00:00
gitea-impl-agent 3bf9b77c6c Add Review 65 evidence manifest 2026-10-07 15:31:16 +00:00
gitea-impl-agent f49568273e Document Review 65 verification plan 2026-10-07 15:16:36 +00:00
gitea-impl-agent 8caf1f8379 Reject invalid dated balances on account creation 2026-10-07 15:15:20 +00:00
gitea-impl-agent fc2d2a6fae Add Review 64 evidence manifest 2026-10-07 13:46:55 +00:00
gitea-impl-agent d258cfdeb4 Document Review 64 verification plan 2026-10-07 13:43:38 +00:00
gitea-impl-agent e41db959e4 Fix Review 64 valuation authority regressions 2026-10-07 13:39:26 +00:00
gitea-impl-agent c7a9cfa50d Bind Review 63 post-correction drift evidence 2026-10-07 08:40:35 +00:00
gitea-impl-agent 3db086a7fd Reject replay after post-correction authority drift 2026-10-07 08:38:35 +00:00
gitea-impl-agent e6ca9a7ff9 Update Review 63 correction-chain evidence 2026-10-07 08:32:13 +00:00
gitea-impl-agent bce25f9106 Preserve replay across correction chains 2026-10-07 08:29:56 +00:00
gitea-impl-agent ce9c9f7326 Add Review 63 verification evidence 2026-10-07 08:19:43 +00:00
gitea-impl-agent 15dd9f92a7 Add Review 63 evidence plan 2026-10-07 08:17:12 +00:00
gitea-impl-agent 77070b8164 Fix Review 63 snapshot and balance regressions 2026-10-07 08:16:30 +00:00
gitea-impl-agent 3b5101128d Clarify Review 61 evidence hash scope 2026-10-07 04:15:59 +00:00
gitea-impl-agent 7fd5c1e0d7 Add Review 61 verification evidence 2026-10-07 04:09:36 +00:00
gitea-impl-agent 1b9eda683c Fix loan account conversion invariant 2026-10-07 03:58:52 +00:00
gitea-impl-agent b8b8811a2a Fix loan valuation definition bypasses 2026-10-07 03:30:23 +00:00
24 changed files with 1115 additions and 953 deletions
+3 -3
View File
@@ -34,7 +34,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
initContainers: initContainers:
- name: init-db - name: init-db
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4 image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- python - python
@@ -62,7 +62,7 @@ spec:
mountPath: /tmp mountPath: /tmp
containers: containers:
- name: mcp - name: mcp
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4 image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
env: env:
- name: JARCA_LEDGER_DB_PATH - name: JARCA_LEDGER_DB_PATH
@@ -104,7 +104,7 @@ spec:
- name: mcp-tmp - name: mcp-tmp
mountPath: /tmp mountPath: /tmp
- name: dashboard - name: dashboard
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4 image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- uvicorn - uvicorn
+10 -2
View File
@@ -60,6 +60,13 @@
| `return_rate` | REAL | 선택 | NULL | finite number | ratio | 아니오 | | `return_rate` | REAL | 선택 | NULL | finite number | ratio | 아니오 |
| `representative_liability_definition` | TEXT | loan 필수 | NULL | principal_only/principal_interest_fees | enum | 아니오 | | `representative_liability_definition` | TEXT | loan 필수 | NULL | principal_only/principal_interest_fees | enum | 아니오 |
#### `select_account_valuation` 선택 규칙과 기존 인덱스 매핑
- `select_account_valuation`은 `account_id is mandatory`인 단일 평가 선택 query다. `currency and valuation_kind/purpose are optional filters`이며, `basis_date exact match or <= upper bound` 조건을 지원한다.
- 선택 결과는 `verified leaf valuations only`다. 즉 superseded parent가 아닌 leaf만 권위로 사용하며, `future-dated and unverified valuations are excluded`한다.
- `idx_account_valuations_account_date supports account/date selection`; `idx_account_valuations_status_date supports status/date selection`. `neither index covers account + currency + purpose`이므로 해당 조합의 전체 선택 조건을 기존 인덱스가 대신 보장한다고 해석하지 않는다.
- `idempotency and single_correction indexes enforce write integrity, not selection`이다. 이 인덱스들은 재기록 방지와 correction 단일 child 불변조건을 위한 것이며 `select_account_valuation`의 후보 검색 인덱스가 아니다.
### manual-assets — 수동 자산/부채 ### manual-assets — 수동 자산/부채
- 책임: transaction이 아닌 평가 source - 책임: transaction이 아닌 평가 source
@@ -118,15 +125,16 @@
### transactions, import-batches, reconciliation-sessions, review-decisions, schema-meta — 기존 entities ### transactions, import-batches, reconciliation-sessions, review-decisions, schema-meta — 기존 entities
기존 필드와 관계를 보존한다. import는 거래를 기록할 수 있지만 balance valuation candidate는 dated `unverified` row만 추가한다. reconciliation은 calculated balance provenance와 `VALUATION_MANAGED` 상태를 보존하며 investment valuation 때문에 transaction을 생성하지 않는다. `schema_meta.schema_version`은 12다. 기존 필드와 관계를 보존한다. import는 거래를 기록할 수 있지만 balance valuation candidate는 dated `unverified` row만 추가한다. loan cache writes without an explicit liability definition do not append an `account_valuations` candidate; current cache와 transaction import는 그대로 완료하며 정의를 추정하지 않는다. 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` 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_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. - `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. - imports and account writes advance the account cache; non-loan writes may also append a dated `account_valuations` candidate. loan cache writes without an explicit liability definition do not append historical evidence, and updating the cache does not rewrite historical rows.
- `account_valuations` is the historical authority. A dated valuation remains append-only evidence even when it is older than the current cache; older evidence never rolls back the current cache. - `account_valuations` is the historical authority. A dated valuation remains append-only evidence even when it is older than the current cache; older evidence never rolls back the current cache.
- Every valuation attached to a `loan` account has an explicit `principal_only` or `principal_interest_fees` `representative_liability_definition`. A non-loan to loan conversion with any undefined or invalid attached valuation is rejected without changing the account or history; conversion with no valuations is allowed.
## 관계와 불변조건 ## 관계와 불변조건
+2 -2
View File
@@ -337,11 +337,11 @@ AppTest는 callback, query mapping, synthetic renderer output과 error/loading e
The public contract explicitly supports these exact values: source_type: `manual`, `import`, `account_write`, `reconciliation`, `snapshot`; verification_status: `verified`, `unverified`, `rejected`; valuation_kind: `balance`, `cash_balance`, `total_value`, `loan`, `loan_balance`; representative_liability_definition: `principal_only`, `principal_interest_fees`. These are the currently supported enums, not a delegation to source code. 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 accounts require representative_liability_definition for `loan`, `loan_balance`, and `balance` aliases. Those are the only accepted valuation kinds for loan accounts; `cash_balance` and `total_value` do not represent a loan liability and are rejected. legacy valuation-kind aliases remain accepted and map to the canonical purpose; alias acceptance does not broaden the canonical enum set. Invalid values, missing loan definitions, and incompatible definitions return `validation_error` and perform no write. A validation failure returns `validation_error` and performs no write. Converting an existing non-loan account to `loan` is rejected with `validation_error` if any attached valuation lacks an explicit valid definition; the account and valuation history remain unchanged. Conversion with no valuations remains allowed.
loan valuations map to liabilities and non-loan valuations map to assets. The output mapping preserves the existing envelopes and signatures: `success` remains the outcome field, failures include `error_code` and `error`, and validation or conflict failures do not partially write. 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. 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 candidate. A dated older non-loan balance appends historical evidence but cannot roll back a newer current cache. loan cache writes without an explicit liability definition do not append a historical candidate; they preserve current-cache and import behavior without inferring a definition. Import and account writers use deterministic candidate keys.
### shared contract and adapter limits ### shared contract and adapter limits
@@ -100,7 +100,7 @@ amount와 principal/interest/fee/cost_basis/net_contributed/profit은 exact inte
### REQ-003.05 — current와 historical authority ### REQ-003.05 — current와 historical authority
current latest display와 historical authority를 분리한다. `last_balance`는 opening evidence가 아니라 external current comparison이며, balance_after/opening evidence가 모두 없으면 `INCOMPLETE_CALCULATION`이다. 늦게 도착한 historical row는 current를 과거로 되돌리지 않으며 cannot roll back newer current cache. account/import writes는 real dated evidence가 있을 때만 dated `unverified` candidate를 만든다. current latest display와 historical authority를 분리한다. `last_balance`는 opening evidence가 아니라 external current comparison이며, balance_after/opening evidence가 모두 없으면 `INCOMPLETE_CALCULATION`이다. 늦게 도착한 historical row는 current를 과거로 되돌리지 않으며 cannot roll back newer current cache. account/import writes는 real dated evidence가 있을 때만 dated `unverified` candidate를 만든다. 대출 cache write는 대표 부채 정의가 없으면 이력 후보를 추가하지 않는다. 이때 current cache와 import 처리는 유지하고 정의를 추정하지 않는다. 기존 valuation이 있는 계좌를 loan으로 변환할 때는 모든 valuation에 유효한 대표 부채 정의가 있어야 하며, 위반 시 계좌와 이력을 변경하지 않고 `validation_error`를 반환한다. valuation이 없는 변환은 허용한다.
**영향 범위** **영향 범위**
- 인터페이스: account update/import candidate, list/select/report - 인터페이스: account update/import candidate, list/select/report
@@ -1,486 +0,0 @@
{
"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"
}
File diff suppressed because one or more lines are too long
@@ -1,181 +0,0 @@
{
"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."
]
}
@@ -1,16 +0,0 @@
{
"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"
}
@@ -1,148 +0,0 @@
{
"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."
}
]
}
@@ -1,82 +0,0 @@
{
"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"
}
]
}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Jarca Ledger MCP server.""" """Jarca Ledger MCP server."""
__version__ = "0.1.4" __version__ = "0.1.5"
@@ -92,17 +92,23 @@ def _validate_common(account_id: str, basis_date: str, amount: int, currency: st
def record_account_valuation_candidate(conn: sqlite3.Connection, account_id: str, basis_date: str, amount: int, currency: str, source_type: str, source_ref: str, idempotency_key: str, memo: str = "") -> dict[str, Any]: def record_account_valuation_candidate(conn: sqlite3.Connection, account_id: str, basis_date: str, amount: int, currency: str, source_type: str, source_ref: str, idempotency_key: str, memo: str = "") -> dict[str, Any]:
"""Append an unverified candidate on a caller-owned transaction.""" """Append an unverified candidate on a caller-owned transaction."""
account = conn.execute("SELECT currency FROM accounts WHERE id=?", (account_id,)).fetchone() account = conn.execute("SELECT type,currency FROM accounts WHERE id=?", (account_id,)).fetchone()
if account is None: if account is None:
return fail("not_found", "account not found") return fail("not_found", "account not found")
if (currency or "KRW").upper() != (account["currency"] or "KRW").upper(): if (currency or "KRW").upper() != (account["currency"] or "KRW").upper():
return fail("validation_error", "valuation currency must match account currency") return fail("validation_error", "valuation currency must match account currency")
if str(account["type"]).lower() == "loan":
return fail("validation_error", "loan valuation requires representative_liability_definition")
try:
amount_int = _strict_int(amount, "amount", allow_none=False)
except (TypeError, ValueError) as exc:
return fail("validation_error", str(exc))
basis = normalize_date(basis_date) basis = normalize_date(basis_date)
existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone()
if existing: if existing:
incoming = { incoming = {
"basis_date": basis, "basis_date": basis,
"amount": int(amount), "amount": amount_int,
"currency": (currency or "KRW").upper(), "currency": (currency or "KRW").upper(),
"valuation_kind": "balance", "valuation_kind": "balance",
"source_type": source_type, "source_type": source_type,
@@ -120,7 +126,7 @@ def record_account_valuation_candidate(conn: sqlite3.Connection, account_id: str
valuation_id = new_id("valuation") valuation_id = new_id("valuation")
conn.execute("""INSERT INTO account_valuations conn.execute("""INSERT INTO account_valuations
(id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key) (id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, int(amount), (currency or "KRW").upper(), "balance", source_type, source_ref or "", "unverified", now_iso(), memo, idempotency_key)) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, amount_int, (currency or "KRW").upper(), "balance", source_type, source_ref or "", "unverified", now_iso(), memo, idempotency_key))
return ok(valuation=_valuation(conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()), reused=False) return ok(valuation=_valuation(conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()), reused=False)
@@ -189,6 +195,8 @@ def register_account_valuation(
is_loan_account = str(account["type"]).lower() == "loan" is_loan_account = str(account["type"]).lower() == "loan"
if effective_kind in {"loan", "loan_balance"} and not is_loan_account: if effective_kind in {"loan", "loan_balance"} and not is_loan_account:
return fail("validation_error", "loan valuation kind requires a loan account") return fail("validation_error", "loan valuation kind requires a loan account")
if is_loan_account and effective_kind not in {"balance", "loan", "loan_balance"}:
return fail("validation_error", "loan account valuation kind must represent a liability")
if is_loan_account and effective_kind in {"balance", "loan", "loan_balance"} and definition is None: 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") return fail("validation_error", "loan valuation requires representative_liability_definition")
if definition is not None and not is_loan_account: if definition is not None and not is_loan_account:
@@ -25,6 +25,14 @@ def create_ledger_account(name: str, type: str = "bank", institution: str = "",
try: try:
basis_date = normalize_date(last_balance_basis_date, default_today=False) if last_balance_basis_date else "" 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)) except Exception as e: return fail("validation_error", str(e))
if basis_date and (last_balance is None or last_balance == ""):
return fail("validation_error", "last_balance_basis_date requires last_balance")
if last_balance is not None:
try:
from .account_valuation_service import _strict_int
last_balance = _strict_int(last_balance, "last_balance", allow_none=False)
except (ValueError, TypeError) as e:
return fail("validation_error", str(e))
with db(db_path) as conn: 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_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)) 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() row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
@@ -42,6 +50,13 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields:
basis_update = "last_balance_basis_date" in fields and fields.get("last_balance_basis_date") is not None basis_update = "last_balance_basis_date" in fields and fields.get("last_balance_basis_date") is not None
if basis_update and not balance_update: if basis_update and not balance_update:
return fail("validation_error", "last_balance_basis_date requires last_balance") return fail("validation_error", "last_balance_basis_date requires last_balance")
normalized_balance = None
if balance_update:
try:
from .account_valuation_service import _strict_int
normalized_balance = _strict_int(fields["last_balance"], "last_balance", allow_none=False)
except (ValueError, TypeError) as e:
return fail("validation_error", str(e))
normalized_basis = None normalized_basis = None
if basis_update: if basis_update:
try: try:
@@ -56,16 +71,26 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields:
if k == "statement_status": v = normalize_statement_status(str(v)) if k == "statement_status": v = normalize_statement_status(str(v))
if k == "liquidity_bucket": v = normalize_liquidity_bucket(str(v)) if k == "liquidity_bucket": v = normalize_liquidity_bucket(str(v))
if k == "active": v = int(bool(v)) if k == "active": v = int(bool(v))
if k == "last_balance" and v != "": v = int(v) if k == "last_balance": v = normalized_balance
if k == "currency": v = str(v).upper() if k == "currency": v = str(v).upper()
if k == "last_balance_basis_date": v = normalized_basis if k == "last_balance_basis_date": v = normalized_basis
except Exception as e: return fail("validation_error", str(e)) except Exception as e: return fail("validation_error", str(e))
updates.append(f"{k}=?"); params.append(v) updates.append(f"{k}=?"); params.append(v)
if not updates: return fail("validation_error", "no fields to update") if not updates: return fail("validation_error", "no fields to update")
target_type = fields.get("type", current["type"])
if str(current["type"]).lower() != "loan" and target_type == "loan":
invalid_valuation = conn.execute(
"SELECT 1 FROM account_valuations "
"WHERE account_id=? AND (representative_liability_definition IS NULL "
"OR representative_liability_definition NOT IN (?, ?)) LIMIT 1",
(account_id, "principal_only", "principal_interest_fees"),
).fetchone()
if invalid_valuation is not None:
return fail("validation_error", "loan account requires a valid representative liability definition for every valuation")
stored_date = str(current["last_balance_basis_date"] or "")[:10] stored_date = str(current["last_balance_basis_date"] or "")[:10]
incoming_date = normalized_basis if basis_update else "" incoming_date = normalized_basis if basis_update else ""
historical = bool(balance_update and incoming_date and stored_date and incoming_date < stored_date) 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_balance = normalized_balance if balance_update else current["last_balance"]
if historical: if historical:
kept=[(u,p) for u,p in zip(updates,params) if not u.startswith(("last_balance=","last_balance_basis_date=","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] updates=[u for u,_ in kept]; params=[p for _,p in kept]
+159 -23
View File
@@ -259,9 +259,21 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool =
return included, excluded return included, excluded
def _persisted_evidence_array(value: Any) -> list[dict[str, Any]] | None:
try:
decoded = json.loads(value or "[]")
except (TypeError, json.JSONDecodeError):
return None
if not isinstance(decoded, list) or any(not isinstance(item, dict) for item in decoded):
return None
return decoded
def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]: def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
"""Load one immutable close run and only the rows persisted by that run.""" """Load one immutable close run and only the rows persisted by that run."""
run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE id=?", (run_id,)).fetchone() run = conn.execute(
"SELECT rowid AS _run_rowid, * FROM asset_snapshot_runs WHERE id=?", (run_id,)
).fetchone()
if not run: if not run:
return [], {} return [], {}
basis_date = run["basis_date"] basis_date = run["basis_date"]
@@ -272,6 +284,24 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[
total_rows = conn.execute( total_rows = conn.execute(
"SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,) "SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,)
).fetchone()[0] ).fetchone()[0]
previous_rows = conn.execute(
"""SELECT COUNT(*)
FROM asset_snapshots AS snapshot
LEFT JOIN asset_snapshot_runs AS snapshot_run
ON snapshot_run.id=snapshot.snapshot_run_id
WHERE snapshot.basis_date=?
AND (
snapshot.snapshot_run_id IS NULL
OR snapshot.snapshot_run_id=''
OR snapshot_run.rowid < ?
)""",
(basis_date, run["_run_rowid"]),
).fetchone()[0]
evidence = {
field: _persisted_evidence_array(run[field])
for field in ("applied_evidence", "excluded_evidence", "carried_evidence")
}
integrity_error = any(value is None for value in evidence.values())
metadata = { metadata = {
"snapshot_run_id": run["id"], "snapshot_run_id": run["id"],
"is_correction": bool(run["correction_of_run_id"]), "is_correction": bool(run["correction_of_run_id"]),
@@ -283,10 +313,12 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[
"approved_at": run["approved_at"], "approved_at": run["approved_at"],
"approval_reason": run["approval_reason"], "approval_reason": run["approval_reason"],
"approval_reference": run["approval_reference"], "approval_reference": run["approval_reference"],
"applied_evidence": json.loads(run["applied_evidence"] or "[]"), "applied_evidence": evidence["applied_evidence"] or [],
"excluded_evidence": json.loads(run["excluded_evidence"] or "[]"), "excluded_evidence": evidence["excluded_evidence"] or [],
"carried_evidence": json.loads(run["carried_evidence"] or "[]"), "carried_evidence": evidence["carried_evidence"] or [],
"evidence_integrity_error": integrity_error,
"closed_at": run["created_at"] or "", "closed_at": run["created_at"] or "",
"previous_snapshot_row_count": previous_rows,
"total_snapshot_rows_for_basis_date": total_rows, "total_snapshot_rows_for_basis_date": total_rows,
} }
return rows, metadata return rows, metadata
@@ -296,6 +328,32 @@ def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) ->
rows = conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis_date,)).fetchall() 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() run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (basis_date,)).fetchone()
if not run: if not run:
legacy_run_rows = [row for row in rows if row["closed_at"] and row["snapshot_run_id"]]
if legacy_run_rows:
# Schema 11 persisted run identity only on the snapshot rows. Keep
# its rowid-based latest-run rule rather than falling back to live
# authority merely because schema 12 has no matching run record.
latest = max(
legacy_run_rows,
key=lambda row: (
int(row["_rowid"]),
row["closed_at"] or row["created_at"] or "",
row["snapshot_run_id"],
),
)
latest_run_id = latest["snapshot_run_id"]
latest_rows = [row for row in legacy_run_rows if row["snapshot_run_id"] == latest_run_id]
return latest_rows, {
"snapshot_run_id": latest_run_id,
"legacy_closed": True,
"is_correction": bool(latest["is_correction"]),
"correction_of_run_id": latest["correction_of_run_id"] or "",
"correction_reason": latest["correction_reason"] or "",
"close_status": "legacy_read_only",
"selection_policy": latest["selection_policy"] or "exact",
"closed_at": latest["closed_at"] or latest["created_at"] or "",
"total_snapshot_rows_for_basis_date": len(rows),
}
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 {} 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 {}
return _snapshot_rows_for_run(conn, run["id"]) return _snapshot_rows_for_run(conn, run["id"])
@@ -319,14 +377,15 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
) )
valuation_date = evidence.get("valuation_date") or snap.get("basis_date", "") valuation_date = evidence.get("valuation_date") or snap.get("basis_date", "")
carried_forward = bool(evidence.get("carried_forward", False)) carried_forward = bool(evidence.get("carried_forward", False))
items.append( public_item = _public_evidence({
_public_evidence({
"id": snap.get("id", ""), "id": snap.get("id", ""),
"source_id": snap.get("source_id", ""), "source_id": snap.get("source_id", ""),
"source_type": snap.get("source_type", ""), "source_type": snap.get("source_type", ""),
"authority_source": "asset_snapshots", "authority_source": "asset_snapshots",
"valuation_source": snap.get("valuation_source", ""), "valuation_source": snap.get("valuation_source", ""),
"name": snap.get("name", ""), "name": snap.get("name", ""),
"currency": snap.get("currency", ""),
"valuation_id": snap.get("valuation_id", ""),
"liquidity_bucket": bucket, "liquidity_bucket": bucket,
"amount": int(snap["amount"]) if snap.get("amount") is not None else None, "amount": int(snap["amount"]) if snap.get("amount") is not None else None,
"valuation_date": valuation_date, "valuation_date": valuation_date,
@@ -341,7 +400,9 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
"correction_of_run_id": snap.get("correction_of_run_id", ""), "correction_of_run_id": snap.get("correction_of_run_id", ""),
"correction_reason": snap.get("correction_reason", ""), "correction_reason": snap.get("correction_reason", ""),
}) })
) if not snap.get("valuation_id"):
public_item.pop("valuation_id", None)
items.append(public_item)
return items return items
@@ -353,7 +414,10 @@ def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
for item in exclusions: for item in exclusions:
if item.get("candidate"): if item.get("candidate"):
key = (item.get("source_type", ""), item.get("source_id", "")) key = (item.get("source_type", ""), item.get("source_id", ""))
warning = candidate_warnings.setdefault(key, {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []}) warning_shape = {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []}
if item.get("blocking") is False:
warning_shape["blocking"] = False
warning = candidate_warnings.setdefault(key, warning_shape)
warning["candidates"].append(item["candidate"]) warning["candidates"].append(item["candidate"])
else: else:
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")}) warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")})
@@ -365,7 +429,7 @@ def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
"snapshot_run_id": metadata.get("snapshot_run_id", ""), "snapshot_run_id": metadata.get("snapshot_run_id", ""),
"close_status": metadata.get("close_status", ""), "close_status": metadata.get("close_status", ""),
"selection_policy": metadata.get("selection_policy", ""), "selection_policy": metadata.get("selection_policy", ""),
"approval": {k: metadata.get(k, "") for k in ("approved_by", "approved_at", "approval_reason", "approval_reference")}, "approval": {} if metadata.get("legacy_closed") else {k: metadata.get(k, "") for k in ("approved_by", "approved_at", "approval_reason", "approval_reference")},
"correction": {k: metadata.get(k, "") for k in ("is_correction", "correction_of_run_id", "correction_reason")}, "correction": {k: metadata.get(k, "") for k in ("is_correction", "correction_of_run_id", "correction_reason")},
} }
@@ -408,6 +472,14 @@ def _closed_warnings(metadata: dict[str, Any], basis_date: str, rows: list[sqlit
valuation_date = str(evidence.get("valuation_date") or "")[:10] valuation_date = str(evidence.get("valuation_date") or "")[:10]
if valuation_date and valuation_date < basis_date: if valuation_date and valuation_date < basis_date:
warnings.append({"code": "stale_valuation", "source_type": evidence.get("source_type", ""), "source_id": evidence.get("source_id", ""), "name": row.get("name", ""), "valuation_date": valuation_date, "basis_date": basis_date}) warnings.append({"code": "stale_valuation", "source_type": evidence.get("source_type", ""), "source_id": evidence.get("source_id", ""), "name": row.get("name", ""), "valuation_date": valuation_date, "basis_date": basis_date})
previous_rows = int(metadata.get("previous_snapshot_row_count") or 0)
if previous_rows:
warnings.append({
"code": "existing_snapshot_rows",
"basis_date": basis_date,
"snapshot_row_count": previous_rows,
"policy": "append_only_correction_required",
})
return warnings return warnings
@@ -607,7 +679,6 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
return fail("validation_error", str(e)) return fail("validation_error", str(e))
with read_db(db_path) as conn: with read_db(db_path) as conn:
preview = _snapshot_preview_from_conn(conn, basis, selection_policy) preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
preview.pop("_included_items", None)
preview["selection_policy"] = selection_policy preview["selection_policy"] = selection_policy
approval_eligible = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))] approval_eligible = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")] carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
@@ -618,7 +689,10 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
preview["close_status"] = "incomplete_draft" preview["close_status"] = "incomplete_draft"
else: else:
preview["close_status"] = "complete_exact" preview["close_status"] = "complete_exact"
# Fingerprint the private authority collection before redacting it from
# the public preview. Close performs the same calculation.
preview["preview_fingerprint"] = _preview_fingerprint(preview, basis, selection_policy) preview["preview_fingerprint"] = _preview_fingerprint(preview, basis, selection_policy)
preview.pop("_included_items", None)
preview.pop("_candidate_state_hashes", None) preview.pop("_candidate_state_hashes", None)
return ok(**preview) return ok(**preview)
@@ -638,6 +712,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy): if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy):
return fail("authority_drift", "asset authority changed since preview", basis_date=basis) return fail("authority_drift", "asset authority changed since preview", basis_date=basis)
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis) latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
if latest_meta.get("evidence_integrity_error"):
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=latest_meta.get("snapshot_run_id", ""))
legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id")) legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id"))
if legacy_snapshot_rows and not correction_reason.strip(): if legacy_snapshot_rows and not correction_reason.strip():
return fail( return fail(
@@ -649,9 +725,10 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
missing_exclusions = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))] missing_exclusions = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")] carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
conditional = bool(missing_exclusions or carried_items) conditional = bool(missing_exclusions or carried_items)
fingerprint_items = preview.get("_included_items", preview["included_items"]) authority_fingerprint = _preview_fingerprint(preview, basis, selection_policy)
fingerprint_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "allow_missing_exclusions": allow_missing_exclusions, "allow_as_of_carry_forward": allow_as_of_carry_forward, "approved_by": approved_by, "approval_reason": approval_reason, "approval_reference": approval_reference, "included": [{**{k: i.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in fingerprint_items], "excluded": [{**{k: i.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["excluded_items"]], "warnings": [warning for warning in preview["warnings"] if warning.get("code") != "existing_snapshot_rows"], "candidate_state_hashes": preview["_candidate_state_hashes"]} request_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "allow_missing_exclusions": allow_missing_exclusions, "allow_as_of_carry_forward": allow_as_of_carry_forward, "approved_by": approved_by, "approval_reason": approval_reason, "approval_reference": approval_reference}
fingerprint = hashlib.sha256(json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest() request_fingerprint = hashlib.sha256(json.dumps(request_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
fingerprint = json.dumps({"authority": authority_fingerprint, "request": request_fingerprint}, sort_keys=True, separators=(",", ":"))
if missing_exclusions and (not allow_missing_exclusions or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()): if missing_exclusions and (not allow_missing_exclusions or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()):
return fail("conditional_approval_required", "missing exclusions require explicit approval metadata and allow_missing_exclusions") return fail("conditional_approval_required", "missing exclusions require explicit approval metadata and allow_missing_exclusions")
if carried_items and (not allow_as_of_carry_forward or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()): if carried_items and (not allow_as_of_carry_forward or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()):
@@ -659,8 +736,53 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
if idempotency_key: if idempotency_key:
existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone() existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone()
if existing_run: if existing_run:
if existing_run["request_fingerprint"] == fingerprint: try:
stored_fingerprint = json.loads(existing_run["request_fingerprint"])
except (TypeError, json.JSONDecodeError):
stored_fingerprint = {"authority": existing_run["request_fingerprint"], "request": ""}
latest_basis_run = conn.execute(
"SELECT id, correction_of_run_id, request_fingerprint FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1",
(basis,),
).fetchone()
run_in_correction_chain = False
seen_run_ids: set[str] = set()
chain_run = latest_basis_run
while chain_run and chain_run["id"] not in seen_run_ids:
if chain_run["id"] == existing_run["id"]:
run_in_correction_chain = True
break
seen_run_ids.add(chain_run["id"])
parent_id = chain_run["correction_of_run_id"]
chain_run = (
conn.execute(
"SELECT id, correction_of_run_id FROM asset_snapshot_runs WHERE id=?",
(parent_id,),
).fetchone()
if parent_id
else None
)
try:
latest_authority_fingerprint = json.loads(
latest_basis_run["request_fingerprint"]
).get("authority") if latest_basis_run else None
except (TypeError, json.JSONDecodeError, AttributeError):
latest_authority_fingerprint = None
immutable_replay_run = bool(
run_in_correction_chain
and latest_basis_run
and latest_authority_fingerprint == authority_fingerprint
and (
latest_basis_run["id"] != existing_run["id"]
or existing_run["correction_of_run_id"]
)
)
if stored_fingerprint.get("request") == request_fingerprint and (
stored_fingerprint.get("authority") == authority_fingerprint
or immutable_replay_run
):
replay_rows, replay_meta = _snapshot_rows_for_run(conn, existing_run["id"]) replay_rows, replay_meta = _snapshot_rows_for_run(conn, existing_run["id"])
if replay_meta.get("evidence_integrity_error"):
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=existing_run["id"])
replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])] replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])]
replay_warnings = _closed_warnings(replay_meta, basis, replay_rows) replay_warnings = _closed_warnings(replay_meta, basis, replay_rows)
replay_public = _closed_run_metadata(replay_meta) replay_public = _closed_run_metadata(replay_meta)
@@ -679,13 +801,16 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
close_status=existing_run["close_status"], close_status=existing_run["close_status"],
selection_policy=existing_run["selection_policy"], selection_policy=existing_run["selection_policy"],
inserted_count=len(replay_rows), inserted_count=len(replay_rows),
inserted_snapshot_ids=[row["id"] for row in replay_rows], inserted_snapshot_ids=[
next((row["id"] for row in replay_rows if row["source_type"] == item.get("source_type") and row["source_id"] == item.get("source_id")), "")
for item in replay_meta.get("applied_evidence", [])
],
warnings=replay_warnings, warnings=replay_warnings,
carried_items=replay_public["carried_items"], carried_items=replay_public["carried_items"],
approval=approval, correction=correction, approval=approval, correction=correction,
provenance=_canonical_provenance( provenance=_canonical_provenance(
valuation_date=replay_provenance["valuation_date"], valuation_date=(replay_items[0].get("valuation_date", "") if replay_items else ""),
age_days=replay_provenance["age_days"], age_days=(replay_items[0].get("age_days") if replay_items else None),
carried=replay_provenance["carried_forward"], carried=replay_provenance["carried_forward"],
verification_status=replay_provenance["verification_status"], verification_status=replay_provenance["verification_status"],
evidence_kind=replay_provenance["evidence_kind"], evidence_kind=replay_provenance["evidence_kind"],
@@ -740,7 +865,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
), ),
) )
snapshot_ids.append(snapshot_id) snapshot_ids.append(snapshot_id)
applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "currency", "valuation_id", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind") if item.get(k) is not None} for item in included] applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "last_synced_at", "currency", "valuation_id", "valuation_kind", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind", "unverified_candidates") if item.get(k) is not None} for item in included]
excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])] excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])]
excluded_evidence.extend( excluded_evidence.extend(
{ {
@@ -748,6 +873,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
"source_id": warning.get("source_id", ""), "source_id": warning.get("source_id", ""),
"reason": "unverified_candidate", "reason": "unverified_candidate",
"candidate": candidate, "candidate": candidate,
"blocking": warning.get("blocking", True),
"state_hash": next( "state_hash": next(
state["state_hash"] state["state_hash"]
for state in preview["_candidate_state_hashes"] for state in preview["_candidate_state_hashes"]
@@ -846,17 +972,22 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None,
return fail("validation_error", f"invalid date: {basis_date}") return fail("validation_error", f"invalid date: {basis_date}")
with read_db(db_path) as conn: with read_db(db_path) as conn:
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis) rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
if close_metadata.get("evidence_integrity_error"):
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=close_metadata.get("snapshot_run_id", ""))
has_close_run = bool(close_metadata.get("snapshot_run_id")) has_close_run = bool(close_metadata.get("snapshot_run_id"))
legacy_closed = bool(close_metadata.get("legacy_closed"))
snapshots = _snapshot_items(conn, basis) if rows else [] snapshots = _snapshot_items(conn, basis) if rows else []
legacy_snapshots = snapshots if rows and not has_close_run else [] legacy_snapshots = snapshots if rows and not has_close_run else []
if has_close_run: if has_close_run or legacy_closed:
run_public = _closed_run_metadata(close_metadata) run_public = _closed_run_metadata(close_metadata)
run_public["warnings"] = _closed_warnings(close_metadata, basis, rows) run_public["warnings"] = _closed_warnings(close_metadata, basis, rows)
if legacy_closed:
run_public["approval"] = {}
summary = _summarize_valuation_items(snapshots, run_public["excluded_items"]) summary = _summarize_valuation_items(snapshots, run_public["excluded_items"])
evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis) evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis)
return _ok_with_provenance_aliases( return _ok_with_provenance_aliases(
basis_date=basis, basis_date=basis,
source="asset_snapshots", source="legacy_asset_snapshots" if legacy_closed else "asset_snapshots",
is_closed_snapshot=True, is_closed_snapshot=True,
snapshot_run_id=close_metadata.get("snapshot_run_id", ""), snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
snapshot_count=len(snapshots), snapshot_count=len(snapshots),
@@ -945,16 +1076,21 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
return fail("validation_error", str(e)) return fail("validation_error", str(e))
with read_db(db_path) as conn: with read_db(db_path) as conn:
basis_rows = conn.execute( basis_rows = conn.execute(
"""SELECT DISTINCT basis_date FROM asset_snapshots """SELECT basis_date FROM asset_snapshots
WHERE basis_date>=? AND basis_date<=?
UNION
SELECT basis_date FROM asset_snapshot_runs
WHERE basis_date>=? AND basis_date<=? WHERE basis_date>=? AND basis_date<=?
ORDER BY basis_date ASC""", ORDER BY basis_date ASC""",
(start_norm, end_norm), (start_norm, end_norm, start_norm, end_norm),
).fetchall() ).fetchall()
points = [] points = []
for row in basis_rows: for row in basis_rows:
basis = row["basis_date"] basis = row["basis_date"]
items = _snapshot_items(conn, basis)
_, metadata = _snapshot_rows_for_latest_run(conn, basis) _, metadata = _snapshot_rows_for_latest_run(conn, basis)
if metadata.get("evidence_integrity_error"):
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=metadata.get("snapshot_run_id", ""))
items = _snapshot_items(conn, basis)
run_public = _closed_run_metadata(metadata) run_public = _closed_run_metadata(metadata)
run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall()) run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall())
run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis) run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jarca-ledger-mcp" name = "jarca-ledger-mcp"
version = "0.1.4" version = "0.1.5"
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis" description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -1,4 +1,41 @@
import sqlite3
from jarca_ledger_mcp import service from jarca_ledger_mcp import service
from jarca_ledger_mcp.application.account_valuation_service import record_account_valuation_candidate
from jarca_ledger_mcp.application.imports_service import _record_import_valuation_candidate
def test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account(
"은행", type="bank", last_balance=100, last_synced_at="2026-06-30",
last_balance_basis_date="2026-06-30", db_path=path,
)
account_id = created["account"]["id"]
before = service.list_account_valuations(account_id, db_path=path)
assert before["count"] == 1
assert before["valuations"][0]["representative_liability_definition"] is None
rejected = service.update_ledger_account(account_id, type="loan", db_path=path)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
accounts = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"]
assert next(account for account in accounts if account["id"] == account_id)["type"] == "bank"
after = service.list_account_valuations(account_id, db_path=path)
assert after["count"] == 1
assert after["valuations"] == before["valuations"]
def test_bank_to_loan_conversion_without_valuations_remains_allowed(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
converted = service.update_ledger_account(account_id, type="loan", db_path=path)
assert converted["success"] is True
assert converted["account"]["type"] == "loan"
assert service.list_account_valuations(account_id, db_path=path)["count"] == 0
def test_loan_balance_requires_representative_liability_definition(tmp_path): def test_loan_balance_requires_representative_liability_definition(tmp_path):
@@ -91,3 +128,104 @@ def test_correction_replays_by_evidence_and_conflicts_on_changed_payload(tmp_pat
assert changed["success"] is False assert changed["success"] is False
assert changed["error_code"] == "correction_conflict" assert changed["error_code"] == "correction_conflict"
assert service.list_account_valuations(account, db_path=path)["count"] == 2 assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_direct_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
rejected = record_account_valuation_candidate(
conn, loan, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_import_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
assert _record_import_valuation_candidate(conn, loan, 100, "2026-06-30", "source-hash") is None
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account(
"대출", type="loan", last_balance=900, last_synced_at="2026-06-30",
last_balance_basis_date="2026-06-30", db_path=path,
)
loan = created["account"]["id"]
assert created["account"]["last_balance"] == 900
assert created["account"]["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
updated = service.update_ledger_account(
loan, 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
assert updated["account"]["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_loan_cash_balance_and_total_value_are_rejected_without_writes(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
for valuation_kind in ("cash_balance", "total_value"):
rejected = service.register_account_valuation(
loan, "2026-06-30", 100, valuation_kind=valuation_kind, 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
def test_non_loan_candidate_still_succeeds_and_reuses_idempotently(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
first = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
replay = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
assert first["success"] is True
assert first["reused"] is False
assert replay["success"] is True
assert replay["reused"] is True
assert replay["valuation"]["id"] == first["valuation"]["id"]
assert service.list_account_valuations(account, db_path=path)["count"] == 1
def test_candidate_invalid_amount_returns_controlled_error_with_existing_row(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
first = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
rejected = record_account_valuation_candidate(
conn, account, "2026-06-30", "not-an-integer", "KRW", "import", "source", "candidate", "memo",
)
assert first["success"] is True
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(account, db_path=path)["count"] == 1
+123
View File
@@ -1,10 +1,133 @@
from __future__ import annotations from __future__ import annotations
import json
import sqlite3 import sqlite3
from jarca_ledger_mcp import service from jarca_ledger_mcp import service
def test_replay_preserves_redacted_unverified_candidates_after_correction(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("투자 계좌", type="investment", db_path=db_path)
account_id = account["account"]["id"]
verified = service.register_account_valuation(
account_id, "2026-06-30", 100, verification_status="verified",
idempotency_key="valuation-verified", db_path=db_path,
)
candidate = service.register_account_valuation(
account_id, "2026-06-30", 110, verification_status="unverified",
idempotency_key="valuation-candidate", db_path=db_path,
)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
)
assert first["success"] is True
assert first["included_items"][0]["unverified_candidates"] == [{
"id": candidate["valuation"]["id"],
"basis_date": "2026-06-30",
"currency": "KRW",
"valuation_kind": "balance",
"verification_status": "unverified",
}]
assert {warning["blocking"] for warning in first["warnings"] if warning["code"] == "unverified_candidate"} == {False}
assert first["included_items"][0]["valuation_id"] == verified["valuation"]["id"]
with sqlite3.connect(db_path) as conn:
persisted = conn.execute("SELECT applied_evidence, excluded_evidence FROM asset_snapshot_runs WHERE id=?", (first["snapshot_run_id"],)).fetchone()
applied_evidence, excluded_evidence = map(json.loads, persisted)
assert applied_evidence[0]["unverified_candidates"] == first["included_items"][0]["unverified_candidates"]
candidate_evidence = [item for item in excluded_evidence if item.get("reason") == "unverified_candidate"]
assert candidate_evidence[0]["candidate"] == first["included_items"][0]["unverified_candidates"][0]
assert candidate_evidence[0]["blocking"] is False
assert candidate_evidence[0]["state_hash"]
corrected = service.close_asset_snapshot(
"2026-06-30", correction_reason="preserve evidence", idempotency_key="candidate-close-b",
db_path=db_path,
)
assert corrected["success"] is True
with sqlite3.connect(db_path) as conn:
before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
)
with sqlite3.connect(db_path) as conn:
after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
after_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
assert replay == first
assert replay["included_items"] == first["included_items"]
assert replay["warnings"] == first["warnings"]
assert after_replay == before_replay
assert after_runs == before_runs
def test_replay_preserves_blocking_candidate_warning_shape(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
account_id = service.create_ledger_account(
"미확인 계좌", type="investment", db_path=db_path,
)["account"]["id"]
service.register_account_valuation(
account_id, "2026-06-30", 110, verification_status="unverified",
idempotency_key="only-candidate", db_path=db_path,
)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="blocking-candidate-close",
allow_missing_exclusions=True, approved_by="reviewer",
approval_reason="exclude unverified evidence",
approval_reference="synthetic-approval", db_path=db_path,
)
assert first["success"] is True
warning = next(item for item in first["warnings"] if item["code"] == "unverified_candidate")
assert "blocking" not in warning
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="blocking-candidate-close",
allow_missing_exclusions=True, approved_by="reviewer",
approval_reason="exclude unverified evidence",
approval_reference="synthetic-approval", db_path=db_path,
)
assert replay == first
replay_warning = next(item for item in replay["warnings"] if item["code"] == "unverified_candidate")
assert "blocking" not in replay_warning
def test_malformed_persisted_evidence_fails_closed_without_snapshot_writes(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="malformed-evidence-close", db_path=db_path,
)
assert first["success"] is True
with sqlite3.connect(db_path) as conn:
conn.execute(
"UPDATE asset_snapshot_runs SET excluded_evidence=? WHERE id=?",
("{malformed", first["snapshot_run_id"]),
)
before_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
report = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="malformed-evidence-close", db_path=db_path,
)
timeseries = service.get_net_worth_timeseries(
"2026-06-01", "2026-06-30", db_path=db_path,
)
for result in (report, replay, timeseries):
assert result["success"] is False
assert result["error_code"] == "data_integrity_error"
with sqlite3.connect(db_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == before_rows
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == before_runs
def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(tmp_path): def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3") db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path) service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
@@ -65,6 +65,163 @@ def test_late_import_current_balance_is_authoritative_by_evidence_basis_date(mon
} }
def test_empty_balance_with_basis_date_is_rejected_without_mutation_before_import(tmp_path, monkeypatch):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("KB", institution="KB국민은행", db_path=path)["account"]["id"]
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
before_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
rejected = service.update_ledger_account(
account,
last_balance="",
last_balance_basis_date="2026-07-01",
db_path=path,
)
after = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
after_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert after == before
assert after_valuations == before_valuations
monkeypatch.setattr(
imports_service,
"parse_kb_bank_pdf",
lambda *_: _parsed(_transaction("2026-06-30", 900, 1), "hash-valid"),
)
monkeypatch.setattr(imports_service, "now_iso", lambda: "2026-07-02T09:00:00")
imported = imports_service.import_kb_bank_pdf("synthetic", "pw", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
valuations = service.list_account_valuations(account, db_path=path)["valuations"]
assert imported["success"] is True
assert current["last_balance"] == 900
assert current["last_balance_basis_date"] == "2026-06-30"
assert {(item["basis_date"], item["amount"]) for item in valuations} == {("2026-06-30", 900)}
def test_create_rejects_whitespace_or_non_integer_dated_balance_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
baseline = service.create_ledger_account("baseline", db_path=path)
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"]
before_valuations = service.list_account_valuations(baseline["account"]["id"], db_path=path)["valuations"]
for invalid_balance in (" ", "not-an-integer", True, False, 1.2, -1.2):
rejected = service.create_ledger_account(
"invalid",
last_balance=invalid_balance,
last_balance_basis_date="2026-07-01",
db_path=path,
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_ledger_accounts(active_only=False, db_path=path)["accounts"] == before
assert service.list_account_valuations(baseline["account"]["id"], db_path=path)["valuations"] == before_valuations
def test_update_rejects_bool_or_float_dated_balance_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"baseline",
last_balance=900,
last_balance_basis_date="2026-07-01",
db_path=path,
)["account"]["id"]
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
before_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
for invalid_balance in (True, False, 1.2, -1.2):
rejected = service.update_ledger_account(
account,
last_balance=invalid_balance,
last_balance_basis_date="2026-07-02",
db_path=path,
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0] == before
assert service.list_account_valuations(account, db_path=path)["valuations"] == before_valuations
def test_update_rejects_empty_undated_balance_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"baseline",
last_balance=900,
last_balance_basis_date="2026-07-01",
db_path=path,
)["account"]["id"]
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
before_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
rejected = service.update_ledger_account(account, last_balance="", db_path=path)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0] == before
assert service.list_account_valuations(account, db_path=path)["valuations"] == before_valuations
def test_update_preserves_zero_and_signed_integer_balance_semantics(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("baseline", db_path=path)["account"]["id"]
zero = service.update_ledger_account(
account,
last_balance=" +0 ",
last_balance_basis_date="2026-07-01",
db_path=path,
)
signed = service.update_ledger_account(
account,
last_balance="-12",
last_balance_basis_date="2026-07-02",
db_path=path,
)
assert zero["success"] is True
assert zero["account"]["last_balance"] == 0
assert signed["success"] is True
assert signed["account"]["last_balance"] == -12
assert signed["account"]["last_balance_basis_date"] == "2026-07-02"
assert [(item["basis_date"], item["amount"]) for item in service.list_account_valuations(account, db_path=path)["valuations"]] == [
("2026-07-01", 0),
("2026-07-02", -12),
]
def test_zero_dated_balance_and_valid_import_recovery(tmp_path, monkeypatch):
path = str(tmp_path / "ledger.sqlite3")
zero = service.create_ledger_account(
"zero",
last_balance=0,
last_balance_basis_date="2026-06-01",
db_path=path,
)
assert zero["success"] is True
assert zero["account"]["last_balance"] == 0
assert [(item["basis_date"], item["amount"]) for item in service.list_account_valuations(zero["account"]["id"], db_path=path)["valuations"]] == [("2026-06-01", 0)]
account = service.create_ledger_account("recoverable", db_path=path)["account"]["id"]
monkeypatch.setattr(
imports_service,
"parse_kb_bank_pdf",
lambda *_: _parsed(_transaction("2026-06-30", 900, 1), "hash-valid"),
)
monkeypatch.setattr(imports_service, "now_iso", lambda: "2026-07-02T09:00:00")
imported = imports_service.import_kb_bank_pdf("synthetic", "pw", account_id=account, db_path=path)
current = next(item for item in service.list_ledger_accounts(active_only=False, db_path=path)["accounts"] if item["id"] == account)
assert imported["success"] is True
assert current["last_balance"] == 900
assert current["last_balance_basis_date"] == "2026-06-30"
def test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate(tmp_path): def test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate(tmp_path):
path = str(tmp_path / "ledger.sqlite3") path = str(tmp_path / "ledger.sqlite3")
unknown = service.create_ledger_account( unknown = service.create_ledger_account(
+24
View File
@@ -104,6 +104,30 @@ def test_import_historical_kb_does_not_replace_newer_current_balance(monkeypatch
assert service.list_account_valuations(account, db_path=path)["count"] == 2 assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_loan_import_preserves_transactions_and_current_cache_without_valuation_candidate(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
monkeypatch.setattr(
imports_service,
"parse_kb_bank_pdf",
lambda *_: _parsed(
_fake_tx("2026-06-30", 700, kind="kb"),
"loan-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]
assert result["success"] is True
assert result["inserted_count"] == 1
assert current["last_balance"] == 700
assert current["last_balance_basis_date"] == "2026-06-30"
assert service.list_ledger_transactions(account_id=account, db_path=path)["count"] == 1
assert service.list_account_valuations(account, db_path=path)["count"] == 0
def test_import_historical_toss_bank_does_not_replace_newer_current_balance(monkeypatch, tmp_path): def test_import_historical_toss_bank_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3") 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, last_balance_basis_date="2026-06-30")["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"]
+76
View File
@@ -0,0 +1,76 @@
from types import SimpleNamespace
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application import imports_service
def _transaction(date, balance):
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"review63:{source_hash}",
raw_payload=lambda: "{}",
)
def test_create_rejects_basis_only_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
rejected = service.create_ledger_account(
"기준일만 있는 계좌",
last_balance_basis_date="2026-07-31",
db_path=path,
)
assert rejected == {
"success": False,
"error_code": "validation_error",
"error": "last_balance_basis_date requires last_balance",
}
baseline = service.create_ledger_account("정상 계좌", db_path=path)
assert baseline["success"] is True
listed = service.list_ledger_accounts(active_only=False, db_path=path)
assert listed["count"] == 1
assert listed["accounts"][0]["name"] == "정상 계좌"
def test_basis_only_create_cannot_block_later_dated_import(tmp_path, monkeypatch):
path = str(tmp_path / "ledger.sqlite3")
rejected = service.create_ledger_account(
"기준일만 있는 계좌",
last_balance_basis_date="2026-07-31",
db_path=path,
)
assert rejected["success"] is False
account = service.create_ledger_account("정상 계좌", db_path=path)["account"]["id"]
monkeypatch.setattr(
imports_service,
"parse_kb_bank_pdf",
lambda *_: {
"transactions": [_transaction("2026-06-30", 50)],
"validation": {"ok": True},
"source_hash": "review63",
"metadata": {"final_balance": 50, "account_number_masked": ""},
"file_path": "/tmp/synthetic.pdf",
"page_count": 1,
},
)
imported = 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]
assert imported["success"] is True
assert current["last_balance"] == 50
assert current["last_balance_basis_date"] == "2026-06-30"
+206
View File
@@ -0,0 +1,206 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp import service
def _approval_kwargs():
return {
"allow_missing_exclusions": True,
"approved_by": "reviewer",
"approval_reason": "reviewed",
"approval_reference": "review-63",
}
def test_f001_preview_fingerprint_uses_same_unredacted_authority_and_rejects_drift(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=db_path)
closed = service.close_asset_snapshot(
"2026-06-30", preview_fingerprint=preview["preview_fingerprint"], db_path=db_path
)
assert closed["success"] is True
service.update_manual_asset(
next(iter(service.list_manual_assets(db_path=db_path)["manual_assets"]))["id"],
valuation_amount=101,
db_path=db_path,
)
with sqlite3.connect(db_path) as conn:
rows_before = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
drifted = service.close_asset_snapshot(
"2026-06-30", preview_fingerprint=preview["preview_fingerprint"], db_path=db_path
)
with sqlite3.connect(db_path) as conn:
rows_after = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
assert drifted["success"] is False
assert drifted["error_code"] == "authority_drift"
assert rows_after == rows_before
def test_f002_close_replay_is_byte_for_byte_equal_for_mixed_account_manual_as_of(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"투자계좌", type="investment", currency="KRW", db_path=db_path
)
service.register_account_valuation(
account["account"]["id"], "2026-06-29", 200, valuation_kind="total_value",
verification_status="verified", idempotency_key="v-29", db_path=db_path,
)
service.create_manual_asset("보증금", 300, "2026-06-30", "real_estate", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30", selection_policy="as_of", idempotency_key="mixed-replay",
allow_as_of_carry_forward=True, **_approval_kwargs(), db_path=db_path,
)
assert first["success"] is True
replay = service.close_asset_snapshot(
"2026-06-30", selection_policy="as_of", idempotency_key="mixed-replay",
allow_as_of_carry_forward=True, **_approval_kwargs(), db_path=db_path,
)
assert replay == first
def test_f002_correction_replay_preserves_full_initial_response(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="correction-initial", db_path=db_path
)
assert first["success"] is True
service.update_manual_asset(
next(iter(service.list_manual_assets(db_path=db_path)["manual_assets"]))["id"],
valuation_amount=120, db_path=db_path,
)
corrected = service.close_asset_snapshot(
"2026-06-30", correction_reason="updated valuation",
idempotency_key="correction-run", db_path=db_path,
)
assert corrected["success"] is True
assert any(warning["code"] == "existing_snapshot_rows" for warning in corrected["warnings"])
correction_replay = service.close_asset_snapshot(
"2026-06-30", correction_reason="updated valuation",
idempotency_key="correction-run", db_path=db_path,
)
assert correction_replay == corrected
initial_replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="correction-initial", db_path=db_path
)
assert initial_replay == first
def test_f006_initial_replay_survives_multiple_later_corrections(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset(
"현금 A", 100, "2026-06-30", "cash", db_path=db_path
)
service.create_manual_asset(
"현금 B", 200, "2026-06-30", "cash", db_path=db_path
)
assets = {
item["name"]: item["id"]
for item in service.list_manual_assets(db_path=db_path)["manual_assets"]
}
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="multi-initial", db_path=db_path
)
assert first["success"] is True
service.update_manual_asset(assets["현금 A"], valuation_amount=110, db_path=db_path)
first_correction = service.close_asset_snapshot(
"2026-06-30", correction_reason="first correction",
idempotency_key="multi-correction-1", db_path=db_path,
)
assert first_correction["success"] is True
service.update_manual_asset(assets["현금 A"], valuation_amount=120, db_path=db_path)
second_correction = service.close_asset_snapshot(
"2026-06-30", correction_reason="second correction",
idempotency_key="multi-correction-2", db_path=db_path,
)
assert second_correction["success"] is True
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="multi-initial", db_path=db_path
)
assert replay == first
service.update_manual_asset(assets["현금 B"], valuation_amount=220, db_path=db_path)
drifted_replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="multi-initial", db_path=db_path
)
assert drifted_replay["success"] is False
assert drifted_replay["error_code"] == "idempotency_conflict"
def test_f003_zero_row_conditional_runs_are_timeseries_authority_with_aliases(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="zero-initial", **_approval_kwargs(), db_path=db_path
)
assert first["success"] is True
assert first["inserted_count"] == 0
assert first["total_assets"] == first["total_liabilities"] == first["net_worth"] == 0
corrected = service.close_asset_snapshot(
"2026-06-30", correction_reason="reconfirm zero", idempotency_key="zero-correction",
**_approval_kwargs(), db_path=db_path,
)
assert corrected["success"] is True
assert corrected["inserted_count"] == 0
series = service.get_net_worth_timeseries(
start_date="2026-06-01", end_date="2026-06-30", db_path=db_path
)
assert series["success"] is True
assert len(series["points"]) == 1
point = series["points"][0]
assert point["basis_date"] == "2026-06-30"
assert point["net_worth"] == point["total_assets"] == point["total_liabilities"] == 0
assert point["close_status"] == "conditional"
assert point["approval"]["approval_reference"] == "review-63"
assert point["excluded_items"]
assert point["snapshot_run_id"] == corrected["snapshot_run_id"]
assert point["provenance"]["monthly_source_snapshot_id"] == corrected["snapshot_run_id"]
def test_f004_closed_report_restores_frozen_valuation_identity_without_legacy_invention(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=db_path)
valuation = service.register_account_valuation(
account["account"]["id"], "2026-06-29", 500, verification_status="verified",
idempotency_key="frozen-v1", db_path=db_path,
)
first = service.close_asset_snapshot(
"2026-06-30", selection_policy="as_of", idempotency_key="frozen-close",
allow_as_of_carry_forward=True, **_approval_kwargs(), db_path=db_path,
)
assert first["success"] is True
report_before = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
item_before = report_before["included_items"][0]
assert item_before["valuation_id"] == valuation["valuation"]["id"]
assert item_before["currency"] == "KRW"
service.register_account_valuation(
account["account"]["id"], "2026-06-29", 900, verification_status="verified",
idempotency_key="frozen-v2", db_path=db_path,
)
report_after = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
assert report_after["included_items"][0]["valuation_id"] == item_before["valuation_id"]
assert report_after["included_items"][0]["currency"] == item_before["currency"]
assert report_after["selection_policy"] == "as_of"
legacy_db = str(tmp_path / "legacy.sqlite3")
service.create_asset_snapshot(
"2026-06-30", "account", "legacy-account", "legacy", 10, "cash", db_path=legacy_db
)
legacy = service.get_asset_snapshot_report("2026-06-30", db_path=legacy_db)
legacy_item = legacy["included_items"][0]
assert "valuation_id" not in legacy_item
assert legacy_item["currency"] == "KRW"
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import hashlib
import sqlite3
from pathlib import Path
from jarca_ledger_mcp import service
from jarca_ledger_mcp.db import init_db
BASIS = "2026-06-30"
def _legacy_closed_db(tmp_path):
path = str(tmp_path / "legacy.sqlite3")
init_db(path)
asset = service.create_manual_asset("legacy cash", 100, "2026-06-01", "cash", db_path=path)["manual_asset"]
with sqlite3.connect(path) as conn:
# These are schema-11 close rows after the additive schema-12 migration:
# run identity is absent, but row close/correction fields remain frozen.
conn.execute(
"""INSERT INTO asset_snapshots
(id, basis_date, source_type, source_id, name, amount, liquidity_bucket,
valuation_source, snapshot_run_id, correction_of_run_id, correction_reason,
is_correction, closed_at, created_at)
VALUES (?, ?, 'manual_asset', ?, ?, 100, 'cash', 'manual_assets', 'legacy-run-correction',
'legacy-initial', 'legacy correction', 1, '2026-06-30T12:00:00+09:00',
'2026-06-30T12:00:00+09:00')""",
("legacy-row", BASIS, asset["id"], asset["name"]),
)
conn.execute("DROP TABLE asset_snapshot_runs")
conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'")
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
return path, asset["id"]
def test_schema11_closed_rows_remain_frozen_after_live_value_and_date_change(tmp_path):
path, asset_id = _legacy_closed_db(tmp_path)
service.update_manual_asset(asset_id, valuation_amount=900, valuation_date="2026-06-30", db_path=path)
before = hashlib.sha256(Path(path).read_bytes()).hexdigest()
report = service.get_asset_snapshot_report(BASIS, db_path=path)
after = hashlib.sha256(Path(path).read_bytes()).hexdigest()
assert report["success"] is True
assert report["source"] == "legacy_asset_snapshots"
assert report["is_closed_snapshot"] is True
assert report["close_status"] == "legacy_read_only"
assert report["net_worth"] == 100
assert report["included_items"][0]["amount"] == 100
assert report["correction"] == {
"is_correction": True,
"correction_of_run_id": "legacy-initial",
"correction_reason": "legacy correction",
}
assert report["snapshot_run_id"] == "legacy-run-correction"
assert report["approval"] == {}
assert "valuation_id" not in report["included_items"][0]
assert after == before
def test_schema11_closed_rows_are_frozen_in_timeseries(tmp_path):
path, asset_id = _legacy_closed_db(tmp_path)
service.update_manual_asset(asset_id, valuation_amount=900, valuation_date="2026-07-01", db_path=path)
point = service.get_net_worth_timeseries(BASIS, BASIS, db_path=path)["points"][0]
assert point["net_worth"] == 100
assert point["item_count"] == 1
assert point["close_status"] == "legacy_read_only"
assert point["snapshot_run_id"] == "legacy-run-correction"
assert point["approval"] == {}
assert point["correction"] == {
"is_correction": True,
"correction_of_run_id": "legacy-initial",
"correction_reason": "legacy correction",
}
def test_schema11_closed_rows_are_frozen_in_monthly_settlement(tmp_path):
path, asset_id = _legacy_closed_db(tmp_path)
service.update_manual_asset(asset_id, valuation_amount=900, valuation_date="2026-07-01", db_path=path)
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
assets = monthly["reports"]["assets"]
coverage = monthly["source_coverage"]["assets"]
assert assets["net_worth"] == 100
assert assets["is_closed_snapshot"] is True
assert coverage["net_worth"] == 100
assert coverage["is_closed_snapshot"] is True
assert coverage["monthly_source_snapshot_id"] == "legacy-run-correction"
assert assets["approval"] == {}
assert coverage["approval"] == {}
assert coverage["correction"] == assets["correction"]
def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(tmp_path):
path = str(tmp_path / "legacy-corrections.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
rows = [
("old-a", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 40),
("old-b", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 60),
("new-a", "legacy-run-2", "legacy-run-1", "correct frozen close", 1, "2026-07-01T10:00:00+09:00", 70),
("new-b", "legacy-run-2", "legacy-run-1", "correct frozen close", 1, "2026-07-01T10:00:00+09:00", 80),
]
for row_id, run_id, prior, reason, correction, closed_at, amount in rows:
conn.execute(
"""INSERT INTO asset_snapshots
(id, basis_date, source_type, source_id, name, amount, currency,
liquidity_bucket, valuation_source, snapshot_run_id,
correction_of_run_id, correction_reason, is_correction,
closed_at, created_at)
VALUES (?, ?, 'manual_asset', ?, ?, ?, 'KRW', 'cash',
'manual_assets', ?, ?, ?, ?, ?, ?)""",
(row_id, BASIS, row_id, row_id, amount, run_id, prior, reason,
correction, closed_at, closed_at),
)
conn.execute("DROP TABLE asset_snapshot_runs")
conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'")
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
before = hashlib.sha256(Path(path).read_bytes()).hexdigest()
report = service.get_asset_snapshot_report(BASIS, db_path=path)
after = hashlib.sha256(Path(path).read_bytes()).hexdigest()
assert report["success"] is True
assert report["source"] == "legacy_asset_snapshots"
assert report["snapshot_run_id"] == "legacy-run-2"
assert report["snapshot_count"] == 2
assert [item["id"] for item in report["included_items"]] == ["new-a", "new-b"]
assert report["net_worth"] == 150
assert report["correction"] == {
"is_correction": True,
"correction_of_run_id": "legacy-run-1",
"correction_reason": "correct frozen close",
}
assert report["approval"] == {}
assert all("valuation_id" not in item for item in report["included_items"])
assert after == before
+29
View File
@@ -41,3 +41,32 @@ def test_interface_contract_documents_public_valuation_enums_validation_and_mapp
) )
for marker in required_contract: for marker in required_contract:
assert marker in interface, marker assert marker in interface, marker
def test_data_model_documents_valuation_selection_contract_and_index_mapping() -> None:
data_model = _text("docs/data-model.md")
required_contract = (
"select_account_valuation",
"account_id is mandatory",
"currency and valuation_kind/purpose are optional filters",
"basis_date exact match or <= upper bound",
"verified leaf valuations only",
"future-dated and unverified valuations are excluded",
"idx_account_valuations_account_date supports account/date selection",
"idx_account_valuations_status_date supports status/date selection",
"neither index covers account + currency + purpose",
"idempotency and single_correction indexes enforce write integrity, not selection",
)
for marker in required_contract:
assert marker in data_model, marker
def test_loan_cache_writers_document_no_inferred_valuation_history() -> None:
data_model = _text("docs/data-model.md")
interface = _text("docs/interface-contract.md")
requirement = _text("docs/requirements/req-003_account-valuation-history.md")
assert "loan cache writes without an explicit liability definition do not append" in data_model
assert "loan cache writes without an explicit liability definition do not append" in interface
assert "대출 cache write는 대표 부채 정의가 없으면 이력 후보를 추가하지 않는다" in requirement
Generated
+1 -1
View File
@@ -469,7 +469,7 @@ wheels = [
[[package]] [[package]]
name = "jarca-ledger-mcp" name = "jarca-ledger-mcp"
version = "0.1.4" version = "0.1.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp" }, { name = "mcp" },