13 Commits
Author SHA1 Message Date
gitea-release-agent f67c07adf0 chore(release): prepare v0.1.6
Merge independently reviewed v0.1.6 release preparation.
2026-10-11 02:03:30 +00:00
genichin 0954d47767 [verified] chore(release): prepare v0.1.6 2026-10-11 11:03:10 +09:00
genichin 565ed8779c Merge pull request 'feat: persistent monthly settlement drafts (#15)' (#16) from gitea-agent/issue-15-settlement-drafts into main
Reviewed-on: #16
Reviewed-by: gitea-spec-agent <gitea-spec-agent@mail.jsfamily.quest>
Reviewed-by: gitea-review-agent <gitea-review-agent@mail.jsfamily.quest>
2026-10-11 01:38:28 +00:00
gitea-impl-agent fa51b4dbb5 fix: align settlement draft valuation and approval policies (PR16 review73) 2026-10-10 15:55:18 +00:00
gitea-impl-agent bae137dce7 docs: clarify draft ownership and cancel recovery (PR16 review71) 2026-10-10 09:49:47 +00:00
gitea-impl-agent 4c5cb0627c docs: bind settlement requirement metadata to implementation PR 16 2026-10-10 04:03:50 +00:00
gitea-impl-agent 577bd0b912 docs: retain canonical close field wording alongside metadata scope 2026-10-10 03:40:10 +00:00
gitea-impl-agent 6fbe81a5d3 test: pin public settlement response and liability registration contracts 2026-10-10 03:22:37 +00:00
gitea-impl-agent 92f44661c3 feat: add persistent monthly settlement drafts and approved valuation selection 2026-10-10 02:49:34 +00:00
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
76 changed files with 3598 additions and 2164 deletions
+4
View File
@@ -8,6 +8,7 @@ import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import assets_gateway
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
from dashboard.page_views.settlement_drafts import render_settlement_drafts
from dashboard.common import * # noqa: F403, F405
@@ -153,3 +154,6 @@ def page_assets(db_path: str) -> None:
st.json({k: close_res.get(k) for k in ["basis_date", "source", "is_closed_snapshot", "snapshot_run_id", "correction_of_run_id", "correction_reason", "closed_at", "net_worth"]})
else:
st.error(close_res.get("error", close_res))
st.divider()
render_settlement_drafts(db_path=db_path)
@@ -9,6 +9,7 @@ import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway
from dashboard.page_views.settlement_drafts import render_settlement_drafts
from dashboard.common import * # noqa: F403, F405
@@ -265,5 +266,8 @@ def page_monthly_settlement(db_path: str) -> None:
for action in action_candidates:
st.markdown(f"- {action}")
st.divider()
render_settlement_drafts(db_path=db_path, month=selected_month)
st.divider()
st.markdown("- [요약 페이지로 이동](?page=요약)")
+218
View File
@@ -0,0 +1,218 @@
from __future__ import annotations
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway as gateway
def _safe_result(result: dict) -> None:
if result.get("success"):
st.success("요청을 처리했습니다.")
else:
# Never expose exception details, arbitrary evidence, or backend secrets.
st.error(f"요청을 처리하지 못했습니다 ({result.get('error_code', 'operation_failed')}).")
def _invalidate_and_rerun(draft_id: str, result: dict) -> None:
st.session_state[f"settlement_mutation_feedback_{draft_id}"] = {
"success": bool(result.get("success")),
"error_code": result.get("error_code", "operation_failed"),
"memo_saved": bool(result.get("memo_saved")),
}
st.session_state.pop(f"review_result_{draft_id}", None)
st.session_state.pop(f"approved_{draft_id}", None)
st.session_state.pop("settlement_bound_state", None)
st.rerun()
def render_settlement_drafts(db_path: str | None = None, month: str = "") -> None:
st.header("월정산 초안 lifecycle")
st.caption("자산 마감과 거래 정산 상태는 별도입니다. 조회/렌더링은 쓰기를 수행하지 않습니다.")
month_value = month or st.text_input("초안 월", value="2026-09", key="settlement_month")
actor = st.text_input("작업자", value="operator", key="settlement_actor")
with st.form("settlement_create"):
memo = st.text_input("메모")
idem = st.text_input("생성 키", value="dashboard-create")
correction_run = st.text_input("정정 대상 run ID (선택)")
correction_reason = st.text_input("정정 사유")
create = st.form_submit_button("초안 생성 / 정정 초안")
if create:
_safe_result(gateway.create_settlement_draft(month_value, actor, idem, memo=memo,
correction_of_run_id=correction_run, correction_reason=correction_reason, db_path=db_path))
listed = gateway.list_settlement_drafts(month_value, db_path=db_path)
drafts = listed.get("drafts", []) if listed.get("success") else []
if not listed.get("success"):
st.error(f"초안 목록을 불러오지 못했습니다 ({listed.get('error_code', 'operation_failed')}).")
if not isinstance(drafts, list): drafts = []
ids = [str(d.get("id", "")) for d in drafts if isinstance(d, dict) and d.get("id")]
selected_id = st.selectbox("초안 선택", ids, key="settlement_draft_id") if ids else ""
if not selected_id:
if listed.get("success"):
st.info("초안이 없습니다. 생성은 명시적 버튼으로 수행됩니다.")
st.caption("완료 초안은 이력에서 불변으로 조회하고, 정정은 이전 마감 ID와 사유를 지정해 새 초안으로 만듭니다.")
st.caption("목록은 활성·완료·취소 이력을 포함합니다. 평가자료 조회·등록·정정은 초안 변경과 별도 작업입니다.")
st.button("검토", disabled=True, key="settlement_review_disabled")
st.button("승인", disabled=True, key="settlement_approve_disabled")
st.button("최종 마감", disabled=True, key="settlement_finalize_disabled")
st.button("초안 취소", disabled=True, key="settlement_cancel_disabled")
st.button("평가자료 조회 / 등록 / 정정", disabled=True, key="settlement_valuation_disabled")
st.caption("제외 사유는 항목별로 명시 입력합니다.")
st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다.")
return
detail = gateway.get_settlement_draft(selected_id, db_path=db_path)
feedback = st.session_state.pop(f"settlement_mutation_feedback_{selected_id}", None)
if isinstance(feedback, dict):
if feedback.get("memo_saved"):
st.warning("메모는 저장되었지만 항목 변경은 실패했습니다")
_safe_result(feedback)
draft = detail.get("draft", {}) if detail.get("success") else {}
if not isinstance(draft, dict): draft = {}
version = draft.get("version")
draft_version = version
fingerprint = detail.get("review", {}).get("review_fingerprint", "") if isinstance(detail.get("review"), dict) else ""
state_key = "settlement_bound_state"
bound = (selected_id, version, fingerprint)
previous = st.session_state.get(state_key)
if previous != bound:
st.session_state[state_key] = bound
st.session_state.pop(f"review_result_{selected_id}", None)
st.session_state.pop(f"approved_{selected_id}", None)
st.caption(f"ID {draft.get('id', selected_id)} · 월 {draft.get('month', month_value)} · 기준일 {draft.get('basis_date', '-')} · 상태 {draft.get('status', '-')} · 버전 {version} · draft_version={draft_version}")
st.caption(f"review_fingerprint={fingerprint or '미검토'}")
st.caption(f"생성자 {draft.get('created_by', '-')} · 생성 {draft.get('created_at', '-')} · 변경 {draft.get('updated_at', '-')} · 메모 {draft.get('memo', '')}")
if draft.get("status") == "completed":
st.success(f"완료 — run {draft.get('final_run_id', '-')}; 변경 불가, 정정은 새 초안으로 생성합니다.")
if draft.get("status") in {"completed", "cancelled"}:
st.markdown("### 불변 이력")
st.write("final_run_id", draft.get("final_run_id"))
st.write("correction_of_run_id", draft.get("correction_of_run_id"))
st.json(detail.get("items", []))
st.caption("종료된 초안의 증거는 수정하지 않습니다. 정정이 필요하면 이전 마감과 사유를 연결해 새 초안을 생성하세요.")
return
items = detail.get("items", []) if isinstance(detail.get("items"), list) else []
for item in items:
if not isinstance(item, dict): continue
st.write(f"{item.get('source_type', '')} {item.get('source_id', '')} · {item.get('status', '')} · 평가 {item.get('valuation_date', '-')}")
basis, actual = item.get("basis_date", draft.get("basis_date", "")), item.get("valuation_date", "")
if basis and actual:
try:
from datetime import date
difference = (date.fromisoformat(actual) - date.fromisoformat(basis)).days
st.caption(f"기준일 대비 날짜 차이: {difference:+}일 ({'이후 대체' if difference > 0 else '이전' if difference < 0 else '동일'})")
except ValueError:
st.caption("날짜 차이를 계산할 수 없습니다.")
if item.get("status") == "missing": st.warning("누락 — 값은 0원이 아닙니다.")
with st.form(f"settlement_edit_{selected_id}"):
memo_edit = st.text_input("초안 메모 저장", value=draft.get("memo", ""))
source_type = st.selectbox("대상 유형", ["account", "manual_asset"])
source_id = st.text_input("대상 ID")
valuation_id = st.text_input("평가자료 ID (비우면 해제)")
mode = st.selectbox("선택 방식", ["exact", "carried_before", "manual_substitute"])
selection_reason = st.text_input("선택 사유")
included = st.checkbox("포함", value=True)
exclusion_reason = st.text_input("제외 사유")
save = st.form_submit_button("저장 / 지정 / 포함·제외")
if save:
result = gateway.update_settlement_draft(selected_id, version, actor, memo_edit, db_path=db_path)
current_version = (result.get("draft") or {}).get("version", version) if result.get("success") else version
memo_updated = bool(result.get("success"))
if result.get("success") and source_id:
result = gateway.set_settlement_draft_item(selected_id, source_type, source_id, current_version, actor,
valuation_id=valuation_id, selection_mode=mode, selection_reason=selection_reason,
included=included, exclusion_reason=exclusion_reason, db_path=db_path)
if not result.get("success"):
result = {**result, "memo_saved": True}
if memo_updated or result.get("success"):
_invalidate_and_rerun(selected_id, result)
_safe_result(result)
with st.form(f"valuation_{selected_id}"):
account_id = st.text_input("평가 등록 계좌 ID")
valuation_date = st.text_input("평가일 YYYY-MM-DD")
amount = st.number_input("금액", value=0, step=1)
currency = st.text_input("통화", value="KRW")
valuation_kind = st.selectbox("평가 종류", ["balance", "total_value", "cash_balance", "loan", "loan_balance"])
liability_definition = st.selectbox("대표 부채 정의", ["", "principal_only", "principal_interest_fees"])
source_ref = st.text_input("증빙 참조 ID")
verification = st.selectbox("확인 상태", ["unverified", "verified", "rejected"])
valuation_memo = st.text_input("평가 메모")
registration_key = st.text_input("등록 idempotency key")
register = st.form_submit_button("평가자료 등록")
if register:
result = gateway.register_account_valuation(account_id, valuation_date, int(amount), currency=currency,
valuation_kind=valuation_kind, source_ref=source_ref, verification_status=verification,
memo=valuation_memo, idempotency_key=registration_key,
representative_liability_definition=liability_definition or None, db_path=db_path)
if result.get("success") and any(item.get("source_id") == account_id for item in items if isinstance(item, dict)):
_invalidate_and_rerun(selected_id, result)
_safe_result(result)
if st.button("평가자료 조회", key=f"valuation_list_{selected_id}") and source_id:
vals = gateway.list_account_valuations(source_id, db_path=db_path)
if vals.get("success"):
st.json([{k: value.get(k) for k in ("id", "account_id", "basis_date", "recorded_at", "amount", "currency", "valuation_kind", "verification_status", "source_ref", "supersedes_id", "representative_liability_definition")} for value in vals.get("valuations", [])])
else: _safe_result(vals)
with st.form(f"valuation_correct_{selected_id}"):
valuation_id_correct = st.text_input("정정할 valuation ID")
corrected_amount = st.number_input("정정 금액", value=0, step=1)
correction_reason = st.text_input("평가 정정 사유")
correction_key = st.text_input("평가 정정 키")
correction_verification = st.selectbox("정정 확인 상태", ["unverified", "verified", "rejected"])
correction_source_ref = st.text_input("정정 증빙 참조 ID")
correct = st.form_submit_button("평가자료 정정")
if correct:
result = gateway.correct_account_valuation(valuation_id_correct, int(corrected_amount), correction_reason,
idempotency_key=correction_key, verification_status=correction_verification,
source_ref=correction_source_ref, db_path=db_path)
corrected_account_id = (result.get("valuation") or {}).get("account_id", "")
if result.get("success") and any(item.get("source_id") == corrected_account_id for item in items if isinstance(item, dict)):
_invalidate_and_rerun(selected_id, result)
_safe_result(result)
review_result = None
if st.button("검토 / 새로고침 대상", key=f"review_{selected_id}"):
review_result = gateway.review_settlement_draft(selected_id, db_path=db_path)
st.session_state[f"review_result_{selected_id}"] = review_result
if st.button("대상 명시적 갱신", key=f"refresh_{selected_id}"):
result = gateway.refresh_settlement_draft_targets(selected_id, version, actor, db_path=db_path)
if result.get("success"):
_invalidate_and_rerun(selected_id, result)
_safe_result(result)
review_result = review_result or st.session_state.get(f"review_result_{selected_id}")
if isinstance(review_result, dict) and review_result.get("success"):
review = review_result.get("review", review_result)
st.write("검토 합계 (잠정)", review.get("total_assets"), review.get("total_liabilities"), review.get("net_worth"))
st.write("누락", review.get("missing_items", []))
if review.get("contains_substitutions"):
st.warning("대체 평가자료를 포함한 월정산")
fingerprint = review.get("review_fingerprint", "")
st.caption(f"현재 승인 대상 fingerprint: {fingerprint}")
st.session_state[state_key] = (selected_id, review.get("version", version), fingerprint)
with st.form(f"approve_{selected_id}"):
approval_reason = st.text_input("승인 사유")
approval_reference = st.text_input("승인 근거")
allow_missing = st.checkbox("누락 승인", value=False)
allow_carried = st.checkbox("이월 승인", value=False)
allow_substitute = st.checkbox("대체자료 승인", value=False)
approve = st.form_submit_button("검토 결과 승인")
if approve:
if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint):
st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.")
return
_safe_result(gateway.approve_settlement_draft(selected_id, version, fingerprint, actor,
approval_reason, approval_reference, allow_missing, allow_carried, allow_substitute, db_path=db_path))
if st.button("최종 마감", key=f"finalize_{selected_id}"):
if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint):
st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.")
return
result = gateway.finalize_settlement_draft(selected_id, version, fingerprint,
f"dashboard-finalize-{selected_id}-{version}", db_path=db_path)
if result.get("success"):
_invalidate_and_rerun(selected_id, result)
_safe_result(result)
with st.form(f"cancel_{selected_id}"):
cancel_reason = st.text_input("취소 사유")
cancel = st.form_submit_button("초안 취소")
if cancel:
result = gateway.cancel_settlement_draft(selected_id, version, actor, cancel_reason, db_path=db_path)
if result.get("success"):
_invalidate_and_rerun(selected_id, result)
_safe_result(result)
+3 -3
View File
@@ -34,7 +34,7 @@ spec:
type: RuntimeDefault
initContainers:
- name: init-db
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.6
imagePullPolicy: IfNotPresent
command:
- python
@@ -62,7 +62,7 @@ spec:
mountPath: /tmp
containers:
- name: mcp
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.6
imagePullPolicy: IfNotPresent
env:
- name: JARCA_LEDGER_DB_PATH
@@ -104,7 +104,7 @@ spec:
- name: mcp-tmp
mountPath: /tmp
- name: dashboard
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.6
imagePullPolicy: IfNotPresent
command:
- uvicorn
+19
View File
@@ -174,3 +174,22 @@
- 오류 데이터: invalid currency/kind, missing loan definition, idempotency/correction conflict
- 저장 후 재조회: service list/select/report and contract regression tests
- 마이그레이션: `test_*schema*` tests plus `python -m pytest tests/test_account_valuations.py tests/test_asset_snapshots.py`
## settlement-drafts — 월정산 초안
Schema 13 adds two additive tables; `init_db()`/`migrate_db()` remain the only schema writers and schema 11→12 history is unchanged. No draft backfill is inferred. Exact migrated columns (from `infrastructure/sqlite/migrations.py`) are listed below; `SCHEMA_SQL` and migration DDL are the schema authority.
| Table | Columns (SQLite types; `?` means nullable) | Indexes / constraints |
|---|---|---|
| `settlement_drafts` | `id TEXT PK`, `month TEXT NOT NULL`, `basis_date TEXT NOT NULL`, `status TEXT NOT NULL`, `version INTEGER NOT NULL`, `created_by TEXT NOT NULL`, `created_at TEXT NOT NULL`, `updated_at TEXT NOT NULL`, `memo TEXT DEFAULT ''`, `create_key TEXT UNIQUE NOT NULL`, `correction_of_run_id TEXT DEFAULT ''`, `correction_reason TEXT DEFAULT ''`, `final_run_id TEXT DEFAULT ''`, `final_response TEXT DEFAULT ''`, `cancelled_by TEXT DEFAULT ''`, `cancellation_reason TEXT DEFAULT ''`, `cancelled_at TEXT DEFAULT ''`, `approval_fingerprint TEXT DEFAULT ''`, `approval_version INTEGER DEFAULT 0`, `approved_by TEXT DEFAULT ''`, `approved_at TEXT DEFAULT ''`, `approval_reason TEXT DEFAULT ''`, `approval_reference TEXT DEFAULT ''`, `approval_flags TEXT DEFAULT '{}'`, additive `original_request_hash TEXT DEFAULT ''` | `idx_settlement_drafts_active_month(month) WHERE status='active'` unique; `idx_settlement_drafts_month(month,created_at)` |
| `settlement_draft_items` | `id TEXT PK`, `draft_id TEXT NOT NULL FK`, `source_type TEXT NOT NULL`, `source_id TEXT NOT NULL`, `valuation_id TEXT DEFAULT ''`, `selection_mode TEXT DEFAULT 'exact'`, `selection_reason TEXT DEFAULT ''`, `included INTEGER DEFAULT 1`, `exclusion_reason TEXT DEFAULT ''`, `version INTEGER DEFAULT 1`, `selected_by TEXT DEFAULT ''`, `selected_at TEXT DEFAULT ''`, `evidence_json TEXT DEFAULT '{}'` | `UNIQUE(draft_id,source_type,source_id)` |
`settlement_drafts` therefore owns generated draft ID, YYYY-MM month, fixed month-end `basis_date`, active/completed/cancelled status, positive version, creator/timestamps/memo, create idempotency key+request fingerprint, correction parent/reason, approval actor/time/reason/reference/flags/fingerprint/version, final run ID and persisted response. The partial unique active-month constraint is shared by normal/correction drafts. `settlement_draft_items` owns stable target type/ID, inclusion/reason, selected valuation and selection mode/reason, item version/audit, frozen source evidence/hash and correction-child identity. Monetary authority is derived from eligible frozen evidence, not editable amount columns.
Modes are `exact`, `carried_before`, and `manual_substitute`. `exact` requires confirmed same-day evidence; `carried_before` requires a verified prior valuation and reason; `manual_substitute` requires explicit valuation ID, reason and review/approval even when valuation date is future. Preserve `date_difference_days = valuation_date - basis_date`; `age_days` retains its historical basis-minus-valuation meaning. Unverified candidates remain visible with `candidate_amount` but authority amount NULL. Missing is distinct from substitution/exclusion. Manual evidence freezes semantic fields and hash; only explicit refresh/reselection changes it.
Frozen/public evidence retains `valuation_date`, original `recorded_at`, derived `date_difference_direction` (`after`/`before`/`same`/`unknown`) and `age_days`; loan evidence retains `representative_liability_definition`. `recorded_at` may be later than month end while `valuation_date` is exactly month end. These names are preserved through finalized response, reports, timeseries and monthly coverage; date differences and semantic hashes are derived from frozen JSON rather than separate draft-item columns. Actor/version/timestamps of approval are cleared together on draft mutation.
Review fingerprint binds draft version, target/inclusion state, full valuation/manual semantic evidence, correction child, warnings, amounts/classification/date and prior run. Approved fingerprint/version become invalid on mutation or external drift. Finalize checks idempotency replay before active status, then validates version, approval, fingerprint, current evidence and correction parent, and inserts run+rows+draft completion/link in one `BEGIN IMMEDIATE`. Rollback on any SQLite failure; replay returns persisted response.
KRW only aggregation; non-KRW is warned/excluded, never converted. NULL is not zero; no targets yields provisional zero plus completeness and unregistered-liability warning, not a complete close. Existing snapshot rows preserve full frozen evidence, approval, date/mode/difference and actors; substitution, carried and approved-missing flags are orthogonal. `monthly_source_snapshot_id` remains a read-model alias, never a persistence field.
+33 -2
View File
@@ -424,12 +424,43 @@ Writer/bootstrap explicitly calls `init_db()`. `read_db()` uses SQLite `mode=ro`
## D04 snapshot close output contract
Every successful snapshot close output has these exact top-level fields (the transport envelope may additionally include `success`): `snapshot_run_id`, `close_status`, `approval`, `correction`, `warnings`, `excluded_items`, `carried_items`, `provenance`, `valuation_date`, `age_days`, `verification_status`, `evidence_kind`, and `monthly_source_snapshot_id`. `monthly_source_snapshot_id` is an alias of `snapshot_run_id`, not a persistence field.
Every successful **legacy direct close** output retains these required top-level fields as required top-level metadata fields (the transport envelope may additionally include `success`): `snapshot_run_id`, `close_status`, `approval`, `correction`, `warnings`, `excluded_items`, `carried_items`, `provenance`, `valuation_date`, `age_days`, `verification_status`, `evidence_kind`, and `monthly_source_snapshot_id`. This is a required retained metadata-field list, not an exhaustive inventory of all keys in a successful legacy response; additional legacy response keys remain part of the legacy output. The list does not define the additive settlement-draft finalize response. `monthly_source_snapshot_id` is an alias of `snapshot_run_id`, not a persistence field.
The top-level `provenance` object has these exact nested `provenance` fields: `valuation_date`, `age_days`, `carried_forward`, `verification_status`, `evidence_kind`, `approval`, `correction`, `warnings`, `excluded_items`, and `carried_items`. `approval` and `correction` are objects or explicit empty values when not applicable; warnings and item collections are arrays. `valuation_date` is the actual selected evidence date, `age_days` is computed from the requested basis date, and verification/evidence status describe selected evidence. Live, closed, timeseries, and monthly read models preserve these fields and alias semantics.
The top-level `provenance` object retains these required nested `provenance` fields (metadata, not an exhaustive inventory of all nested response keys): `valuation_date`, `age_days`, `carried_forward`, `verification_status`, `evidence_kind`, `approval`, `correction`, `warnings`, `excluded_items`, and `carried_items`. `approval` and `correction` are objects or explicit empty values when not applicable; warnings and item collections are arrays. `valuation_date` is the actual selected evidence date, `age_days` is computed from the requested basis date, and verification/evidence status describe selected evidence. Live, closed, timeseries, and monthly read models preserve these fields and alias semantics.
The preview input includes basis_date, selection_policy, included/excluded evidence state hashes, warnings, and candidate_state_hashes. preview output includes preview_fingerprint. Close accepts the exact `preview_fingerprint: str = ""` parameter shown in the signature block and recomputes the same authority fingerprint before opening the write boundary. A mismatch returns `authority_drift` and no partial write.
Replay uses replay payload equality: an existing idempotency key replays only when the persisted request fingerprint is equal; a changed payload returns `idempotency_conflict`. The replay response is reconstructed from the persisted run and its evidence, with no replay-only reused fields.
`close_metadata` is a recursive close metadata allowlist: only run, approval, correction, warnings, exclusions, carried evidence, and allowlisted evidence fields (`source_type`, `source_id`, `authority_source`, `valuation_id`, dates/statuses and `state_hash`) cross the boundary. `applied_evidence`, `excluded_evidence`, and `carried_evidence` are redacted recursively; unknown nested keys are dropped, and credentials, tokens, cookies, raw account data, and arbitrary source-note payloads are never returned. This is the recursive close_metadata allowlist and redaction contract.
## settlement-draft-service — 월정산 초안 API (Issue #15)
Common application owner: `jarca_ledger_mcp/application/settlement_draft_service.py`; re-export through `jarca_ledger_mcp.service`; service and gateway signatures below include internal `db_path`, which is omitted from MCP tool inputSchema. All other MCP-exposed parameters retain their documented names, defaults, and requiredness. MCP exposes the same operation names, not the same signatures; Dashboard uses existing gateway layering. Draft finalize adds draft-specific response fields while preserving common aliases; it is not a claim that its response has the legacy direct-close shape. Each returns the existing `success`/`error_code`/`error` envelope. Dashboard auth gate precedes DB access; this adds no MCP authentication claim.
```python
create_settlement_draft(month: str, created_by: str, idempotency_key: str, basis_date: str = "", memo: str = "", correction_of_run_id: str = "", correction_reason: str = "", db_path: str | None = None)
list_settlement_drafts(month: str = "", db_path: str | None = None)
get_settlement_draft(draft_id: str, db_path: str | None = None)
update_settlement_draft(draft_id: str, expected_version: int, updated_by: str, memo: str, db_path: str | None = None)
refresh_settlement_draft_targets(draft_id: str, expected_version: int, updated_by: str, db_path: str | None = None)
set_settlement_draft_item(draft_id: str, source_type: str, source_id: str, expected_version: int, selected_by: str, valuation_id: str = "", selection_mode: str = "exact", selection_reason: str = "", included: bool = True, exclusion_reason: str = "", db_path: str | None = None)
review_settlement_draft(draft_id: str, db_path: str | None = None)
approve_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, approved_by: str, approval_reason: str, approval_reference: str, allow_missing_exclusions: bool = False, allow_carried_before: bool = False, allow_manual_substitute: bool = False, db_path: str | None = None)
finalize_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, idempotency_key: str, db_path: str | None = None)
cancel_settlement_draft(draft_id: str, expected_version: int, cancelled_by: str, reason: str, db_path: str | None = None)
```
Create validates strict YYYY-MM and month-end date, nonempty actor/key and payload replay equality; same key+payload returns same draft, changed payload or active duplicate conflicts. Versions are positive strict integers (bool/fraction/None rejected); inclusion flags actual bool. `review` is read-only and returns draft/version/fingerprint, items and included/excluded/missing/warnings, approval_required, provisional totals, completeness, substitution and unregistered-liability flags. `candidate_amount` may be shown for unverified evidence but authority amount NULL. Approval binds fingerprint+version and explicit flags. Finalize requires approval even for exact complete data; same request replays the persisted response, conflicting key/payload errors, and failed transaction leaves no partial state.
`read_db()` remains SQLite `mode=ro`: missing DB returns `db_not_initialized`, no-create/no mkdir/no migrate/no commit; initialized read failures return `db_error`. Existing auth cookie/logout, MCP transport/scope, menu, query, shell and browser behavior are unchanged. Existing D04 close aliases and recursive metadata allowlist/redaction remain authoritative.
### Draft evidence public fields
`unregistered_liabilities` is true when the frozen target set contains no registered liability source. It does not assert unknown real-world debt or prove the operator is debt-free. Exclusion and missing evidence remain separate completeness states; neither changes the registration meaning of this flag.
`review.included_items`, finalized `items`, asset report `close_metadata.applied_evidence`, timeseries point `applied_evidence`, and monthly `source_coverage.assets.applied_evidence` preserve the same selected-evidence field names: `valuation_date` (actual valuation date), `recorded_at` (original recording timestamp, not the basis date), `date_difference_days = valuation_date - basis_date`, `date_difference_direction` (`after`, `before`, `same`, or `unknown`), and `age_days` (basis-minus-valuation days; negative for a future substitute). A loan additionally preserves `representative_liability_definition`; selection audit includes `selection_mode`, `selection_reason`, `selected_by`, `selected_at`, `item_version`, and draft/fingerprint lineage. These are derived or frozen response fields, not additional database columns.
Nested draft `approval` retains `allow_missing_exclusions`, `allow_carried_before`, and `allow_manual_substitute` alongside actor/time/reason/reference. The three approval flags and the three result flags (`contains_substitutions`, `contains_carried`, `has_approved_missing`) are independent. Unverified evidence can be visible as `candidate_amount` but cannot become applied monetary authority; only an explicitly approved missing/excluded row may persist with NULL amount. No lineage default keys are injected into legacy persisted evidence arrays. Recursive allowlisting excludes raw source notes, memos, and unknown nested fields.
Executable field-contract regression: `tests/test_settlement_draft_contract_scenarios.py::test_date_metadata_public_contract_names_survive_all_closed_consumers`. Related clause tests cover representative integer precision, transaction readiness independent of asset close, approved unresolved candidates, and the synthetic September pension/loan dates with stale, rollback, and concurrent idempotent retry.
+40
View File
@@ -210,3 +210,43 @@ The owner is the canonical decision or write boundary; consumers render or read
The Dashboard gateway is `jarca_ledger_mcp/adapters/streamlit/overview_gateway.py` (including `get_asset_snapshot_report` and reconciliation/monthly read delegates). It calls shared application read services after `auth-before-DB`; page rendering is not an alternate policy or persistence boundary. A missing DB has stable unavailable semantics (`db_not_initialized`) and no-create behavior. The missing DB stable unavailable contract is no-create. An initialized DB read failure is `db_error`; initialized read has no write, migration, mkdir, or commit. The initialized DB read failure is db_error and initialized read has no write.
Reconciliation ownership remains in `reconciliation_service.py`: opening evidence and transaction coverage are required inputs. Missing opening evidence or incomplete coverage produces `INCOMPLETE_CALCULATION`/`reconciliation unavailable` with provenance, never an inferred balance or fake transaction.
## Issue #15 settlement draft ownership
| Path | Ownership / boundary |
|---|---|
| `jarca_ledger_mcp/application/settlement_draft_service.py` | Shared business validation, frozen selection/review/approval/finalize; owns no UI or transport behavior. |
| `jarca_ledger_mcp/application/monthly_settlement_service.py` | Read-only monthly aggregate; never draft mutation or transaction readiness write. |
| `jarca_ledger_mcp/application/assets_service.py` | Existing close/report authority; draft finalization does not call live-select close as shortcut. |
| `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | Additive schema 12→13 migration; schema initializer and migration constraints agree. |
| `jarca_ledger_mcp/adapters/mcp/server.py` | Thin same-name/same-signature MCP tools; no duplicate business logic, no new auth claim. |
| `jarca_ledger_mcp/adapters/streamlit/` | Existing Dashboard gateway boundary; auth-before-DB, explicit user actions only. |
| `tests/test_monthly_settlement_drafts.py` | Synthetic DB lifecycle, failure injection, replay and no-write regressions. |
Never document invented `jarca_ledger_mcp.mcp` or `monthly.py` modules. `transaction readiness` is separate from asset completeness and monthly reads remain read-only.
### Shared settlement-draft entrypoints
| Path | Responsibility / entrypoint and dependency boundary |
|---|---|
| `jarca_ledger_mcp/service.py` | Existing public re-export facade exports the ten lifecycle operations from `application/settlement_draft_service.py`; adapters share these functions, not separate business implementations. |
| `jarca_ledger_mcp/adapters/mcp/server.py` | Same-name MCP lifecycle tools delegate to the shared facade, retaining existing success/error envelopes. |
| `jarca_ledger_mcp/adapters/mcp/tool_contracts.py` | Registers the same facade callables and their inputSchema contracts; internal `db_path` remains outside public MCP input. |
| `jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py` | Wraps shared lifecycle functions plus valuation list/register/correct delegates, preserving auth-before-DB and internal database-path resolution. |
| `dashboard/page_views/settlement_drafts.py` | `render_settlement_drafts` owns reusable presentation and explicit user-action controls through the gateway; no direct SQL or duplicated approval policy. |
| `dashboard/page_views/monthly_settlement.py` | `page_monthly_settlement` invokes the shared panel beside the existing read-only monthly summary. |
| `dashboard/page_views/assets.py` | `page_assets` invokes the same panel beside existing valuation and immutable asset-history views. |
| `jarca_ledger_mcp/application/account_valuation_service.py` | `register_account_valuation`, `list_account_valuations`, and `correct_account_valuation` retain registration, validation and append-only correction ownership; the draft service reuses their evidence. |
| `jarca_ledger_mcp/application/assets_service.py` | Owns established valuation/amount authority and asset close/report semantics; draft finalization does not bypass review by calling legacy live-select close. |
| `jarca_ledger_mcp/application/monthly_settlement_service.py` | `get_monthly_settlement_summary` owns read-only propagation of finalized evidence, not draft mutation or transaction-readiness writes. |
| `jarca_ledger_mcp/infrastructure/sqlite/schema.py` | schema 13 initialization uses the additive migration path; no adapter owns persistence constraints. |
| `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | Creates persistent draft/item tables and indexes, preserving existing schema and rows on repeated initialization. |
### Shared settlement-draft policies
- `jarca_ledger_mcp/application/settlement_draft_service.py` checks one active draft per month for ordinary and correction drafts; SQLite independently enforces it with `idx_settlement_drafts_active_month` on month `WHERE status='active'`. Completed and cancelled history is retained, not deleted to release that slot.
- The same application service validates strict month syntax and month-end basis-date equality. Dashboard and MCP cannot supply an alternate date policy.
- The shared review service exposes unverified `candidate_amount` with authority amount NULL; no adapter may sum it as verified evidence. A confirmed child requires explicit reselection, fresh review and approval; correction children are not silently followed.
- Dependencies remain page → gateway → public facade → application → SQLite, with MCP server/tool contracts → the same facade. No UI/MCP direct SQL, application-to-Dashboard reverse dependency or duplicated business validation is introduced.
- Paired synthetic coverage: `tests/test_monthly_settlement_drafts.py` (month-end, active-month, unverified/reselection, cancellation and atomicity), `tests/test_settlement_draft_adapters.py` (facade/gateway/MCP signatures and no-create reads), `tests/test_settlement_draft_dashboard.py` and `tests/test_settlement_draft_panel_workflow.py` (shared panel/actions), `tests/test_settlement_draft_mcp_runtime.py` (real stdio responses), and `tests/test_settlement_draft_docs.py` (this mapping). Existing authentication/menu/legacy regression tests remain unchanged.
@@ -3,13 +3,13 @@
"schema": "gitea-agent.requirement/v1",
"id": "REQ-003",
"title": "계좌 기준일 평가 이력과 자산 마감 연동",
"revision": "1.0",
"revision": "1.1",
"status": "approved",
"depends_on": [],
"applies_to": ["ledger", "dashboard", "mcp"],
"language": "ko",
"issue": "#12",
"implementation_pr": "#13"
"issue": "#15",
"implementation_pr": "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16"
}
---
@@ -21,7 +21,7 @@
## 2. 배경
구현은 SQLite schema 12를 schema 11에 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다.
구현은 SQLite schema 13를 schema 11에 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다.
## 3. 범위
@@ -52,7 +52,7 @@
### REQ-003.01 — additive 이력과 legacy 보존
schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 12의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다.
schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 13의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다.
**영향 범위**
- 인터페이스: 기존 service/MCP 응답 envelope 유지, 신규 valuation/snapshot fields additive
@@ -172,9 +172,21 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
- 호환성: 기존 auth/menu/query/shell/browser evidence semantic inventory 0 missing
- 배포 전제 조건: secret redaction과 fixture DB
### REQ-003.11 — 월정산 초안·승인·원자적 마감 (Issue #15)
월말 기준일의 durable draft는 자료가 없어도 생성되며 생성 자체로 run/rows를 만들지 않는다. 월별 활성 초안은 일반·정정 공통으로 하나다. 대상 구성은 명시적 refresh만 반영하고 검토는 frozen evidence의 version/fingerprint에 묶인다. Finalize는 drift를 거절하고 `BEGIN IMMEDIATE`에서 run, rows, completion/link를 함께 기록하며 동일 요청은 persisted response로 replay한다.
**영향 범위**
- 인터페이스: shared settlement draft service, MCP tools, authenticated Dashboard gateway; monthly summary read-only
- 영속성: schema 12→13 additive `settlement_drafts`, `settlement_draft_items`; legacy 11→12 보존
- 후속 사용 주체: Dashboard operator, MCP caller, close reports/timeseries/monthly read model
- 마이그레이션: additive/idempotent; historical drafts backfill 없음
- 호환성: existing valuation/auth/menu/query/shell/MCP contracts 유지
- 배포 전제 조건: explicit writer init, SQLite transaction, synthetic tests
## 6. 제약과 불변조건
- verified leaf만 authority이며 future/unverified/rejected/unknown은 합산하지 않는다.
- Automatic `exact`/`as_of` selection and legacy direct close exclude future, unverified, rejected, and unknown valuations from authority. The sole future-valuation exception is an explicitly selected, verified `manual_substitute` in a settlement draft, with reason, review, approval, and original valuation/basis dates preserved; a future valuation is never chosen automatically. Unverified, rejected, or unknown amounts are never authority on any path.
- 동일 idempotency payload는 같은 ID를 반환하고 다른 payload는 conflict다.
- correction과 re-close는 append-only이고 parent당 child/run은 하나다.
- NULL과 0을 구별하고 모든 금액은 integer precision이다.
@@ -186,13 +198,353 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
## 7. 인수 기준
### REQ-003.11-AC01 — 빈 월 초안 영속 생성
**판정 기준**
자료가 없어도 월말 기준일의 초안이 저장되고 생성만으로 snapshot run/rows가 생기지 않는다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_empty_draft_persists_without_closing_and_has_consistent_public_shape`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: 정상 empty draft; 경계 no targets; 실패 invalid month
**상태 불변조건**
Create는 close side effect가 없다.
### REQ-003.11-AC02 — 월말 기준일 및 create replay
**판정 기준**
월말만 허용하고 같은 key/payload는 idempotent, 다른 payload는 충돌한다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_basis_must_be_calendar_month_end_and_create_key_is_idempotent`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal month-end; boundary leap month; failure non-month-end
**상태 불변조건**
거절된 create는 초안을 추가하지 않는다.
### REQ-003.11-AC03 — 월별 단일 active 초안과 correction link
**판정 기준**
Normal/correction 초안을 합쳐 월별 active는 하나이며 취소 후 월 재개가 가능하다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_one_active_draft_across_normal_and_correction_and_cancellation_reopens_month`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal competing create; boundary correction lineage; failure second active
**상태 불변조건**
Correction lineage는 명시적이고 유일하다.
### REQ-003.11-AC04 — 입력·버전·행위자 검증
**판정 기준**
잘못된 값과 stale version을 거절하고 include/exclude 사유 및 actor를 기록한다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_input_validation_versions_actors_and_include_exclude_reason`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal valid mutation; boundary version; failure invalid type
**상태 불변조건**
거절된 mutation은 상태와 version을 변경하지 않는다.
### REQ-003.11-AC05 — 명시적 target refresh와 read-only review
**판정 기준**
대상 membership은 refresh 전 고정되고 review는 읽기 전용이다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_targets_are_frozen_until_explicit_refresh_and_review_is_read_only`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal refresh; boundary changed sources; failure implicit refresh
**상태 불변조건**
Review는 DB 상태를 쓰지 않는다.
### REQ-003.11-AC06 — exact, carry, manual substitute 날짜 구분
**판정 기준**
선택 모드는 valuation date와 basis date 차이를 보존하며 future substitute는 명시적이다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_explicit_future_substitute_and_late_recorded_exact_are_distinguished`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal exact; boundary future substitute; failure implicit future choice
**상태 불변조건**
`date_difference_days = valuation_date - basis_date`를 보존한다.
### REQ-003.11-AC07 — unverified 후보는 authority 아님
**판정 기준**
Unverified 후보 금액은 표시 가능하나 authority amount는 NULL이며 verified child는 재선택을 요구한다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_unverified_candidate_has_no_authority_and_verified_child_requires_reselection`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal verified selection; boundary candidate; failure candidate authority
**상태 불변조건**
NULL은 0이나 verified 금액으로 대체되지 않는다.
### REQ-003.11-AC08 — 수동 evidence drift와 future 차단
**판정 기준**
수동 source 변경은 명시적 refresh가 필요하고 future evidence가 자동 선택되지 않는다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_manual_sources_drift_requires_explicit_refresh_and_future_not_auto_selected`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal unchanged source; boundary drift; failure auto-select future
**상태 불변조건**
Frozen source hash는 refresh 전까지 불변이다.
### REQ-003.11-AC09 — 승인 fingerprint 및 drift gate
**판정 기준**
Mutation/source drift는 approval을 무효화하고 finalize를 막는다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_approval_fingerprint_mutation_and_external_drift_block_finalize`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal unchanged approval; boundary drift; failure stale finalize
**상태 불변조건**
Finalize는 승인된 version/fingerprint만 받는다.
### REQ-003.11-AC10 — 원자적 finalize 및 persisted replay
**판정 기준**
Finalize는 immutable provenance를 저장하고 동일 요청에 persisted response를 반환한다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_finalize_persists_immutable_provenance_replays_exact_response_and_conflicts_key`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal finalize; boundary replay; failure conflicting key
**상태 불변조건**
Replay는 저장된 결과를 재구성하며 중복 run을 만들지 않는다.
### REQ-003.11-AC11 — DB 실패 rollback
**판정 기준**
SQLite write 실패 시 run, rows, completed 상태가 모두 rollback된다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_sqlite_trigger_failure_rolls_back_run_rows_and_completion`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal successful commit; boundary trigger failure; failure mid-transaction
**상태 불변조건**
부분 마감 결과는 남지 않는다.
### REQ-003.11-AC12 — additive schema 13 migration
**판정 기준**
Migration은 legacy rows를 보존하고 반복 실행에 안전하다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_schema13_migration_is_additive_and_idempotent`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal new DB; boundary schema 12; failure repeated migration
**상태 불변조건**
Schema 11→12 의미와 데이터는 보존된다.
### REQ-003.11-AC13 — 동시 create와 stale finalize
**판정 기준**
동시 create는 active uniqueness를 지키고 stale version finalize는 거절한다.
**검증**
`tests/test_monthly_settlement_drafts.py::test_concurrent_create_and_stale_version_finalization_are_safe`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal sequential mutation; boundary concurrent create; failure stale finalize
**상태 불변조건**
한 달에 active 초안은 최대 하나다.
### REQ-003.11-AC14 — draft adapter public contract
**판정 기준**
Gateway/MCP는 실제 service signature와 결과를 그대로 노출한다.
**검증**
`tests/test_settlement_draft_adapters.py::test_gateway_exports_exact_contract_signature_and_delegates`
**검증 분류**
- 수준: integration
- 백엔드: application
- 케이스: normal delegation; boundary signature; failure service error
**상태 불변조건**
Adapter는 authority 판단이나 응답 변형을 하지 않는다.
### REQ-003.11-AC15 — Dashboard auth 및 render 경계
**판정 기준**
인증은 DB 접근 전 수행되고 render는 lifecycle mutation을 하지 않는다.
**검증**
`tests/test_settlement_draft_adapters.py::test_dashboard_authentication_entrypoint_precedes_database_resolution`
**검증 분류**
- 수준: integration
- 백엔드: dashboard
- 케이스: normal authenticated read; boundary unauthenticated; failure DB access before auth
**상태 불변조건**
인증 없는 요청은 DB에 접근하지 않는다.
### REQ-003.11-AC16 — missing DB read no-create
**판정 기준**
Read-only gateway missing database를 생성하거나 migrate하지 않는다.
**검증**
`tests/test_settlement_draft_adapters.py::test_read_only_gateway_calls_do_not_create_missing_database`
**검증 분류**
- 수준: integration
- 백엔드: sqlite
- 케이스: normal initialized read; boundary missing DB; failure implicit create
**상태 불변조건**
Read path는 no mkdir, no migrate, no commit이다.
### REQ-003.11-AC17 — legacy contract regression
**판정 기준**
Valuation correction, monthly summary read-only, menu/auth compatibility regressions remain green.
**검증**
`tests/test_settlement_draft_adapters.py::test_service_reexports_every_lifecycle_operation`
**검증 분류**
- 수준: integration
- 백엔드: application
- 케이스: normal legacy call; boundary old schema; failure incompatible export
**상태 불변조건**
New draft operations are additive and legacy entrypoints remain available.
### REQ-003.01-AC01 — migration and preservation
**Cases (정상/경계/실패)**: 정상 schema 12 init twice; 경계 schema 11 legacy DB; 실패 malformed legacy DB.
**Cases (정상/경계/실패)**: 정상 schema 13 init twice; 경계 schema 11 legacy DB; 실패 malformed legacy DB.
**State Invariants**: legacy tables/rows remain; migration repeats without duplicate columns.
**판정 기준**: `schema_version=12`, required tables/columns/indexes exist and old rows remain.
**판정 기준**: `schema_version=13`, required tables/columns/indexes exist and old rows remain.
**검증**: `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`.
**검증 분류**: level=integration; backend=sqlite
@@ -312,6 +664,7 @@ REQ-001의 auth gate·cookie/cleanup/logout, REQ-002의 메뉴 10개와 order(`
- 근거 이슈: `#12`
- 구현 PR: `#13`
- Issue #15 구현 PR: https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16
- 관련 설계 문서: `docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md`
- 관련 요구사항: `REQ-001`, `REQ-002`
- DB lifecycle: writer/bootstrap은 명시적 `init_db()` 호출로만 생성·마이그레이션하고, `read_db()`는 SQLite `mode=ro`로 연다. missing DB는 `db_not_initialized`이며 read path는 no mkdir, no migrate, no commit이다. first-use write만 init_db를 수행하고 auth-before-DB 순서를 보존한다.
+33
View File
@@ -239,3 +239,36 @@ The fingerprint input is exactly basis date, selection policy, included and excl
An idempotent close is replayed only for equal request fingerprints. Replay reconstructs the response from the persisted run/evidence (no replay-only reused payload), while a different payload returns `idempotency_conflict`. Recursive close metadata is allowlisted and redacted before it reaches Dashboard/MCP.
Dashboard missing-DB reads return stable unavailable `db_not_initialized` without create/migrate/commit. The missing DB stable unavailable contract is no-create. Initialized read failures return `db_error`; successful initialized reads are read-only. The initialized DB read failure is db_error and initialized read has no write. Reconciliation requires opening evidence and coverage through the requested basis; otherwise it reports `INCOMPLETE_CALCULATION`/`reconciliation unavailable` with provenance, no inferred balance, and no fake transaction.
## settlement-draft-create — 월정산 초안 lifecycle (Issue #15)
### create → edit → review → approve → finalize
| 단계 ID | 실행 주체 | 처리 / guard | 상태 경계 | 실패 전이 |
|---|---|---|---|---|
| `settlement-draft-create` | adapter/service | Strict month/date/actor/key validation; explicit correction prior run/reason; acquire `BEGIN IMMEDIATE`; idempotent equal replay; enforce one active draft/month. | Persist draft and initial target items only; no snapshot run/rows/close. | validation/conflict; rollback |
| `settlement-draft-edit` | shared service | Expected version; targets change only through explicit refresh; item include/exclude and valuation selection audited; mutation clears approval. | Increment draft/item versions; retain history/reasons. | stale version or invalid scope, no write |
| `settlement-draft-review` | shared service | Read frozen source/valuation evidence, warning and correction-child drift; compute review fingerprint/totals/missing; no auto future selection. | Read-only; unverified candidate_amount with authority amount NULL. | drift/missing warnings; no authority mutation |
| `settlement-draft-approve` | authorized caller | Require matching current fingerprint/version and explicit actor/reason/reference/flags. | Persist approval binding and flags. | mismatch/unsafe unknown/rejected evidence rejected |
| `settlement-draft-finalize` | shared service/SQLite | Idempotency persisted-response lookup first; then active/version/approval/fingerprint/drift/correction-parent checks; insert run, rows and completed/link within one `BEGIN IMMEDIATE`. | Atomic immutable close and draft completion. | conflict or injected DB failure rolls back all writes |
Exact requires same-date verified evidence; carried_before uses verified prior with reason; manual_substitute requires explicit ID and reason, with signed `date_difference_days = valuation_date - basis_date`. `age_days` keeps its existing direction/meaning. Corrected valuation children are never followed automatically: explicit reselection then fresh review/approval. Missing and substitution remain orthogonal; manual evidence drift requires explicit refresh. Existing row-only legacy close uses established latest row-group ordering and available run identity; never infer a registry row or fall back to live authority.
### failure/recovery and read-only guards
Draft create/update/finalize use transaction-scoped writes; any DB failure rolls back run, rows and completion. Same finalize request returns persisted response, not a replay-only `reused` field; different request conflicts. Monthly summary and review do not mutate; transaction readiness remains independent. `read_db()` missing path is `db_not_initialized`, with no-create/no mkdir/no migrate/no commit. Dashboard gate precedes every protected DB access; existing auth cookie lifecycle and exact menu/query/shell/browser semantics remain unchanged.
### Cancellation and reopening
- Entry: authenticated Dashboard explicit action or the existing MCP tool delegates through the shared service facade to `cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason, db_path=None)`. This adds no MCP authentication claim and does not alter Dashboard authentication.
| Stage ID | Owner / input and guard | State, success and failure transition |
|---|---|---|
| `settlement-draft-cancel` | Shared `_mutate` requires an existing active draft, matching positive-integer `expected_version`, nonempty `cancelled_by` actor and nonempty reason. | In `BEGIN IMMEDIATE`, persist status cancelled plus `cancelled_by`, `cancellation_reason`, `cancelled_at`; increment draft version and `updated_at`. Clear `approval_fingerprint`, `approval_version`, approver/time/reason/reference and flags. Success terminates in cancelled; state/version conflicts return conflict with no-write to the existing draft. Invalid input returns validation_error. |
| `settlement-draft-cancel-recovery` | Cancellation is a transaction-scoped metadata mutation, not evidence deletion or an asset close. | Existing draft item/evidence rows, `account_valuations`, `asset_snapshot_runs`, and `asset_snapshots` are retained. DB failures rollback both cancellation and approval invalidation and return db_error; correct input or reread current state/version before retry. Completed/already cancelled drafts cannot be cancelled again; stale expected versions never overwrite current state. |
| `settlement-draft-reopen` | Only completed or cancelled drafts release the active-month slot. Start a new explicit create request with a fresh create key; ordinary and correction drafts still share the one-active-month restriction. | The same create key with its original equal payload returns the previous draft, even after completion/cancellation; changed payload conflicts. A fresh key is not permission to overwrite history. No close exists: create without a correction parent. A close exists: explicitly link the latest prior run using `correction_of_run_id` and nonempty `correction_reason`, still at month-end. Continue at settlement-draft-create; missing/wrong parent or reason is rejected. |
Cancellation reason is stored in `cancellation_reason`, not memo; the cancellation actor and timestamp are persisted, not inferred from a later caller. Approval invalidation prevents reuse of the cancelled draft's approval. Reopening creates new draft/item identity and requires its own review/approval; finalization rechecks the correction parent and preserves prior immutable run/rows. Cancellation itself is not an idempotent finalization replay: retrying cancellation after its successful terminal transition returns conflict.
Coverage: `tests/test_monthly_settlement_drafts.py` checks cancellation/reopen, version conflicts and lifecycle persistence; `tests/test_settlement_draft_authority.py` checks cancellation approval invalidation; `tests/test_settlement_draft_correction_contract.py` checks latest-run linkage, reason, immutable history and persisted replay; `tests/test_settlement_draft_docs.py` checks these documented transitions. Existing auth/menu and read-only recovery flows above remain unchanged.
@@ -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,50 +0,0 @@
{
"schema": "gitea-agent.contract-preservation-inventory/v1",
"owner": "jsfamily",
"repository": "jarca-ledger-mcp",
"issue": 12,
"pull_request": 13,
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"review_id": 61,
"reviewed_parent_sha": "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8",
"reviewed_head": "1b9eda683c66d3c7129716b9899eddf61df3362d",
"preserved_semantics": [
{
"criterion": "N01-loan-balance-definition",
"required": "Every valuation attached to a loan account explicitly records principal_only or principal_interest_fees; no writer or account-type transition may infer or bypass it.",
"evidence": [
"tests/test_account_valuation_edge_contracts.py",
"tests/test_issue_12_review59_account.py"
]
},
{
"criterion": "C05-current-import",
"required": "Loan cache and import operations remain successful without creating definition-less history, and late evidence never rolls back newer current cache.",
"evidence": [
"test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations",
"test_loan_import_preserves_transactions_and_current_cache_without_valuation_candidate"
]
},
{
"criterion": "C13-read-only-and-no-partial-write",
"required": "Validation failure changes neither account state nor append-only valuation history.",
"evidence": [
"test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation"
]
},
{
"criterion": "compatibility",
"required": "A non-loan account with no valuation history may still be converted to loan; non-loan candidate insertion and replay are unchanged.",
"evidence": [
"test_bank_to_loan_conversion_without_valuations_remains_allowed",
"test_non_loan_candidate_still_succeeds_and_reuses_idempotently"
]
}
],
"excluded_scope": [
"No inferred or automatic liability-definition migration.",
"No schema change.",
"No changes to snapshot, reconciliation, dashboard, authentication, or menu behavior.",
"No merge, Issue close, or release operation."
]
}
@@ -1,208 +0,0 @@
{
"schema": "gitea-agent.evidence-manifest/v1",
"owner": "jsfamily",
"repository": "jarca-ledger-mcp",
"issue": 12,
"pull_request": 13,
"review": 61,
"reviewed_parent_sha": "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8",
"source_head_sha": "1b9eda683c66d3c7129716b9899eddf61df3362d",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"environment": {
"platform": "Linux-6.18.34+rpt-rpi-v8-aarch64-with-glibc2.41",
"python": "3.13.5",
"uv": "uv 0.11.6 (aarch64-unknown-linux-musl)",
"fixture_policy": "synthetic repository fixtures only; no credentials or real financial data"
},
"commands": [
{
"id": "conversion-regression",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"pytest",
"-q",
"-rs",
"tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_without_valuations_remains_allowed"
],
"purpose": "Verify conversion rejection is atomic and history-free conversion remains compatible",
"started_at_utc": "2026-10-07T03:59:49.130624Z",
"finished_at_utc": "2026-10-07T03:59:51.888407Z",
"duration_seconds": 2.757882494479418,
"exit_code": 0,
"expected_exit_code": 0,
"output": ".. [100%]\n"
},
{
"id": "review61-regressions",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"pytest",
"-q",
"-rs",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_issue_12_review59_account.py",
"tests/test_valuation_contract_docs.py"
],
"purpose": "Run Review 61 and independent-review named regressions",
"started_at_utc": "2026-10-07T03:59:51.888570Z",
"finished_at_utc": "2026-10-07T03:59:58.351942Z",
"duration_seconds": 6.463426006026566,
"exit_code": 0,
"expected_exit_code": 0,
"output": "........................ [100%]\n"
},
{
"id": "full-suite",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"pytest",
"-q",
"-rs"
],
"purpose": "Run the complete repository test suite at the exact source head",
"started_at_utc": "2026-10-07T03:59:58.352023Z",
"finished_at_utc": "2026-10-07T04:01:03.222283Z",
"duration_seconds": 64.87031899858266,
"exit_code": 0,
"expected_exit_code": 0,
"output": "........................................................................ [ 25%]\n........................................................................ [ 51%]\n...............................................................ss....... [ 77%]\n.....................................................ss...... [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-12/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n=========================== short test summary info ============================\nSKIPPED [1] tests/test_kb_bank_pdf.py:26: sample KB Bank PDF/password is not available\nSKIPPED [1] tests/test_kb_bank_pdf.py:42: sample KB Bank PDF/password is not available\nSKIPPED [1] tests/test_toss_securities_pdf.py:20: sample Toss Securities PDF is not available\nSKIPPED [1] tests/test_toss_securities_pdf.py:32: sample Toss Securities PDF is not available\n"
},
{
"id": "ruff",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"ruff",
"check",
"."
],
"purpose": "Run Python lint checks",
"started_at_utc": "2026-10-07T04:01:03.222360Z",
"finished_at_utc": "2026-10-07T04:01:03.426930Z",
"duration_seconds": 0.20462407637387514,
"exit_code": 0,
"expected_exit_code": 0,
"output": "All checks passed!\n"
},
{
"id": "compileall",
"argv": [
"python3",
"-m",
"compileall",
"-q",
"jarca_ledger_mcp",
"dashboard",
"tests",
"scripts"
],
"purpose": "Compile production, dashboard, test, and script Python surfaces",
"started_at_utc": "2026-10-07T04:01:03.427003Z",
"finished_at_utc": "2026-10-07T04:01:03.684488Z",
"duration_seconds": 0.25754371751099825,
"exit_code": 0,
"expected_exit_code": 0,
"output": ""
},
{
"id": "diff-check",
"argv": [
"git",
"diff",
"--check",
"5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8..1b9eda683c66d3c7129716b9899eddf61df3362d"
],
"purpose": "Check the complete rework diff for whitespace errors",
"started_at_utc": "2026-10-07T04:01:03.684567Z",
"finished_at_utc": "2026-10-07T04:01:03.730543Z",
"duration_seconds": 0.04603164829313755,
"exit_code": 0,
"expected_exit_code": 0,
"output": ""
}
],
"changed_files": [
{
"path": "docs/data-model.md",
"sha256": "f9932a04f3c74907da1c21d754a16e1401f64fd948945017d410de7d91494dca",
"git_blob": "70f26ce1522b8609a93471655c9506040a4078f1"
},
{
"path": "docs/interface-contract.md",
"sha256": "01a3f37e23ee17b2b928b44e3e07d4d6982faead1ab6f0298889225b0c5ce916",
"git_blob": "4304427a604949b8295b4f93cb6054357805fe92"
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"sha256": "f5a3bf9f69d622451a1a08854062218382f03355a3464538a35b1e270ca5b70f",
"git_blob": "2a7be4d780999fce47402f15f0b05d0b92990715"
},
{
"path": "docs/verification/issue-12-review-61/red-evidence.json",
"sha256": "567c03d28e6de74b6adfc0f6141dd96e9e129bab9d4455761615a1655870b4bf",
"git_blob": "b5f4bc9286b4ea443671ab0744935a11c295b124"
},
{
"path": "jarca_ledger_mcp/application/account_valuation_service.py",
"sha256": "f7f5bac1e224401dfd02efb321b7e7bd87e02875b727a5366bcc223bf633fb57",
"git_blob": "0dfd7b7ce94a699b855e40b336afa7bf5cdc2595"
},
{
"path": "jarca_ledger_mcp/application/accounts_write_services.py",
"sha256": "41dd6579dc422549316da05347c6d5e33d89da1e1502d6065b5b78a0160776d3",
"git_blob": "48a1dd5acfe782c9cc35e8bd615dc71671dadf6f"
},
{
"path": "tests/test_account_valuation_edge_contracts.py",
"sha256": "fed17aec3518e2380d00b46252402ad69a71e7d265961a6264b03d790d579f42",
"git_blob": "0b08cb32af63084225e01642d333ca291c7445c4"
},
{
"path": "tests/test_issue_12_review59_account.py",
"sha256": "a8a7491c7b78841c2be0dbc66913c1e076bdd231281fd64818493a0f3a1de550",
"git_blob": "f5cee3e993f189e688f8e8ab3829b5c4ab34cf5a"
},
{
"path": "tests/test_valuation_contract_docs.py",
"sha256": "7443b8425d6b733e0e95d21b3c460a42dceeb2f99f84e1fb8fadcd88f6b77514",
"git_blob": "c473ee14a530496e11c28e33008b0d8a50d774cb"
}
],
"added_line_security_scan": {
"hardcoded_secret": [],
"shell_injection": [],
"dynamic_eval": [],
"pickle": [],
"interpolated_sql": []
},
"worktree_clean_after": true,
"limitations": [
"Four external private PDF fixture tests may be skipped when fixtures are unavailable.",
"Gitea status/readiness is not functional acceptance evidence."
],
"changed_files_scope": "Behavior-bearing source, tests, normative docs, and immutable RED evidence at source_head_sha; mutable verification metadata and generated GREEN/manifest artifacts are intentionally excluded from hash binding."
}
@@ -1,61 +0,0 @@
{
"schema": "gitea-agent.evidence-plan/v1",
"owner": "jsfamily",
"repository": "jarca-ledger-mcp",
"issue": 12,
"pull_request": 13,
"review_id": 61,
"reviewed_parent_sha": "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8",
"reviewed_head": "1b9eda683c66d3c7129716b9899eddf61df3362d",
"implementation_parent": "b8b8811a2a50371795a02e4fa65f097c9d9508ac",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"manifest_generator": {
"path": "/opt/data/gitea-agent/.run/i12-r61/run_final_evidence.py",
"execution": "python3 /opt/data/gitea-agent/.run/i12-r61/run_final_evidence.py"
},
"commands": [
{
"id": "conversion-regression",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-q", "-rs", "tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation", "tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_without_valuations_remains_allowed"]
},
{
"id": "review61-regressions",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-q", "-rs", "tests/test_account_valuation_edge_contracts.py", "tests/test_issue_12_review59_account.py", "tests/test_valuation_contract_docs.py"]
},
{
"id": "full-suite",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-q", "-rs"]
},
{
"id": "ruff",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "ruff", "check", "."]
},
{
"id": "compileall",
"argv": ["python3", "-m", "compileall", "-q", "jarca_ledger_mcp", "dashboard", "tests", "scripts"]
},
{
"id": "diff-check",
"argv": ["git", "diff", "--check", "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8..1b9eda683c66d3c7129716b9899eddf61df3362d"]
}
],
"bound_paths": [
"jarca_ledger_mcp/application/account_valuation_service.py",
"jarca_ledger_mcp/application/accounts_write_services.py",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_issue_12_review59_account.py",
"tests/test_valuation_contract_docs.py",
"docs/data-model.md",
"docs/interface-contract.md",
"docs/requirements/req-003_account-valuation-history.md",
"docs/verification/issue-12-review-61/red-evidence.json",
"docs/verification/issue-12-review-61/finding-matrix.json",
"docs/verification/issue-12-review-61/contract-preservation-inventory.json",
"docs/verification/issue-12-review-61/evidence-plan.json"
],
"redaction": "Synthetic fixtures only; no credentials, real account identifiers, financial documents, or environment secrets.",
"limitations": [
"External private PDF fixtures may be skipped when unavailable.",
"Gitea commit status is readiness metadata, not functional acceptance evidence."
]
}
@@ -1,56 +0,0 @@
{
"schema": "gitea-agent.finding-matrix/v1",
"owner": "jsfamily",
"repository": "jarca-ledger-mcp",
"issue": 12,
"pull_request": 13,
"review_id": 61,
"reviewed_parent_sha": "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8",
"reviewed_head": "1b9eda683c66d3c7129716b9899eddf61df3362d",
"implementation_parent": "b8b8811a2a50371795a02e4fa65f097c9d9508ac",
"findings": [
{
"id": "N01-loan-balance-definition",
"classification": "implementation_defect",
"status": "resolved_and_verified_at_source_head",
"root_cause": "Internal candidate writers could persist loan balance evidence without an explicit representative liability definition, and unsupported loan purposes could bypass the definition rule.",
"tests": [
"test_direct_loan_candidate_requires_liability_definition_and_writes_nothing",
"test_import_loan_candidate_requires_liability_definition_and_writes_nothing",
"test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations",
"test_loan_cash_balance_and_total_value_are_rejected_without_writes",
"test_loan_import_preserves_transactions_and_current_cache_without_valuation_candidate"
],
"changed_paths": [
"jarca_ledger_mcp/application/account_valuation_service.py",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_issue_12_review59_account.py",
"docs/data-model.md",
"docs/interface-contract.md",
"docs/requirements/req-003_account-valuation-history.md"
]
},
{
"id": "N01-account-type-conversion-bypass",
"classification": "implementation_defect",
"status": "resolved",
"root_cause": "update_ledger_account allowed a non-loan account with definition-less historical valuations to become a loan account without validating the persisted history.",
"named_regression": "test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"positive_regression": "test_bank_to_loan_conversion_without_valuations_remains_allowed",
"red_evidence": "docs/verification/issue-12-review-61/red-evidence.json",
"minimal_fix": "Reject non-loan to loan conversion before mutation when any attached valuation lacks one of the two explicit valid representative liability definitions.",
"changed_paths": [
"jarca_ledger_mcp/application/accounts_write_services.py",
"tests/test_account_valuation_edge_contracts.py",
"docs/data-model.md",
"docs/interface-contract.md",
"docs/requirements/req-003_account-valuation-history.md"
],
"risks": [
"Do not infer or backfill a liability definition.",
"Do not reject conversion when no valuation history exists.",
"Do not partially mutate account or valuation history on rejection."
]
}
]
}
@@ -1,30 +0,0 @@
{
"schema": "gitea-agent.green-evidence/v1",
"finding": "N01-account-type-conversion-bypass",
"source_commit": "1b9eda683c66d3c7129716b9899eddf61df3362d",
"tests": [
"test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"test_bank_to_loan_conversion_without_valuations_remains_allowed"
],
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"pytest",
"-q",
"-rs",
"tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_without_valuations_remains_allowed"
],
"started_at_utc": "2026-10-07T03:59:49.130624Z",
"finished_at_utc": "2026-10-07T03:59:51.888407Z",
"exit_code": 0,
"expected_exit_code": 0,
"result": "GREEN",
"output": ".. [100%]\n",
"fixture_policy": "synthetic only; no credentials or real financial data"
}
@@ -1,9 +0,0 @@
{
"gate": "independent-bank-to-loan-conversion",
"source_sha": "b8b8811a2a50371795a02e4fa65f097c9d9508ac",
"test": "tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"command": "uv run --frozen --extra dev --extra dashboard pytest tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation -q",
"exit_code": 1,
"result": "RED",
"output": "F [100%]\n=================================== FAILURES ===================================\n_ test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation _\n\n assert rejected[\"success\"] is False\nE assert True is False\n=========================== short test summary info ============================\nFAILED tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation"
}
@@ -1,36 +0,0 @@
{
"schema": "contract-preservation-inventory/v1",
"issue": 12,
"pull_request": 13,
"review_id": 63,
"source_revision": "3db086a7fd15e9152a57fb95ab7942c6dc3be2b1",
"contracts": [
{
"contract": "append-only immutable snapshot runs and corrections",
"preserved_by": ["test_f002_correction_replay_preserves_full_initial_response", "test_f006_initial_replay_survives_multiple_later_corrections", "tests/test_asset_snapshot_idempotency.py"]
},
{
"contract": "preview-to-close authority drift protection",
"preserved_by": ["test_f001_preview_fingerprint_uses_same_unredacted_authority_and_rejects_drift"]
},
{
"contract": "conditional zero-row closes remain visible historical authority",
"preserved_by": ["test_f003_zero_row_conditional_runs_are_timeseries_authority_with_aliases"]
},
{
"contract": "closed consumers use frozen valuation identity rather than live valuation state",
"preserved_by": ["test_f004_closed_report_restores_frozen_valuation_identity_without_legacy_invention"]
},
{
"contract": "NULL and zero remain distinct and balance basis dates require an actual balance",
"preserved_by": ["test_create_rejects_basis_only_without_mutation", "test_basis_only_create_cannot_block_later_dated_import"]
},
{
"contract": "existing Issue 12 behavior outside Review 63 remains intact",
"preserved_by": ["review63-full-suite", "review63-ruff", "review63-compileall", "review63-diff-check"]
}
],
"limitations": [
"Private KB Bank and Toss Securities PDF fixtures were unavailable; four fixture-dependent tests are reported as skips."
]
}
File diff suppressed because one or more lines are too long
@@ -1,62 +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": [
"jarca_ledger_mcp/application/accounts_write_services.py",
"jarca_ledger_mcp/application/assets_service.py",
"tests/test_issue_12_review63_accounts.py",
"tests/test_issue_12_review63_assets.py",
"tests/test_asset_snapshot_idempotency.py",
"tests/test_issue_12_regressions.py",
"docs/interface-contract.md",
"docs/data-model.md",
"docs/requirements/req-003_account-valuation-history.md"
],
"commands": [
{
"id": "review63-regressions",
"purpose": "Verify all named Review 63 RED to GREEN regressions",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-o", "addopts=", "-q", "tests/test_issue_12_review63_accounts.py", "tests/test_issue_12_review63_assets.py"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review63-findings-green"]
},
{
"id": "full-suite",
"purpose": "Run the canonical full repository suite at the functional source revision",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-o", "addopts=", "-ra"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review63-full-suite"]
},
{
"id": "ruff",
"purpose": "Run repository lint checks",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "ruff", "check", "."],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review63-ruff"]
},
{
"id": "compileall",
"purpose": "Compile package, dashboard, tests, and scripts",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "python", "-m", "compileall", "-q", "jarca_ledger_mcp", "dashboard", "tests", "scripts"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review63-compileall"]
},
{
"id": "diff-check",
"purpose": "Check the complete pull-request diff for whitespace errors",
"argv": ["git", "diff", "--check", "main...HEAD"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review63-diff-check"]
}
],
"limitations": [
"Four PDF fixture tests are skipped because private KB Bank and Toss Securities PDF fixtures are unavailable."
]
}
@@ -1,59 +0,0 @@
{
"schema": "review-finding-matrix/v1",
"issue": 12,
"pull_request": 13,
"review_id": 63,
"reviewed_head": "3b5101128d5a2cf038b8fb84facfd9d176928c26",
"source_revision": "3db086a7fd15e9152a57fb95ab7942c6dc3be2b1",
"findings": [
{
"id": "F-001",
"status": "resolved",
"production": "jarca_ledger_mcp/application/assets_service.py",
"regression": "test_f001_preview_fingerprint_uses_same_unredacted_authority_and_rejects_drift",
"claim": "preview and close hash the same private authority representation; real drift is rejected without a run write"
},
{
"id": "F-002",
"status": "resolved",
"production": "jarca_ledger_mcp/application/assets_service.py",
"regressions": [
"test_f002_close_replay_is_byte_for_byte_equal_for_mixed_account_manual_as_of",
"test_f002_correction_replay_preserves_full_initial_response"
],
"claim": "initial and correction replays reconstruct the original immutable response, warning set, provenance, item ordering, and IDs"
},
{
"id": "F-003",
"status": "resolved",
"production": "jarca_ledger_mcp/application/assets_service.py",
"regression": "test_f003_zero_row_conditional_runs_are_timeseries_authority_with_aliases",
"claim": "timeseries enumerates both legacy snapshot and run-only basis dates and exposes the latest zero-row conditional run"
},
{
"id": "F-004",
"status": "resolved",
"production": "jarca_ledger_mcp/application/assets_service.py",
"regression": "test_f004_closed_report_restores_frozen_valuation_identity_without_legacy_invention",
"claim": "closed reports restore persisted valuation identity and currency without consulting live authority or inventing legacy IDs"
},
{
"id": "F-005",
"status": "resolved",
"production": "jarca_ledger_mcp/application/accounts_write_services.py",
"regressions": [
"test_create_rejects_basis_only_without_mutation",
"test_basis_only_create_cannot_block_later_dated_import"
],
"claim": "basis-only creates fail before mutation while zero-with-basis and unknown pairs remain valid"
},
{
"id": "F-006",
"status": "resolved",
"production": "jarca_ledger_mcp/application/assets_service.py",
"regression": "test_f006_initial_replay_survives_multiple_later_corrections",
"claim": "an immutable run can be replayed through the complete later correction chain while unrelated live-authority drift still conflicts"
}
],
"green_evidence": "evidence-manifest.json"
}
@@ -1,74 +0,0 @@
{
"schema": "review-red-evidence/v1",
"issue": 12,
"pull_request": 13,
"review_id": 63,
"reviewed_head": "3b5101128d5a2cf038b8fb84facfd9d176928c26",
"review_state": "REQUEST_CHANGES",
"findings": [
{
"id": "F-001",
"test": "test_f001_preview_fingerprint_uses_same_unredacted_authority_and_rejects_drift",
"red_observation": "unchanged preview fingerprint was rejected with authority_drift"
},
{
"id": "F-002",
"tests": [
"test_f002_close_replay_is_byte_for_byte_equal_for_mixed_account_manual_as_of",
"test_f002_correction_replay_preserves_full_initial_response"
],
"red_observation": "replay responses differed in warnings/provenance/items or conflicted"
},
{
"id": "F-003",
"test": "test_f003_zero_row_conditional_runs_are_timeseries_authority_with_aliases",
"red_observation": "zero-row closed run produced no timeseries point"
},
{
"id": "F-004",
"test": "test_f004_closed_report_restores_frozen_valuation_identity_without_legacy_invention",
"red_observation": "closed report omitted persisted valuation_id and currency"
},
{
"id": "F-005",
"tests": [
"test_create_rejects_basis_only_without_mutation",
"test_basis_only_create_cannot_block_later_dated_import"
],
"red_observation": "basis-only account creation succeeded and could suppress a real dated import balance"
},
{
"id": "F-006",
"test": "test_f006_initial_replay_survives_multiple_later_corrections",
"red_observation": "replaying the initial key first conflicted after two corrections; after that path was enabled, unrelated live-authority drift after the correction chain was incorrectly accepted"
}
],
"red_runs": [
{
"argv": ["pytest", "-q", "tests/test_issue_12_review63_assets.py"],
"exit_code": 1,
"result": "5 failed before production changes",
"limitation": "isolated development environment used by the delegated TDD worker"
},
{
"argv": ["uv", "run", "pytest", "-q", "tests/test_issue_12_review63_accounts.py"],
"exit_code": 1,
"result": "2 failed before production changes"
},
{
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-o", "addopts=", "-q", "tests/test_issue_12_review63_assets.py::test_f002_correction_replay_preserves_full_initial_response"],
"exit_code": 1,
"result": "correction replay omitted existing_snapshot_rows and differed from the original response"
},
{
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-o", "addopts=", "-q", "tests/test_issue_12_review63_assets.py::test_f006_initial_replay_survives_multiple_later_corrections"],
"exit_code": 1,
"result": "initial replay after two later corrections returned idempotency_conflict"
},
{
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-o", "addopts=", "-q", "tests/test_issue_12_review63_assets.py::test_f006_initial_replay_survives_multiple_later_corrections"],
"exit_code": 1,
"result": "unrelated live-authority drift after the correction chain returned success instead of idempotency_conflict"
}
]
}
@@ -1,72 +0,0 @@
{
"schema": "contract-preservation-inventory/v1",
"issue": 12,
"pull_request": 13,
"review_id": 64,
"reviewed_head": "c7a9cfa50d25ac68b6b722b415dc89bea8f2ae24",
"source_revision": "e41db959e4aa0093ab254ec4bb8aafd7dd57aafa",
"contracts": [
{
"contract": "current display cache and historical valuation authority remain separate and an evidence basis date requires a usable integer balance",
"status": "preserved",
"preserved_by": [
"test_empty_balance_with_basis_date_is_rejected_without_mutation_before_import",
"tests/test_import_current_balance_authority.py"
]
},
{
"contract": "actual zero remains valid while empty, whitespace, and non-integer balances fail without mutation",
"status": "preserved",
"preserved_by": [
"tests/test_issue_12_review63_accounts.py",
"tests/test_import_current_balance_authority.py"
]
},
{
"contract": "snapshot runs and corrections are append-only and idempotent replay returns the original immutable public response",
"status": "preserved",
"preserved_by": [
"test_replay_preserves_redacted_unverified_candidates_after_correction",
"test_replay_preserves_blocking_candidate_warning_shape",
"tests/test_issue_12_review63_assets.py"
]
},
{
"contract": "unverified candidate metadata remains redacted and candidate state hashes bind persisted evidence",
"status": "preserved",
"preserved_by": [
"test_replay_preserves_redacted_unverified_candidates_after_correction"
]
},
{
"contract": "malformed immutable evidence fails closed without report, replay, timeseries, or snapshot mutation ambiguity",
"status": "preserved",
"preserved_by": [
"test_malformed_persisted_evidence_fails_closed_without_snapshot_writes"
]
},
{
"contract": "canonical documentation describes actual account, currency, purpose, and basis-date selection together with existing physical index limitations",
"status": "preserved",
"preserved_by": [
"test_data_model_documents_valuation_selection_contract_and_index_mapping"
]
},
{
"contract": "all remaining Issue 12 acceptance behavior and prior Review 63 regressions remain intact",
"status": "preserved",
"preserved_by": [
"review64-focused-regressions",
"review64-full-suite",
"review64-ruff",
"review64-compileall",
"review64-diff-check"
]
}
],
"missing": 0,
"ambiguous": 0,
"limitations": [
"Four fixture-dependent PDF tests remain skipped because private KB Bank and Toss Securities fixtures are unavailable."
]
}
File diff suppressed because one or more lines are too long
@@ -1,64 +0,0 @@
{
"environment": ["os", "kernel", "architecture", "python", "uv"],
"command_env_allowlist": ["PATH", "HOME", "TMPDIR", "LANG", "VIRTUAL_ENV", "UV_CACHE_DIR"],
"secret_env_names": ["GITEA_TOKEN"],
"forbidden_regexes": [],
"hash_paths": [
"jarca_ledger_mcp/application/accounts_write_services.py",
"jarca_ledger_mcp/application/assets_service.py",
"tests/test_import_current_balance_authority.py",
"tests/test_asset_snapshot_idempotency.py",
"tests/test_valuation_contract_docs.py",
"tests/test_issue_12_review63_accounts.py",
"tests/test_issue_12_review63_assets.py",
"tests/test_issue_12_regressions.py",
"docs/data-model.md",
"docs/interface-contract.md",
"docs/requirements/req-003_account-valuation-history.md"
],
"commands": [
{
"id": "review64-focused-regressions",
"purpose": "Run Review 64 and preserved Review 63 regression coverage",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-o", "addopts=", "-q", "tests/test_import_current_balance_authority.py", "tests/test_asset_snapshot_idempotency.py", "tests/test_valuation_contract_docs.py", "tests/test_issue_12_review63_accounts.py", "tests/test_issue_12_review63_assets.py", "tests/test_account_valuation_edge_contracts.py", "tests/test_issue_12_regressions.py"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review64-findings-green"]
},
{
"id": "review64-full-suite",
"purpose": "Run the canonical full repository suite at the functional source revision",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-o", "addopts=", "-q"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review64-full-suite"]
},
{
"id": "review64-ruff",
"purpose": "Run repository lint checks",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "ruff", "check", "."],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review64-ruff"]
},
{
"id": "review64-compileall",
"purpose": "Compile package, dashboard, tests, and scripts",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "python", "-m", "compileall", "-q", "jarca_ledger_mcp", "dashboard", "tests", "scripts"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review64-compileall"]
},
{
"id": "review64-diff-check",
"purpose": "Check the complete pull-request diff for whitespace errors",
"argv": ["git", "diff", "--check", "main...HEAD"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review64-diff-check"]
}
],
"limitations": [
"Four PDF fixture tests are skipped because private KB Bank and Toss Securities PDF fixtures are unavailable."
]
}
@@ -1,47 +0,0 @@
{
"schema": "review-finding-matrix/v1",
"issue": 12,
"pull_request": 13,
"review_id": 64,
"reviewed_head": "c7a9cfa50d25ac68b6b722b415dc89bea8f2ae24",
"source_revision": "e41db959e4aa0093ab254ec4bb8aafd7dd57aafa",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"findings": [
{
"id": "C05-current-import",
"classification": "valid_fix",
"status": "resolved",
"production": "jarca_ledger_mcp/application/accounts_write_services.py",
"regression": "test_empty_balance_with_basis_date_is_rejected_without_mutation_before_import",
"claim": "an empty balance cannot acquire a basis date or block a later valid dated import; zero and valid integer balances remain distinct"
},
{
"id": "N02-close-replay-run-binding",
"classification": "valid_fix",
"status": "resolved",
"production": "jarca_ledger_mcp/application/assets_service.py",
"regressions": [
"test_replay_preserves_redacted_unverified_candidates_after_correction",
"test_replay_preserves_blocking_candidate_warning_shape"
],
"claim": "immutable close replay preserves redacted candidate metadata and exact blocking or nonblocking warning shape without additional writes"
},
{
"id": "D03-data-model",
"classification": "evidence_gap",
"status": "resolved",
"production": "docs/data-model.md",
"regression": "test_data_model_documents_valuation_selection_contract_and_index_mapping",
"claim": "the data model maps actual selection predicates to existing indexes and states their limitations without inventing new DDL"
},
{
"id": "IR-001-malformed-persisted-evidence",
"classification": "valid_fix",
"status": "resolved",
"production": "jarca_ledger_mcp/application/assets_service.py",
"regression": "test_malformed_persisted_evidence_fails_closed_without_snapshot_writes",
"claim": "malformed persisted evidence returns a controlled data_integrity_error from report, replay, and timeseries paths without snapshot writes"
}
],
"green_evidence": "evidence-manifest.json"
}
@@ -1,35 +0,0 @@
{
"schema": "review-red-evidence/v1",
"issue": 12,
"pull_request": 13,
"review_id": 64,
"reviewed_head": "c7a9cfa50d25ac68b6b722b415dc89bea8f2ae24",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"reproductions": [
{
"id": "C05-current-import",
"command": "uv run pytest -q tests/test_import_current_balance_authority.py::test_empty_balance_with_basis_date_is_rejected_without_mutation_before_import",
"exit_code": 1,
"observed": "update_ledger_account accepted an empty balance paired with a basis date, persisted unusable cache authority, and could block a later valid dated import"
},
{
"id": "N02-close-replay-run-binding",
"command": "uv run python synthetic verified-plus-unverified close and replay probe",
"exit_code": 1,
"observed": "replay omitted included_items.unverified_candidates and the nonblocking warning marker, so replay differed from the first immutable response"
},
{
"id": "D03-data-model",
"command": "python -c tests.test_valuation_contract_docs.test_data_model_documents_valuation_selection_contract_and_index_mapping",
"exit_code": 1,
"observed": "the canonical data model did not document select_account_valuation predicates or existing index coverage and limitations"
},
{
"id": "IR-001-malformed-persisted-evidence",
"command": "uv run pytest -o addopts= -q tests/test_asset_snapshot_idempotency.py::test_malformed_persisted_evidence_fails_closed_without_snapshot_writes",
"exit_code": 1,
"observed": "malformed persisted excluded_evidence raised JSONDecodeError instead of a controlled fail-closed response"
}
],
"sensitive_data": "synthetic fixtures only; no credentials or real financial data"
}
@@ -1,58 +0,0 @@
{
"schema": "contract-preservation-inventory/v1",
"issue": 12,
"pull_request": 13,
"review_id": 65,
"reviewed_head": "fc2d2a6faeac3c64c0ed604f2aa1ad7c37c65570",
"source_revision": "8caf1f8379171a128a2c12971a2eadc9a324309a",
"contracts": [
{
"contract": "current cache balance is an exact integer or unknown NULL; zero remains a valid known balance",
"status": "preserved",
"preserved_by": [
"test_create_rejects_whitespace_or_non_integer_dated_balance_without_mutation",
"test_zero_dated_balance_and_valid_import_recovery"
]
},
{
"contract": "invalid dated account creation fails with validation_error before account or valuation mutation",
"status": "preserved",
"preserved_by": [
"test_create_rejects_whitespace_or_non_integer_dated_balance_without_mutation"
]
},
{
"contract": "a valid dated import can establish current cache and append a valuation after invalid create attempts are rejected",
"status": "preserved",
"preserved_by": [
"test_zero_dated_balance_and_valid_import_recovery",
"test_late_import_current_balance_is_authoritative_by_evidence_basis_date"
]
},
{
"contract": "unknown basis remains distinct from last_synced_at and historical imports cannot roll back a newer usable cache",
"status": "preserved",
"preserved_by": [
"test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate",
"test_unknown_legacy_balance_basis_is_not_reported_as_valuation_date",
"review65-focused-regressions"
]
},
{
"contract": "all prior Issue 12 and Review 64 public envelopes, valuation selection, snapshot replay, read-only, migration, privacy, authentication, and menu semantics remain intact",
"status": "preserved",
"preserved_by": [
"review65-focused-regressions",
"review65-full-suite",
"review65-ruff",
"review65-compileall",
"review65-diff-check"
]
}
],
"missing": 0,
"ambiguous": 0,
"limitations": [
"Four fixture-dependent PDF tests remain skipped because private KB Bank and Toss Securities fixtures are unavailable."
]
}
File diff suppressed because one or more lines are too long
@@ -1,62 +0,0 @@
{
"environment": ["os", "kernel", "architecture", "python", "uv"],
"command_env_allowlist": ["PATH", "HOME", "TMPDIR", "LANG", "VIRTUAL_ENV", "UV_CACHE_DIR"],
"secret_env_names": ["GITEA_TOKEN"],
"forbidden_regexes": [],
"hash_paths": [
"jarca_ledger_mcp/application/accounts_write_services.py",
"tests/test_import_current_balance_authority.py",
"tests/test_account_basis_public_consumers.py",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_account_valuations.py",
"tests/test_issue_12_regressions.py",
"tests/test_issue_12_review63_accounts.py",
"docs/data-model.md",
"docs/requirements/req-003_account-valuation-history.md"
],
"commands": [
{
"id": "review65-focused-regressions",
"purpose": "Run Review 65 and preserved account authority regression coverage",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-o", "addopts=", "-q", "tests/test_import_current_balance_authority.py", "tests/test_account_basis_public_consumers.py", "tests/test_account_valuation_edge_contracts.py", "tests/test_account_valuations.py", "tests/test_issue_12_regressions.py", "tests/test_issue_12_review63_accounts.py"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review65-findings-green"]
},
{
"id": "review65-full-suite",
"purpose": "Run the canonical full repository suite at the functional source revision",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-o", "addopts=", "-q"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review65-full-suite"]
},
{
"id": "review65-ruff",
"purpose": "Run repository lint checks",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "ruff", "check", "."],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review65-ruff"]
},
{
"id": "review65-compileall",
"purpose": "Compile package, dashboard, tests, and scripts",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "python", "-m", "compileall", "-q", "jarca_ledger_mcp", "dashboard", "tests", "scripts"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review65-compileall"]
},
{
"id": "review65-diff-check",
"purpose": "Check the complete pull-request diff for whitespace errors",
"argv": ["git", "diff", "--check", "main...HEAD"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review65-diff-check"]
}
],
"limitations": [
"Four PDF fixture tests are skipped because private KB Bank and Toss Securities PDF fixtures are unavailable."
]
}
@@ -1,31 +0,0 @@
{
"schema": "review-finding-matrix/v1",
"issue": 12,
"pull_request": 13,
"review_id": 65,
"reviewed_head": "fc2d2a6faeac3c64c0ed604f2aa1ad7c37c65570",
"source_revision": "8caf1f8379171a128a2c12971a2eadc9a324309a",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"findings": [
{
"id": "C05-current-import-create",
"classification": "valid_fix",
"status": "resolved",
"production": "jarca_ledger_mcp/application/accounts_write_services.py",
"regressions": [
"test_create_rejects_whitespace_or_non_integer_dated_balance_without_mutation",
"test_zero_dated_balance_and_valid_import_recovery"
],
"claim": "account creation rejects unusable dated balances before mutation while preserving zero and later valid dated import authority"
},
{
"id": "IR-001-strict-integer-create",
"classification": "valid_fix",
"status": "resolved",
"production": "jarca_ledger_mcp/application/accounts_write_services.py",
"regression": "test_create_rejects_whitespace_or_non_integer_dated_balance_without_mutation",
"claim": "boolean and floating-point balances are rejected instead of coercing or truncating; canonical signed integer text remains compatible"
}
],
"green_evidence": "evidence-manifest.json"
}
@@ -1,23 +0,0 @@
{
"schema": "review-red-evidence/v1",
"issue": 12,
"pull_request": 13,
"review_id": 65,
"reviewed_head": "fc2d2a6faeac3c64c0ed604f2aa1ad7c37c65570",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"reproductions": [
{
"id": "C05-current-import-create",
"command": "uv run pytest -q tests/test_import_current_balance_authority.py::test_create_rejects_whitespace_or_non_integer_dated_balance_without_mutation tests/test_import_current_balance_authority.py::test_zero_dated_balance_and_valid_import_recovery",
"exit_code": 1,
"observed": "invalid whitespace or non-integer dated balance creation returned success, persisted unusable cache authority, omitted its valuation candidate, and exposed ValueError to a public liquidity consumer"
},
{
"id": "IR-001-strict-integer-create",
"command": "uv run pytest -o addopts= -q tests/test_import_current_balance_authority.py::test_create_rejects_whitespace_or_non_integer_dated_balance_without_mutation",
"exit_code": 1,
"observed": "boolean and floating-point balances were accepted through int coercion and persisted values different from caller input"
}
],
"sensitive_data": "synthetic fixtures only; no credentials or real financial data"
}
@@ -1,59 +0,0 @@
{
"schema": "contract-preservation-inventory/v1",
"issue": 12,
"pull_request": 13,
"review_id": 66,
"reviewed_head": "3bf9b77c6cc167a4e2a8706271bdf8e848d2c4fe",
"source_revision": "31d1c11dcf397ddf6a23e98d6d9062f96bab0194",
"contracts": [
{
"contract": "last_balance is an exact integer or NULL-compatible unknown value; boolean and floating-point coercion are invalid while zero and signed integer text remain valid",
"status": "preserved",
"preserved_by": [
"test_update_rejects_bool_or_float_dated_balance_without_mutation",
"test_update_rejects_empty_undated_balance_without_mutation",
"test_update_preserves_zero_and_signed_integer_balance_semantics"
]
},
{
"contract": "invalid account updates return validation_error before changing account cache or valuation history",
"status": "preserved",
"preserved_by": [
"test_update_rejects_bool_or_float_dated_balance_without_mutation",
"test_update_rejects_empty_undated_balance_without_mutation"
]
},
{
"contract": "a historical dated candidate is append-only and cannot roll back a newer current cache",
"status": "preserved",
"preserved_by": [
"test_historical_account_update_preserves_current_and_adds_one_candidate",
"test_late_import_current_balance_is_authoritative_by_evidence_basis_date"
]
},
{
"contract": "unknown basis remains distinct from last_synced_at and an undated valid balance write creates no dated candidate",
"status": "preserved",
"preserved_by": [
"test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate",
"test_unknown_legacy_balance_basis_is_not_reported_as_valuation_date"
]
},
{
"contract": "Review 65 create validation, actual zero, valid import recovery, public consumer, valuation selection, snapshot replay, read-only, migration, privacy, authentication, and menu semantics remain intact",
"status": "preserved",
"preserved_by": [
"review66-focused-regressions",
"review66-full-suite",
"review66-ruff",
"review66-compileall",
"review66-diff-check"
]
}
],
"missing": 0,
"ambiguous": 0,
"limitations": [
"Four fixture-dependent PDF tests remain skipped because private KB Bank and Toss Securities fixtures are unavailable."
]
}
File diff suppressed because one or more lines are too long
@@ -1,64 +0,0 @@
{
"environment": ["os", "kernel", "architecture", "python", "uv"],
"command_env_allowlist": ["PATH", "HOME", "TMPDIR", "LANG", "VIRTUAL_ENV", "UV_CACHE_DIR"],
"secret_env_names": ["GITEA_TOKEN"],
"forbidden_regexes": [],
"hash_paths": [
"jarca_ledger_mcp/application/accounts_write_services.py",
"jarca_ledger_mcp/application/account_valuation_service.py",
"tests/test_import_current_balance_authority.py",
"tests/test_account_basis_public_consumers.py",
"tests/test_issue_12_review63_accounts.py",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_account_valuations.py",
"tests/test_issue_12_regressions.py",
"docs/data-model.md",
"docs/interface-contract.md",
"docs/requirements/req-003_account-valuation-history.md"
],
"commands": [
{
"id": "review66-focused-regressions",
"purpose": "Run Review 66 and preserved account authority regressions",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-o", "addopts=", "-q", "tests/test_import_current_balance_authority.py", "tests/test_account_basis_public_consumers.py", "tests/test_issue_12_review63_accounts.py", "tests/test_account_valuation_edge_contracts.py", "tests/test_account_valuations.py", "tests/test_issue_12_regressions.py"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review66-findings-green"]
},
{
"id": "review66-full-suite",
"purpose": "Run the canonical full repository suite at the functional source revision",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-o", "addopts=", "-q"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review66-full-suite"]
},
{
"id": "review66-ruff",
"purpose": "Run repository lint checks",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "ruff", "check", "."],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review66-ruff"]
},
{
"id": "review66-compileall",
"purpose": "Compile package, dashboard, tests, and scripts",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "python", "-m", "compileall", "-q", "jarca_ledger_mcp", "dashboard", "tests", "scripts"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review66-compileall"]
},
{
"id": "review66-diff-check",
"purpose": "Check the complete pull-request diff for whitespace errors",
"argv": ["git", "diff", "--check", "main...HEAD"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["review66-diff-check"]
}
],
"limitations": [
"Four PDF fixture tests are skipped because private KB Bank and Toss Securities PDF fixtures are unavailable."
]
}
@@ -1,24 +0,0 @@
{
"schema": "review-finding-matrix/v1",
"issue": 12,
"pull_request": 13,
"review_id": 66,
"reviewed_head": "3bf9b77c6cc167a4e2a8706271bdf8e848d2c4fe",
"source_revision": "31d1c11dcf397ddf6a23e98d6d9062f96bab0194",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"findings": [
{
"id": "C05-account-update-balance-authority",
"classification": "valid_fix",
"status": "resolved",
"production": "jarca_ledger_mcp/application/accounts_write_services.py",
"regressions": [
"test_update_rejects_bool_or_float_dated_balance_without_mutation",
"test_update_rejects_empty_undated_balance_without_mutation",
"test_update_preserves_zero_and_signed_integer_balance_semantics"
],
"claim": "account updates reject boolean, floating-point, empty, whitespace, and non-integer balance values before account or valuation mutation while preserving zero, signed integer text, dated candidates, historical cache authority, and unknown-basis semantics"
}
],
"green_evidence": "evidence-manifest.json"
}
@@ -1,23 +0,0 @@
{
"schema": "review-red-evidence/v1",
"issue": 12,
"pull_request": 13,
"review_id": 66,
"reviewed_head": "3bf9b77c6cc167a4e2a8706271bdf8e848d2c4fe",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"reproductions": [
{
"id": "C05-account-update-balance-authority",
"command": "uv run --frozen --extra dev --extra dashboard pytest -o addopts= -q tests/test_import_current_balance_authority.py::test_update_rejects_bool_or_float_dated_balance_without_mutation tests/test_import_current_balance_authority.py::test_update_rejects_empty_undated_balance_without_mutation",
"exit_code": 1,
"observed": "2 failed: dated bool and float values returned success after coercion and appended candidates; an undated empty string returned success and replaced the integer cache"
},
{
"id": "C05-exact-head-synthetic-probe",
"command": "uv run --frozen --extra dev --extra dashboard python <synthetic temporary-DB update probe>",
"exit_code": 0,
"observed": "True, False, 1.2, and -1.2 were persisted as 1, 0, 1, and -1 with dated candidates; an undated empty string was persisted and cleared the basis; all invalid requests mutated state"
}
],
"sensitive_data": "synthetic temporary databases only; no credentials or real financial data"
}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Jarca Ledger MCP server."""
__version__ = "0.1.4"
__version__ = "0.1.6"
+41
View File
@@ -443,3 +443,44 @@ def main() -> None:
if __name__ == "__main__":
main()
@mcp.tool()
def create_settlement_draft(month: str, created_by: str, idempotency_key: str, basis_date: str = "", memo: str = "", correction_of_run_id: str = "", correction_reason: str = "") -> dict:
return contracts.create_settlement_draft(month, created_by, idempotency_key, basis_date, memo, correction_of_run_id, correction_reason)
@mcp.tool()
def list_settlement_drafts(month: str = "") -> dict:
return contracts.list_settlement_drafts(month)
@mcp.tool()
def get_settlement_draft(draft_id: str) -> dict:
return contracts.get_settlement_draft(draft_id)
@mcp.tool()
def update_settlement_draft(draft_id: str, expected_version: int, updated_by: str, memo: str) -> dict:
return contracts.update_settlement_draft(draft_id, expected_version, updated_by, memo)
@mcp.tool()
def refresh_settlement_draft_targets(draft_id: str, expected_version: int, updated_by: str) -> dict:
return contracts.refresh_settlement_draft_targets(draft_id, expected_version, updated_by)
@mcp.tool()
def set_settlement_draft_item(draft_id: str, source_type: str, source_id: str, expected_version: int, selected_by: str, valuation_id: str = "", selection_mode: str = "exact", selection_reason: str = "", included: bool = True, exclusion_reason: str = "") -> dict:
return contracts.set_settlement_draft_item(draft_id, source_type, source_id, expected_version, selected_by, valuation_id, selection_mode, selection_reason, included, exclusion_reason)
@mcp.tool()
def review_settlement_draft(draft_id: str) -> dict:
return contracts.review_settlement_draft(draft_id)
@mcp.tool()
def approve_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, approved_by: str, approval_reason: str, approval_reference: str, allow_missing_exclusions: bool = False, allow_carried_before: bool = False, allow_manual_substitute: bool = False) -> dict:
return contracts.approve_settlement_draft(draft_id, expected_version, review_fingerprint, approved_by, approval_reason, approval_reference, allow_missing_exclusions, allow_carried_before, allow_manual_substitute)
@mcp.tool()
def finalize_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, idempotency_key: str) -> dict:
return contracts.finalize_settlement_draft(draft_id, expected_version, review_fingerprint, idempotency_key)
@mcp.tool()
def cancel_settlement_draft(draft_id: str, expected_version: int, cancelled_by: str, reason: str) -> dict:
return contracts.cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason)
@@ -1,6 +1,16 @@
from __future__ import annotations
from ...service import (
approve_settlement_draft as approve_settlement_draft,
cancel_settlement_draft as cancel_settlement_draft,
create_settlement_draft as create_settlement_draft,
finalize_settlement_draft as finalize_settlement_draft,
get_settlement_draft as get_settlement_draft,
list_settlement_drafts as list_settlement_drafts,
refresh_settlement_draft_targets as refresh_settlement_draft_targets,
review_settlement_draft as review_settlement_draft,
set_settlement_draft_item as set_settlement_draft_item,
update_settlement_draft as update_settlement_draft,
annotations as annotations,
apply_categorization_rules as apply_categorization_rules,
close_asset_snapshot as close_asset_snapshot,
@@ -74,7 +84,21 @@ from ...service import (
update_manual_asset as update_manual_asset,
)
TOOL_CONTRACTS = {
name: globals()[name]
for name in (
"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
"update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
)
}
__all__ = [
"approve_settlement_draft", "cancel_settlement_draft", "create_settlement_draft",
"finalize_settlement_draft", "get_settlement_draft", "list_settlement_drafts",
"refresh_settlement_draft_targets", "review_settlement_draft",
"set_settlement_draft_item", "update_settlement_draft",
"annotations",
"apply_categorization_rules",
"close_asset_snapshot",
@@ -0,0 +1,59 @@
from __future__ import annotations
import inspect
from ...service import (
approve_settlement_draft as approve_settlement_draft,
cancel_settlement_draft as cancel_settlement_draft,
correct_account_valuation as correct_account_valuation,
create_settlement_draft as create_settlement_draft,
finalize_settlement_draft as finalize_settlement_draft,
get_settlement_draft as get_settlement_draft,
list_account_valuations as list_account_valuations,
list_settlement_drafts as list_settlement_drafts,
refresh_settlement_draft_targets as refresh_settlement_draft_targets,
register_account_valuation as register_account_valuation,
review_settlement_draft as review_settlement_draft,
set_settlement_draft_item as set_settlement_draft_item,
update_settlement_draft as update_settlement_draft,
)
from ...application import settlement_draft_service as _draft_service
from ... import service as _service
def _resolve_db_path() -> str:
from ...db import resolve_db_path
return str(resolve_db_path())
def _guard(db_path: str | None) -> str:
return db_path or _resolve_db_path()
def _wrap(fn):
def call(*args, db_path=None, **kwargs):
target = _draft_service if fn.__name__ in {"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft", "update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft"} else _service
return getattr(target, fn.__name__)(*args, db_path=_guard(db_path), **kwargs)
call.__name__ = fn.__name__
call.__doc__ = fn.__doc__
call.__signature__ = inspect.signature(fn)
return call
# Keep the service signatures unchanged; path resolution/auth is adapter policy.
for _name in (
"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
"update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
):
globals()[_name] = _wrap(getattr(_service, _name))
__all__ = [
"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
"update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
]
+73 -7
View File
@@ -67,6 +67,18 @@ def _public_evidence(item: dict[str, Any], strict: bool = False) -> dict[str, An
"valuation_id", "valuation_kind", "active", "last_synced_at", "last_balance", "valuation_amount", "reason",
"unverified_candidates", "candidate", "candidates", "state_hash", "blocking", "snapshot_run_id", "is_correction",
"correction_of_run_id", "correction_reason",
# Draft-close provenance is intentionally an exact allowlist. Nested
# objects are recursively filtered by these same keys.
"date_difference_days", "direction", "original_recorded_at", "selection_mode",
"date_difference_direction", "recorded_at", "representative_liability_definition",
"allow_missing_exclusions", "allow_carried_before", "allow_manual_substitute",
"reason", "selection_reason", "selected_by", "selected_at", "item_version", "source_ref",
"source_attributes", "approval", "approval_reference", "approved_by",
"approved_at", "approval_reason", "draft_id", "draft_version",
"review_fingerprint", "contains_substitutions", "contains_carried",
"has_approved_missing", "carried_forward", "source_type", "source_id",
"amount", "currency", "valuation_id", "valuation_date", "age_days",
"verification_status", "evidence_kind", "carried_forward",
}
if isinstance(item, dict):
return {key: _public_evidence(value, strict) for key, value in item.items() if key in allowed or (not strict and isinstance(value, (dict, list)))}
@@ -304,6 +316,9 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[
integrity_error = any(value is None for value in evidence.values())
metadata = {
"snapshot_run_id": run["id"],
"draft_id": run["draft_id"] if "draft_id" in run.keys() else "",
"draft_version": run["draft_version"] if "draft_version" in run.keys() else 0,
"review_fingerprint": run["review_fingerprint"] if "review_fingerprint" in run.keys() else "",
"is_correction": bool(run["correction_of_run_id"]),
"correction_of_run_id": run["correction_of_run_id"] or "",
"correction_reason": run["correction_reason"] or "",
@@ -328,6 +343,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()
run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (basis_date,)).fetchone()
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 _snapshot_rows_for_run(conn, run["id"])
@@ -382,10 +423,18 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
"""Expose immutable run evidence without source notes or memos."""
applied = [_public_evidence(item, strict=True) for item in metadata.get("applied_evidence", [])]
excluded = [_public_evidence(item, strict=True) for item in metadata.get("excluded_evidence", [])]
carried = [_public_evidence(item, strict=True) for item in metadata.get("carried_evidence", [])]
contains_substitutions = any(item.get("selection_mode") == "manual_substitute" for item in applied)
contains_carried = bool(carried) or any(item.get("selection_mode") == "carried_before" or item.get("carried_forward") for item in applied)
has_approved_missing = any(item.get("reason") == "approved_missing" for item in excluded)
# Evidence is immutable: do not inject absent draft keys into legacy
# exclusions/carried rows. Draft finalization persists its own lineage.
warnings = []
exclusions = [_public_evidence(item, strict=True) for item in metadata.get("excluded_evidence", [])]
candidate_warnings: dict[tuple[str, str], dict[str, Any]] = {}
for item in exclusions:
for item in excluded:
if item.get("candidate"):
key = (item.get("source_type", ""), item.get("source_id", ""))
warning_shape = {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []}
@@ -398,12 +447,19 @@ def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
warnings.extend(candidate_warnings.values())
return {
"warnings": warnings,
"excluded_items": exclusions,
"carried_items": [_public_evidence(item, strict=True) for item in metadata.get("carried_evidence", [])],
"excluded_items": excluded,
"applied_evidence": applied,
"carried_items": carried,
"contains_substitutions": contains_substitutions,
"contains_carried": contains_carried,
"has_approved_missing": has_approved_missing,
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
"draft_id": metadata.get("draft_id", ""),
"draft_version": metadata.get("draft_version", 0),
"review_fingerprint": metadata.get("review_fingerprint", ""),
"close_status": metadata.get("close_status", ""),
"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")},
}
@@ -949,16 +1005,19 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None,
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"))
legacy_closed = bool(close_metadata.get("legacy_closed"))
snapshots = _snapshot_items(conn, basis) if rows 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["warnings"] = _closed_warnings(close_metadata, basis, rows)
if legacy_closed:
run_public["approval"] = {}
summary = _summarize_valuation_items(snapshots, run_public["excluded_items"])
evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis)
return _ok_with_provenance_aliases(
basis_date=basis,
source="asset_snapshots",
source="legacy_asset_snapshots" if legacy_closed else "asset_snapshots",
is_closed_snapshot=True,
snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
snapshot_count=len(snapshots),
@@ -1075,6 +1134,13 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
"source": "asset_snapshots",
"item_count": len(items),
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
"draft_id": run_public.get("draft_id", ""),
"draft_version": run_public.get("draft_version", 0),
"review_fingerprint": run_public.get("review_fingerprint", ""),
"contains_substitutions": run_public.get("contains_substitutions", False),
"contains_carried": run_public.get("contains_carried", False),
"has_approved_missing": run_public.get("has_approved_missing", False),
"applied_evidence": run_public.get("applied_evidence", []),
"close_status": metadata.get("close_status", ""),
"selection_policy": metadata.get("selection_policy", ""),
"warnings": run_public["warnings"],
@@ -75,6 +75,13 @@ def _source_coverage(
"excluded_items": asset_report.get("excluded_items", []),
"carried_items": asset_report.get("carried_items", asset_report.get("included_items", [])),
"monthly_source_snapshot_id": asset_report.get("snapshot_run_id", ""),
"draft_id": asset_report.get("close_metadata", {}).get("draft_id", ""),
"draft_version": asset_report.get("close_metadata", {}).get("draft_version", 0),
"review_fingerprint": asset_report.get("close_metadata", {}).get("review_fingerprint", ""),
"contains_substitutions": asset_report.get("close_metadata", {}).get("contains_substitutions", False),
"contains_carried": asset_report.get("close_metadata", {}).get("contains_carried", False),
"has_approved_missing": asset_report.get("close_metadata", {}).get("has_approved_missing", False),
"applied_evidence": asset_report.get("close_metadata", {}).get("applied_evidence", []),
},
}
@@ -0,0 +1,396 @@
from __future__ import annotations
import calendar
import hashlib
import json
import sqlite3
from datetime import date
from uuid import uuid4
from .shared import db, read_db, now_iso, ok, fail
def _valid_version(v):
return isinstance(v, int) and not isinstance(v, bool) and v > 0
def _month_end(month):
if not isinstance(month, str) or len(month) != 7:
raise ValueError('month must be YYYY-MM')
y, m = map(int, month.split('-'))
if date(y, m, 1).strftime('%Y-%m') != month:
raise ValueError('month must be YYYY-MM')
return f'{month}-{calendar.monthrange(y,m)[1]:02d}'
def _err(code, message): return fail(code, message)
def _items(conn, draft_id):
rows = conn.execute('SELECT * FROM settlement_draft_items WHERE draft_id=? ORDER BY source_type,source_id', (draft_id,)).fetchall()
return [dict(r) for r in rows]
def _draft(conn, did):
r=conn.execute('SELECT * FROM settlement_drafts WHERE id=?',(did,)).fetchone()
return dict(r) if r else None
def _current_semantics(conn, item):
if item['source_type']=='account':
src=conn.execute('SELECT * FROM accounts WHERE id=?',(item['source_id'],)).fetchone()
val=conn.execute('SELECT * FROM account_valuations WHERE id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None
child=conn.execute('SELECT id FROM account_valuations WHERE supersedes_id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None
return {'source':dict(src) if src else None,'valuation':dict(val) if val else None,'child':dict(child) if child else None}
src=conn.execute('SELECT * FROM manual_assets WHERE id=?',(item['source_id'],)).fetchone()
return {'source':dict(src) if src else None}
def _nonempty(value):
return isinstance(value, str) and bool(value.strip())
def _latest_close(conn, basis):
from .assets_service import _snapshot_rows_for_latest_run
_, metadata = _snapshot_rows_for_latest_run(conn, basis)
return metadata.get('snapshot_run_id', '')
def _freeze(conn, source_type, source_id, valuation_id=''):
return _current_semantics(conn, {'source_type': source_type, 'source_id': source_id, 'valuation_id': valuation_id})
def _review(conn, draft):
from .assets_service import _summarize_valuation_items
stored = _items(conn, draft['id'])
items, included, excluded, missing, warnings, states = [], [], [], [], [], []
for item in stored:
current = _current_semantics(conn, item)
frozen = json.loads(item.get('evidence_json') or '{}')
# No permissive fallback from malformed/absent frozen evidence to live authority.
source = frozen.get('source')
if source is None and item['source_type'] != 'account' and 'valuation_amount' in frozen:
source = frozen # development draft compatibility, not legacy run backfill
source = source or {}
row = {k: v for k, v in item.items() if k != 'evidence_json'}
row['item_version'] = item['version']
row.update(source_type='account' if item['source_type'] == 'account' else 'manual_asset',
included=bool(item['included']), basis_date=draft['basis_date'],
name=source.get('name', ''), currency=(source.get('currency') or 'KRW').upper(),
liquidity_bucket='loan' if source.get('type') == 'loan' else source.get('liquidity_bucket', 'other'),
authority_source='account_valuations' if item['source_type'] == 'account' else 'manual_assets')
row['is_liability'] = row['liquidity_bucket'] == 'loan'
row['state_hash'] = hashlib.sha256(json.dumps(frozen, sort_keys=True).encode()).hexdigest()
source_drift = source != (current.get('source') or {})
value = frozen.get('valuation') if item['source_type'] == 'account' else source
drift = source_drift or (item['source_type'] == 'account' and value != current.get('valuation'))
child = current.get('child')
row.update(drift=drift, review_needed=bool(drift or child), amount=None, candidate_amount=None)
if child:
row['correction_child_id'] = child['id']
warnings.append({'code': 'correction_child', 'source_id': item['source_id']})
if drift:
warnings.append({'code': 'source_drift', 'source_id': item['source_id']})
mode = item['selection_mode']
if value:
actual = value.get('basis_date', '') if item['source_type'] == 'account' else value.get('valuation_date', '')
delta = (date.fromisoformat(actual) - date.fromisoformat(draft['basis_date'])).days if actual else None
candidate = value.get('amount') if item['source_type'] == 'account' else value.get('valuation_amount')
status = value.get('verification_status', '') if item['source_type'] == 'account' else 'verified'
row.update(valuation_date=actual, date_difference_days=delta,
date_difference_direction='after' if delta is not None and delta > 0 else 'before' if delta is not None and delta < 0 else 'same' if delta == 0 else 'unknown',
age_days=-delta if delta is not None else None, candidate_amount=candidate,
verification_status=status, recorded_at=value.get('recorded_at', ''),
source_ref=value.get('source_ref', ''), valuation_kind=value.get('valuation_kind', ''),
representative_liability_definition=value.get('representative_liability_definition'),
carried_forward=mode == 'carried_before', evidence_kind='manual_substitute' if mode == 'manual_substitute' else 'carried_before' if mode == 'carried_before' else 'exact')
eligible_date = delta == 0 if mode == 'exact' else delta is not None and delta < 0 if mode == 'carried_before' else item['source_type'] == 'account' and delta is not None
if status == 'verified' and eligible_date and source.get('active') and row['currency'] == 'KRW' and not row['review_needed']:
row['amount'] = candidate if row['included'] else None
elif item['source_type'] == 'account':
# Candidate visibility does not make it selected or authoritative.
candidate = conn.execute('SELECT amount FROM account_valuations WHERE account_id=? AND basis_date<=? ORDER BY recorded_at DESC,rowid DESC LIMIT 1', (item['source_id'], draft['basis_date'])).fetchone()
if candidate:
row['candidate_amount'] = candidate['amount']
if row['currency'] != 'KRW':
warnings.append({'code': 'non_krw', 'source_id': item['source_id']})
row['status'] = 'excluded' if not row['included'] else 'ready' if row['amount'] is not None else 'missing'
if row['status'] == 'missing':
row['reason'] = 'review_needed' if row['review_needed'] else 'missing_evidence'
missing.append(row)
elif row['status'] == 'excluded':
row['reason'] = row['exclusion_reason']
excluded.append(row)
else:
included.append(row)
items.append(row)
# All candidate changes and source/selected evidence are bound privately; never exposed as raw JSON.
candidates = [dict(v) for v in conn.execute('SELECT * FROM account_valuations WHERE account_id=? ORDER BY id', (item['source_id'],))] if item['source_type'] == 'account' else []
states.append({'stored': item, 'current': current, 'candidates': candidates})
latest = _latest_close(conn, draft['basis_date'])
summary = _summarize_valuation_items(included)
totals = {key: summary[key] for key in ('total_assets', 'total_liabilities', 'net_worth')}
serial = json.dumps({'draft_id': draft['id'], 'version': draft['version'], 'basis_date': draft['basis_date'], 'states': states, 'warnings': warnings, 'latest_run': latest, 'correction_parent': draft['correction_of_run_id'], 'totals': totals}, sort_keys=True, ensure_ascii=False)
return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(),
'items': items, 'included_items': included, 'excluded_items': excluded, 'missing_items': missing, 'warnings': warnings,
'approval_required': True, 'totals': totals, **totals, 'complete': bool(items) and not missing,
'unregistered_liabilities': not any(x['is_liability'] for x in items),
'contains_substitutions': any(x['selection_mode'] == 'manual_substitute' for x in included),
'contains_carried': any(x['selection_mode'] == 'carried_before' for x in included),
'has_approved_missing': bool(missing or excluded)}
def _result(conn,did):
d=_draft(conn,did)
if not d:return _err('not_found','settlement draft not found')
if d['status'] == 'completed' and d['final_response']:
final = json.loads(d['final_response'])
return ok(draft={k: v for k, v in d.items() if k != 'final_response'}, items=final.get('items', []) + final.get('excluded_items', []), final_response=final)
review = _review(conn, d)
return ok(draft={k: v for k, v in d.items() if k != 'final_response'}, items=review['items'], review=review)
def create_settlement_draft(month: str, created_by: str, idempotency_key: str, basis_date: str = '', memo: str = '', correction_of_run_id: str = '', correction_reason: str = '', db_path: str|None=None):
try:
basis=_month_end(month)
if basis_date and basis_date != basis: raise ValueError('basis_date must be month end')
if not _nonempty(created_by) or not _nonempty(idempotency_key): raise ValueError('creator and idempotency key required')
if not all(isinstance(v, str) for v in (basis_date, memo, correction_of_run_id, correction_reason)): raise ValueError('draft metadata must be strings')
if correction_of_run_id and not _nonempty(correction_reason): raise ValueError('correction reason required')
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE')
payload_hash=hashlib.sha256(json.dumps([month,created_by,basis,memo,correction_of_run_id,correction_reason],separators=(',',':')).encode()).hexdigest()
old=c.execute('SELECT * FROM settlement_drafts WHERE create_key=?',(idempotency_key,)).fetchone()
if old:
if old['original_request_hash']!=payload_hash: return _err('conflict','idempotency key payload conflict')
return _result(c,old['id'])
if c.execute("SELECT 1 FROM settlement_drafts WHERE month=? AND status='active'",(month,)).fetchone(): return _err('conflict','active draft already exists for month')
latest = _latest_close(c, basis)
if correction_of_run_id != latest:
return _err('validation_error', 'latest closed run must be linked explicitly with correction reason')
did=uuid4().hex; ts=now_iso()
c.execute('INSERT INTO settlement_drafts(id,month,basis_date,status,version,created_by,created_at,updated_at,memo,create_key,correction_of_run_id,correction_reason,original_request_hash) VALUES(?,?,? ,\'active\',1,?,?,?,?,?,?,?,?)',(did,month,basis,created_by,ts,ts,memo,idempotency_key,correction_of_run_id,correction_reason,payload_hash))
# Freeze initial membership; account selections may use only verified, non-future evidence.
from .assets_service import _account_valuation_items
eligible, _, _ = _account_valuation_items(c, basis, 'exact')
selected = {x['source_id']: x.get('valuation_id', '') for x in eligible}
for a in c.execute("SELECT * FROM accounts WHERE active=1 ORDER BY id").fetchall():
vid = selected.get(a['id'], '')
ev = _freeze(c, 'account', a['id'], vid)
c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'account',a['id'],vid,'exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True)))
for a in c.execute("SELECT * FROM manual_assets WHERE active=1 ORDER BY id").fetchall():
ev = _freeze(c, 'manual_asset', a['id'])
c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'manual_asset',a['id'],'','exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True)))
return _result(c,did)
except (ValueError,TypeError,sqlite3.Error) as e: return _err('validation_error' if isinstance(e,(ValueError,TypeError)) else 'db_error','invalid draft request' if isinstance(e,(ValueError,TypeError)) else 'database error')
def list_settlement_drafts(month: str='',db_path: str|None=None):
try:
with read_db(db_path) as c:
rows=c.execute('SELECT * FROM settlement_drafts'+(' WHERE month=?' if month else '')+' ORDER BY created_at DESC',(month,) if month else ()).fetchall()
return ok(drafts=[dict(x) for x in rows])
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
def get_settlement_draft(draft_id: str,db_path: str|None=None):
try:
with read_db(db_path) as c:return _result(c,draft_id)
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
def _mutate(did, version, actor, sql, params, db_path):
if not _valid_version(version) or not _nonempty(actor):return _err('validation_error','positive integer version and actor required')
try:
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE'); d=_draft(c,did)
if not d:return _err('not_found','draft not found')
if d['status']!='active' or d['version']!=version:return _err('conflict','draft state/version conflict')
c.execute(sql,(*params,did)); c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?",(now_iso(),did))
return _result(c,did)
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
def update_settlement_draft(draft_id: str,expected_version: int,updated_by: str,memo: str,db_path: str|None=None):
if not isinstance(memo, str): return _err('validation_error', 'memo must be a string')
return _mutate(draft_id,expected_version,updated_by,'UPDATE settlement_drafts SET memo=? WHERE id=?',(memo,),db_path)
def refresh_settlement_draft_targets(draft_id: str,expected_version: int,updated_by: str,db_path: str|None=None):
if not _valid_version(expected_version) or not _nonempty(updated_by):
return _err('validation_error', 'version and actor required')
try:
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE')
d = _draft(c, draft_id)
if not d: return _err('not_found', 'draft not found')
if d['status'] != 'active' or d['version'] != expected_version:
return _err('conflict', 'draft state/version conflict')
ts = now_iso()
from .assets_service import _account_valuation_items
eligible, _, _ = _account_valuation_items(c, d['basis_date'], 'exact')
selected = {x['source_id']: x.get('valuation_id', '') for x in eligible}
existing = {(x['source_type'], x['source_id']): x for x in _items(c, draft_id)}
active = set()
for typ, table in [('account', 'accounts'), ('manual_asset', 'manual_assets')]:
for source in c.execute(f'SELECT id FROM {table} WHERE active=1 ORDER BY id').fetchall():
key = (typ, source['id'])
active.add(key)
if key not in existing:
vid = selected.get(source['id'], '') if typ == 'account' else ''
ev = _freeze(c, typ, source['id'], vid)
c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,1,1,?,?,?)',
(uuid4().hex, draft_id, typ, source['id'], vid, 'exact', updated_by, ts, json.dumps(ev, sort_keys=True)))
elif typ == 'manual_asset':
# This explicit action can refresh manual values; it must never
# silently replace an account's selected valuation with a child.
ev = _freeze(c, typ, source['id'])
c.execute('UPDATE settlement_draft_items SET evidence_json=?,version=version+1,selected_by=?,selected_at=? WHERE id=?',
(json.dumps(ev, sort_keys=True), updated_by, ts, existing[key]['id']))
for key, item in existing.items():
if key not in active:
# Preserve the retired row and frozen evidence as an audited exclusion.
c.execute("UPDATE settlement_draft_items SET included=0,exclusion_reason='target retired by explicit refresh',version=version+1,selected_by=?,selected_at=? WHERE id=?",
(updated_by, ts, item['id']))
c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?", (ts, draft_id))
return _result(c, draft_id)
except sqlite3.Error: return _err('db_error', 'database error')
except (TypeError, ValueError): return _err('validation_error', 'invalid persisted draft evidence')
def set_settlement_draft_item(draft_id: str,source_type: str,source_id: str,expected_version: int,selected_by: str,valuation_id: str='',selection_mode: str='exact',selection_reason: str='',included: bool=True,exclusion_reason: str='',db_path: str|None=None):
source_type = 'manual_asset' if source_type == 'manual' else source_type
if type(included) is not bool or source_type not in ('account', 'manual_asset') or selection_mode not in ('exact', 'carried_before', 'manual_substitute'):
return _err('validation_error', 'invalid item fields')
if not _valid_version(expected_version) or not _nonempty(selected_by) or not _nonempty(source_id) or not isinstance(valuation_id, str) or not isinstance(selection_reason, str) or not isinstance(exclusion_reason, str):
return _err('validation_error', 'valid version, source and actor required')
if not included and not _nonempty(exclusion_reason): return _err('validation_error', 'exclusion reason required')
if selection_mode in ('carried_before', 'manual_substitute') and not _nonempty(selection_reason): return _err('validation_error', 'selection reason required')
try:
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE')
d = _draft(c, draft_id)
if not d: return _err('not_found', 'draft not found')
if d['status'] != 'active' or d['version'] != expected_version: return _err('conflict', 'draft state/version conflict')
item = c.execute('SELECT * FROM settlement_draft_items WHERE draft_id=? AND source_type=? AND source_id=?', (draft_id, source_type, source_id)).fetchone()
if not item: return _err('validation_error', 'source is not a draft target; refresh targets first')
ev = _freeze(c, source_type, source_id, valuation_id)
source = ev.get('source')
if included and (not source or not source.get('active')): return _err('validation_error', 'included target must be active')
if source_type == 'account' and valuation_id:
v = ev.get('valuation')
if not v or v['account_id'] != source_id: return _err('validation_error', 'valuation does not belong to account')
if v['currency'] != source['currency']: return _err('validation_error', 'valuation currency mismatch')
kind, loan = v['valuation_kind'], source['type'] == 'loan'
valid_kind = kind in ('loan', 'loan_balance', 'balance') if loan else kind in ('total_value', 'cash_balance', 'balance') if source['type'] == 'investment' else kind in ('balance', 'cash_balance')
if not valid_kind or (loan and v['representative_liability_definition'] not in ('principal_only', 'principal_interest_fees')) or (not loan and v['representative_liability_definition']):
return _err('validation_error', 'valuation purpose or representative liability is invalid')
status = v['verification_status']
if status not in ('verified', 'unverified') or (status != 'verified' and selection_mode != 'manual_substitute'):
return _err('validation_error', 'selection requires confirmed evidence except review-only manual substitute')
delta = (date.fromisoformat(v['basis_date']) - date.fromisoformat(d['basis_date'])).days
if selection_mode == 'exact' and delta != 0: return _err('validation_error', 'exact requires basis-date valuation')
if selection_mode == 'carried_before' and delta >= 0: return _err('validation_error', 'carried_before requires earlier valuation')
if ev.get('child') and included: return _err('conflict', 'select corrected child explicitly or exclude target')
elif source_type == 'account':
if selection_mode != 'exact': return _err('validation_error', 'an explicit valuation ID is required')
else:
if valuation_id or selection_mode == 'manual_substitute': return _err('validation_error', 'manual asset supports exact or carried_before only')
if included and source.get('valuation_date'):
delta = (date.fromisoformat(source['valuation_date']) - date.fromisoformat(d['basis_date'])).days
if (selection_mode == 'exact' and delta != 0) or (selection_mode == 'carried_before' and delta >= 0): return _err('validation_error', 'manual evidence date does not match selection mode')
ts = now_iso()
c.execute('UPDATE settlement_draft_items SET valuation_id=?,selection_mode=?,selection_reason=?,included=?,exclusion_reason=?,selected_by=?,selected_at=?,version=version+1,evidence_json=? WHERE id=?', (valuation_id, selection_mode, selection_reason, int(included), exclusion_reason, selected_by, ts, json.dumps(ev, sort_keys=True), item['id']))
c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?", (ts, draft_id))
return _result(c, draft_id)
except (ValueError, TypeError): return _err('validation_error', 'invalid selection evidence')
except sqlite3.Error: return _err('db_error', 'database error')
def review_settlement_draft(draft_id: str,db_path: str|None=None):
try:
with read_db(db_path) as c:
d=_draft(c,draft_id)
if not d:return _err('not_found','draft not found')
return ok(**_review(c,d))
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
def approve_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,approved_by: str,approval_reason: str,approval_reference: str,allow_missing_exclusions: bool=False,allow_carried_before: bool=False,allow_manual_substitute: bool=False,db_path: str|None=None):
if not _valid_version(expected_version) or not all(_nonempty(x) for x in (approved_by, approval_reason, approval_reference, review_fingerprint)):return _err('validation_error','approval metadata required')
if any(type(x) is not bool for x in (allow_missing_exclusions, allow_carried_before, allow_manual_substitute)): return _err('validation_error', 'approval flags must be booleans')
try:
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE'); d=_draft(c,draft_id)
if not d or d['version']!=expected_version or d['status']!='active':return _err('conflict','draft version/state conflict')
r=_review(c,d)
if r['review_fingerprint']!=review_fingerprint:return _err('conflict','review fingerprint changed')
if any(x['included'] and x.get('review_needed') for x in r['items']): return _err('conflict', 'changed evidence requires explicit selection or exclusion')
if r['has_approved_missing'] and not allow_missing_exclusions:return _err('validation_error','missing/excluded items require approval')
if r['contains_carried'] and not allow_carried_before:return _err('validation_error','carried values require approval')
if r['contains_substitutions'] and not allow_manual_substitute:return _err('validation_error','substitutes require approval')
flags=json.dumps({'allow_missing_exclusions':allow_missing_exclusions,'allow_carried_before':allow_carried_before,'allow_manual_substitute':allow_manual_substitute})
c.execute('UPDATE settlement_drafts SET approval_fingerprint=?,approval_version=?,approved_by=?,approved_at=?,approval_reason=?,approval_reference=?,approval_flags=? WHERE id=?',(review_fingerprint,expected_version,approved_by,now_iso(),approval_reason,approval_reference,flags,draft_id))
return _result(c,draft_id)
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,idempotency_key: str,db_path: str|None=None):
if not _valid_version(expected_version) or not _nonempty(idempotency_key) or not _nonempty(review_fingerprint):return _err('validation_error','version and close key required')
try:
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE')
previous=c.execute('SELECT id,request_fingerprint FROM asset_snapshot_runs WHERE idempotency_key=?',(idempotency_key,)).fetchone()
d=_draft(c,draft_id)
req=hashlib.sha256(f'{draft_id}:{expected_version}:{review_fingerprint}'.encode()).hexdigest()
if previous:
if previous['request_fingerprint']!=req:return _err('conflict','idempotency key payload conflict')
saved=c.execute('SELECT final_response FROM settlement_drafts WHERE id=?',(draft_id,)).fetchone()
if saved and saved[0]:return json.loads(saved[0])
return _err('conflict','persisted replay unavailable')
if not d or d['status']!='active' or d['version']!=expected_version:return _err('conflict','draft state/version conflict')
r=_review(c,d)
if not r['items']: return _err('validation_error', 'an empty draft cannot create a close run')
if any(x['included'] and x.get('review_needed') for x in r['items']): return _err('conflict', 'changed evidence requires explicit selection or exclusion')
if _latest_close(c, d['basis_date']) != d['correction_of_run_id']: return _err('conflict', 'correction parent changed')
if r['review_fingerprint']!=review_fingerprint or d['approval_fingerprint']!=review_fingerprint or d['approval_version']!=expected_version:return _err('conflict','review/approval stale')
flags=json.loads(d['approval_flags'] or '{}')
if r['missing_items'] and not flags.get('allow_missing_exclusions'):return _err('validation_error','missing items not approved')
if r['contains_substitutions'] and not flags.get('allow_manual_substitute'):return _err('validation_error','substitutes not approved')
if r['contains_carried'] and not flags.get('allow_carried_before'):return _err('validation_error','carried items not approved')
from .assets_service import _canonical_provenance, _evidence_provenance, _ok_with_provenance_aliases, _summarize_valuation_items
approval = {k: d[k] for k in ('approved_by', 'approved_at', 'approval_reason', 'approval_reference')}
approval.update(flags)
correction = {'is_correction': bool(d['correction_of_run_id']), 'correction_of_run_id': d['correction_of_run_id'], 'correction_reason': d['correction_reason']}
applied = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval} for x in r['included_items']]
excluded = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval, 'reason': 'approved_missing', 'exclusion_reason': x.get('exclusion_reason') or x.get('reason', 'missing_evidence')} for x in r['excluded_items'] + r['missing_items']]
carried = [x for x in applied if x['selection_mode'] == 'carried_before']
run=uuid4().hex; ts=now_iso(); basis=d['basis_date']; status='conditional' if excluded or r['contains_substitutions'] or r['contains_carried'] else 'complete_exact'
c.execute('INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at,draft_id,draft_version,review_fingerprint) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(run,basis,status,'exact',idempotency_key,d['approved_by'],d['approved_at'],d['approval_reason'],d['approval_reference'],d['correction_of_run_id'],d['correction_reason'],json.dumps(applied,sort_keys=True),json.dumps(excluded,sort_keys=True),json.dumps(carried,sort_keys=True),req,ts,draft_id,expected_version,review_fingerprint))
snapshot_ids = []
for x in applied + excluded:
sid = uuid4().hex
snapshot_ids.append(sid)
c.execute('INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(sid,basis,x['source_type'],x['source_id'],x.get('name',''),x['amount'],x['currency'],x['liquidity_bucket'],x['selection_mode'],'','',run,d['correction_of_run_id'],d['correction_reason'],int(bool(d['correction_of_run_id'])),ts,ts,x.get('valuation_id') or '',x['selection_mode'],x.get('verification_status','unverified'),'excluded' if x in excluded else 'applied',x.get('exclusion_reason','')))
evidence = _evidence_provenance(applied, basis)
response = _ok_with_provenance_aliases(
draft_id=draft_id, draft_version=expected_version, review_fingerprint=review_fingerprint,
snapshot_run_id=run, run_id=run, basis_date=basis, close_status=status, closed_at=ts,
source='snapshot', is_closed_snapshot=True, selection_policy='exact',
inserted_count=len(snapshot_ids), inserted_snapshot_ids=snapshot_ids,
items=applied, excluded_items=excluded, totals=r['totals'],
contains_substitutions=r['contains_substitutions'], contains_carried=r['contains_carried'], has_approved_missing=bool(excluded),
settlement_label='대체 평가자료를 포함한 월정산' if r['contains_substitutions'] else '조건부 월정산' if status == 'conditional' else '정확한 월말 평가 월정산',
warnings=r['warnings'], carried_items=carried, approval=approval, correction=correction, **correction,
provenance=_canonical_provenance(valuation_date=evidence['valuation_date'], age_days=evidence['age_days'], carried=bool(carried), verification_status=evidence['verification_status'], evidence_kind=evidence['evidence_kind'], basis_date=basis, snapshot_run_id=run, close_status=status, selection_policy='exact', warnings=r['warnings'], exclusions=excluded, approval=approval, correction=correction),
**{k: v for k, v in _summarize_valuation_items(applied, excluded).items() if k != 'excluded_items'},
)
c.execute("UPDATE settlement_drafts SET status='completed',version=version+1,final_run_id=?,final_response=?,updated_at=? WHERE id=?",(run,json.dumps(response),ts,draft_id))
return response
except sqlite3.Error:return _err('db_error','database error')
except (TypeError, ValueError):return _err('validation_error','invalid persisted draft evidence')
def cancel_settlement_draft(draft_id: str,expected_version: int,cancelled_by: str,reason: str,db_path: str|None=None):
if not _nonempty(reason):return _err('validation_error','cancellation reason required')
return _mutate(draft_id,expected_version,cancelled_by,"UPDATE settlement_drafts SET status='cancelled',cancelled_by=?,cancellation_reason=?,cancelled_at=? WHERE id=?",(cancelled_by,reason,now_iso()),db_path)
@@ -18,6 +18,31 @@ def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str
def migrate_db(conn: sqlite3.Connection) -> None:
"""Apply additive, idempotent migrations only."""
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_drafts (
id TEXT PRIMARY KEY, month TEXT NOT NULL, basis_date TEXT NOT NULL,
status TEXT NOT NULL, version INTEGER NOT NULL, created_by TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, memo TEXT NOT NULL DEFAULT '',
create_key TEXT NOT NULL UNIQUE, correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '', final_run_id TEXT NOT NULL DEFAULT '',
final_response TEXT NOT NULL DEFAULT '', cancelled_by TEXT NOT NULL DEFAULT '',
cancellation_reason TEXT NOT NULL DEFAULT '', cancelled_at TEXT NOT NULL DEFAULT '',
approval_fingerprint TEXT NOT NULL DEFAULT '', approval_version INTEGER NOT NULL DEFAULT 0,
approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
approval_flags TEXT NOT NULL DEFAULT '{}'
)""")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_settlement_drafts_active_month ON settlement_drafts(month) WHERE status='active'")
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_drafts_month ON settlement_drafts(month,created_at)")
_add_missing_columns(conn, "settlement_drafts", {"original_request_hash": "TEXT NOT NULL DEFAULT ''"})
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_draft_items (
id TEXT PRIMARY KEY, draft_id TEXT NOT NULL REFERENCES settlement_drafts(id),
source_type TEXT NOT NULL, source_id TEXT NOT NULL, valuation_id TEXT NOT NULL DEFAULT '',
selection_mode TEXT NOT NULL DEFAULT 'exact', selection_reason TEXT NOT NULL DEFAULT '',
included INTEGER NOT NULL DEFAULT 1, exclusion_reason TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1, selected_by TEXT NOT NULL DEFAULT '',
selected_at TEXT NOT NULL DEFAULT '', evidence_json TEXT NOT NULL DEFAULT '{}',
UNIQUE(draft_id,source_type,source_id)
)""")
_add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS)
_add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS)
conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)")
@@ -116,7 +141,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]',
carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
)""")
_add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''"})
_add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''", "draft_id": "TEXT NOT NULL DEFAULT ''", "draft_version": "INTEGER NOT NULL DEFAULT 0", "review_fingerprint": "TEXT NOT NULL DEFAULT ''"})
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''")
@@ -1,6 +1,6 @@
from __future__ import annotations
SCHEMA_VERSION = "12"
SCHEMA_VERSION = "13"
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
+2
View File
@@ -14,6 +14,7 @@ from .application import cross_transfer_service as _cross_transfer_service
from .application import imports_service as _imports_service
from .application import import_transfer_services as _import_transfer_services
from .application import monthly_settlement_service as _monthly_settlement_service
from .application import settlement_draft_service as _settlement_draft_service
from .application import reconciliation_service as _reconciliation_service
from .application import review_decision_service as _review_decision_service
from .application import shared as _shared
@@ -38,6 +39,7 @@ _PROXY_MODULES = (
_reconciliation_service,
_review_decision_service,
_monthly_settlement_service,
_settlement_draft_service,
_imports_service,
_import_transfer_services,
_cross_transfer_service,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jarca-ledger-mcp"
version = "0.1.4"
version = "0.1.6"
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -17,7 +17,7 @@ def test_account_valuation_schema_is_v12_and_idempotent(tmp_path):
with sqlite3.connect(path) as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(account_valuations)")}
version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0]
assert version == "12"
assert version == "13"
assert {"id", "account_id", "basis_date", "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "recorded_at", "memo", "idempotency_key", "supersedes_id", "correction_reason", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition"} <= cols
+1 -1
View File
@@ -122,7 +122,7 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone()
indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")}
assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "12"
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "13"
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
+1 -1
View File
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
def test_ledger_category_create_list_delete_reactivate(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
health = service.health_check(db_path)
assert health["schema_version"] == "12"
assert health["schema_version"] == "13"
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
assert created["success"] is True
+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
+288
View File
@@ -0,0 +1,288 @@
from __future__ import annotations
"""Acceptance contract for the approved monthly settlement draft lifecycle.
These tests intentionally resolve the API at runtime: before implementation the
suite must fail as an assertion (RED), not fail during pytest collection.
All data is synthetic and each test owns a temporary SQLite database.
"""
import json
import sqlite3
from concurrent.futures import ThreadPoolExecutor
import pytest
from jarca_ledger_mcp import service
from jarca_ledger_mcp.db import init_db
API = (
"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
"update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft",
"cancel_settlement_draft",
)
def api(name):
# Deliberately runtime checked so absent API produces a clear RED assertion.
fn = getattr(service, name, None)
assert callable(fn), f"required settlement-draft API missing: service.{name}"
return fn
def dbfile(tmp_path):
path = str(tmp_path / "synthetic-ledger.sqlite3")
init_db(path)
return path
def create(path, month="2026-09", key="create-1", **kwargs):
return api("create_settlement_draft")(month, "tester", key, db_path=path, **kwargs)
def assert_ok(result):
assert result.get("success") is True, result
return result
def draft_of(result):
assert isinstance(result.get("draft"), dict), result
return result["draft"]
def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tmp_path):
path = dbfile(tmp_path)
result = assert_ok(create(path))
draft = draft_of(result)
assert draft["month"] == "2026-09" and draft["basis_date"] == "2026-09-30"
assert draft["status"] == "active" and draft["version"] == 1
assert result["items"] == []
assert isinstance(result["review"], dict)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
fetched = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert fetched["draft"]["id"] == draft["id"]
listed = assert_ok(api("list_settlement_drafts")(month="2026-09", db_path=path))
assert any(row.get("id") == draft["id"] for row in listed.get("drafts", []))
@pytest.mark.parametrize("month", ["", "2026-9", "2026-13", "2026/09", "2026-09-30", " 2026-09"])
def test_month_and_basis_are_strictly_validated(tmp_path, month):
path = dbfile(tmp_path)
result = api("create_settlement_draft")(month, "tester", "strict", db_path=path)
assert result.get("success") is False
assert result.get("error_code") in {"validation_error", "invalid_argument"}
def test_basis_must_be_calendar_month_end_and_create_key_is_idempotent(tmp_path):
path = dbfile(tmp_path)
bad = api("create_settlement_draft")("2026-09", "tester", "bad-date", basis_date="2026-09-29", db_path=path)
assert bad.get("success") is False
first = assert_ok(create(path, key="same-key", memo="first"))
replay = assert_ok(create(path, key="same-key", memo="first"))
assert draft_of(first)["id"] == draft_of(replay)["id"]
conflict = create(path, key="same-key", memo="different payload")
assert conflict.get("success") is False
def test_one_active_draft_across_normal_and_correction_and_cancellation_reopens_month(tmp_path):
path = dbfile(tmp_path)
first = draft_of(assert_ok(create(path, key="one")))
duplicate = create(path, key="two")
assert duplicate.get("success") is False
assert api("cancel_settlement_draft")(first["id"], first["version"], "tester", "test cancellation", db_path=path).get("success") is True
reopened = draft_of(assert_ok(create(path, key="three")))
assert reopened["id"] != first["id"]
assert reopened["correction_of_run_id"] in (None, "")
def test_input_validation_versions_actors_and_include_exclude_reason(tmp_path):
path = dbfile(tmp_path)
draft = draft_of(assert_ok(create(path)))
update = api("update_settlement_draft")
for bad_version in (0, -1, True, 1.5, None):
result = update(draft["id"], bad_version, "tester", "memo", db_path=path)
assert result.get("success") is False
for actor in ("", " "):
result = update(draft["id"], 1, actor, "memo", db_path=path)
assert result.get("success") is False
assert api("refresh_settlement_draft_targets")(draft["id"], 1, "tester", db_path=path).get("success") is True
current = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))["draft"]
assert current["version"] >= 2
def test_targets_are_frozen_until_explicit_refresh_and_review_is_read_only(tmp_path):
path = dbfile(tmp_path)
acct = service.create_ledger_account("Synthetic cash", last_balance=10, liquidity_bucket="cash", db_path=path)
assert acct["success"]
draft = draft_of(assert_ok(create(path)))
before = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
service.create_ledger_account("Added later", last_balance=20, liquidity_bucket="cash", db_path=path)
with sqlite3.connect(path) as conn:
snapshot = conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] if conn.execute("SELECT 1 FROM sqlite_master WHERE name='settlement_draft_items'").fetchone() else None
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert {"version", "review_fingerprint", "items", "included_items", "excluded_items", "missing_items", "warnings", "approval_required", "totals"} <= review.keys()
assert {"total_assets", "total_liabilities", "net_worth"} <= review["totals"].keys()
assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert before["draft"]["version"] == draft["version"]
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
if snapshot is not None:
assert conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] == snapshot
def test_explicit_future_substitute_and_late_recorded_exact_are_distinguished(tmp_path):
path = dbfile(tmp_path)
a = service.create_ledger_account("Future account", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
b = service.create_ledger_account("Late exact", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
future = service.register_account_valuation(a, "2026-10-05", 500, valuation_kind="total_value", verification_status="verified", idempotency_key="future", db_path=path)
late = service.register_account_valuation(b, "2026-09-30", 700, valuation_kind="total_value", verification_status="verified", idempotency_key="late-exact", db_path=path)
assert late.get("success")
with sqlite3.connect(path) as conn:
conn.execute("UPDATE account_valuations SET recorded_at=? WHERE id=?", ("2026-10-08T00:00:00Z", late["valuation"]["id"]))
d = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
rows = review["items"]
auto_future = [x for x in rows if x.get("source_id") == a]
assert not any(x.get("valuation_id") == future.get("valuation", {}).get("id") and x.get("selection_mode") == "exact" for x in auto_future)
item = next(x for x in rows if x.get("source_id") == b)
assert item.get("valuation_id") == late["valuation"]["id"]
assert item.get("selection_mode", "exact") == "exact"
assert api("set_settlement_draft_item")(d["id"], "account", a, d["version"], "tester", valuation_id=future["valuation"]["id"], selection_mode="manual_substitute", selection_reason="synthetic future evidence", db_path=path).get("success") is True
revised = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
selected = next(x for x in revised["items"] if x.get("source_id") == a)
assert selected.get("valuation_date") == "2026-10-05"
assert selected.get("date_difference_days") == 5
assert selected.get("selection_mode") == "manual_substitute"
def test_unverified_candidate_has_no_authority_and_verified_child_requires_reselection(tmp_path):
path = dbfile(tmp_path)
acct = service.create_ledger_account("Unverified", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
unv = service.register_account_valuation(acct, "2026-09-30", 900, valuation_kind="total_value", verification_status="unverified", idempotency_key="unv", db_path=path)
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
row = next(x for x in review["items"] if x.get("source_id") == acct)
assert row.get("amount") is None
assert row.get("candidate_amount") == 900
child = service.correct_account_valuation(unv["valuation"]["id"], amount=950, verification_status="verified", correction_reason="synthetic verification", idempotency_key="verified-child", db_path=path)
assert child.get("success")
changed = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert any(x.get("source_id") == acct for x in changed["missing_items"])
assert api("set_settlement_draft_item")(draft["id"], "account", acct, draft["version"], "tester", valuation_id=child["valuation"]["id"], selection_mode="exact", db_path=path).get("success") is True
def test_manual_sources_drift_requires_explicit_refresh_and_future_not_auto_selected(tmp_path):
path = dbfile(tmp_path)
manual = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path)["manual_asset"]
future = service.create_manual_asset("Future asset", 200, "2026-10-07", "deposit", db_path=path)["manual_asset"]
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert not any(x.get("source_id") == future["id"] and x.get("amount") is not None for x in review["items"])
service.update_manual_asset(manual["id"], valuation_amount=110, db_path=path)
drift = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert any("drift" in json.dumps(x).lower() or x.get("review_needed") for x in drift["items"])
refreshed = api("refresh_settlement_draft_targets")(draft["id"], draft["version"], "tester", db_path=path)
assert refreshed.get("success") is True
def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_path):
path = dbfile(tmp_path)
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "reviewed synthetic evidence", "ref-1", allow_missing_exclusions=True, db_path=path)
assert approved.get("success") is True
mutated = api("update_settlement_draft")(draft["id"], draft["version"], "tester", "changed after approval", db_path=path)
assert mutated.get("success") is True
stale = api("finalize_settlement_draft")(draft["id"], mutated["draft"]["version"], review["review_fingerprint"], "close-key", db_path=path)
assert stale.get("success") is False
assert stale.get("error_code") in {"conflict", "validation_error"}
def test_finalize_persists_immutable_provenance_replays_exact_response_and_conflicts_key(tmp_path):
path = dbfile(tmp_path)
assert_ok(service.create_ledger_account("Synthetic cash", last_balance=321, liquidity_bucket="cash", db_path=path))
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
approval = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "approval-1", allow_missing_exclusions=True, db_path=path)
assert approval.get("success")
final = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
assert final.get("success")
replay = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
assert replay == final
conflict = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
assert conflict == final
with sqlite3.connect(path) as conn:
run_count = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
row_count = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
assert run_count == 1 and row_count >= 1
assert final["draft_id"] == draft["id"]
assert final["draft_version"] == draft["version"]
assert final["review_fingerprint"] == review["review_fingerprint"]
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
series = service.get_net_worth_timeseries(start_date="2026-09-01", end_date="2026-09-30", db_path=path)
monthly = service.get_monthly_settlement_summary("2026-09", db_path=path)
assert report["success"] and series["success"] and monthly["success"]
assert report["close_metadata"].get("draft_id") == draft["id"]
def test_sqlite_trigger_failure_rolls_back_run_rows_and_completion(tmp_path):
path = dbfile(tmp_path)
assert_ok(service.create_ledger_account("Synthetic", last_balance=5, liquidity_bucket="cash", db_path=path))
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "ref", allow_missing_exclusions=True, db_path=path)
assert approved.get("success")
with sqlite3.connect(path) as conn:
conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
result = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "failure-key", db_path=path)
assert result.get("success") is False
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
after = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert after["draft"]["status"] == "active"
def test_schema13_migration_is_additive_and_idempotent(tmp_path):
path = dbfile(tmp_path)
with sqlite3.connect(path) as conn:
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-08-31','complete_exact','2026-09-01T00:00:00Z')")
conn.commit()
init_db(path)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT id FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()[0] == 'legacy-run'
assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','asset_snapshots'}
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '13'
def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
path = dbfile(tmp_path)
fn = api("create_settlement_draft")
def attempt(i):
return fn("2026-09", "tester", f"concurrent-{i}", db_path=path)
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(attempt, range(4)))
successes = [r for r in results if r.get("success")]
assert len(successes) == 1
assert all(r.get("success") or r.get("error_code") in {"conflict", "validation_error"} for r in results)
d = draft_of(successes[0])
review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
approved = api("approve_settlement_draft")(d["id"], d["version"], review["review_fingerprint"], "approver", "approved", "r", allow_missing_exclusions=True, db_path=path)
assert approved.get("success")
stale = api("finalize_settlement_draft")(d["id"], d["version"] + 1, review["review_fingerprint"], "stale", db_path=path)
assert stale.get("success") is False
def test_approval_is_mandatory_even_for_complete_exact_evidence(tmp_path):
path = dbfile(tmp_path)
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
finalized = api("finalize_settlement_draft")(
draft["id"], draft["version"], review["review_fingerprint"], "unapproved",
db_path=path,
)
assert finalized.get("success") is False
+13 -9
View File
@@ -21,31 +21,35 @@ def test_requirement_docs_are_canonical_and_traceable() -> None:
"schema": "gitea-agent.requirement/v1",
"id": "REQ-003",
"title": "계좌 기준일 평가 이력과 자산 마감 연동",
"revision": "1.0",
"revision": "1.1",
"status": "approved",
"depends_on": [],
"applies_to": ["ledger", "dashboard", "mcp"],
"language": "ko",
"issue": "#12",
"implementation_pr": "#13",
"issue": "#15",
"implementation_pr": "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16",
}
assert re.search(r"^# REQ-003 — ", body, re.M)
assert "${" not in text
requirements = re.findall(r"^### REQ-003\.(\d+) —", body, re.M)
assert requirements == [f"{n:02d}" for n in range(1, 11)]
assert requirements == [f"{n:02d}" for n in range(1, 12)]
acs = re.findall(r"^### REQ-003\.\d+-AC(\d+) —", body, re.M)
assert len(acs) == 10
assert body.count("**영향 범위**") == 10
assert len(acs) == 27
baseline_acs = re.findall(r"^### REQ-003\.(0[1-9]|10)-AC01 —", body, re.M)
assert baseline_acs == [f"{n:02d}" for n in range(1, 11)]
assert body.count("**영향 범위**") == 11 # Impact is per requirement, not per AC.
assert body.count("**Cases (정상/경계/실패)**") == 10
assert body.count("**State Invariants**") == 10
assert body.count("**판정 기준**") == 10
assert body.count("**검증**") == 10
assert body.count("**판정 기준**") == 27
assert body.count("**검증**") == 27
classification = re.findall(
r"^\*\*검증 분류\*\*: level=(unit|integration|static); backend=(sqlite|application|dashboard)$",
body,
re.M,
)
assert len(classification) == 10
assert body.count("- 수준: integration") == 17
assert len(re.findall(r"^- 백엔드: (sqlite|application|dashboard)$", body, re.M)) == 17
assert {level for level, _ in classification} >= {"unit", "integration", "static"}
assert {backend for _, backend in classification} >= {"sqlite", "application", "dashboard"}
for marker in (
@@ -142,7 +146,7 @@ def test_req003_has_exact_canonical_numbered_sections() -> None:
headings = re.findall(r"^## (\d+)\. ", body, re.M)
assert headings == [str(n) for n in range(1, 12)]
assert not re.search(r"^## (?:8|9)\.(?: D| C)", body, re.M)
assert body.count("### REQ-003.") == 20
assert body.count("### REQ-003.") == 38
assert "### REQ-003.01-AC01" in body
assert "### REQ-003.10-AC01" in body
+1 -1
View File
@@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p
assert status["integrity_check"] == "ok"
assert status["table_counts"]["review_decisions"] == 0
assert status["table_counts"]["reconciliation_sessions"] == 0
assert service.health_check(db_path)["schema_version"] == "12"
assert service.health_check(db_path)["schema_version"] == "13"
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
+1 -1
View File
@@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path):
db_path = tmp_path / "ledger.sqlite3"
result = service.health_check(str(db_path))
assert result["success"] is True
assert result["schema_version"] == "12"
assert result["schema_version"] == "13"
assert db_path.exists()
+112
View File
@@ -0,0 +1,112 @@
"""Contract tests for the Issue 15 Streamlit gateway and MCP surface.
Fixtures are synthetic and isolated: each test receives an empty temporary SQLite
path; no production DB or external service is used. These assertions intentionally
fail until the approved adapter/API is implemented.
"""
from __future__ import annotations
import importlib
import inspect
from pathlib import Path
import pytest
OPERATIONS = {
"create_settlement_draft": ["month", "created_by", "idempotency_key", "basis_date", "memo", "correction_of_run_id", "correction_reason", "db_path"],
"list_settlement_drafts": ["month", "db_path"],
"get_settlement_draft": ["draft_id", "db_path"],
"update_settlement_draft": ["draft_id", "expected_version", "updated_by", "memo", "db_path"],
"refresh_settlement_draft_targets": ["draft_id", "expected_version", "updated_by", "db_path"],
"set_settlement_draft_item": ["draft_id", "source_type", "source_id", "expected_version", "selected_by", "valuation_id", "selection_mode", "selection_reason", "included", "exclusion_reason", "db_path"],
"review_settlement_draft": ["draft_id", "db_path"],
"approve_settlement_draft": ["draft_id", "expected_version", "review_fingerprint", "approved_by", "approval_reason", "approval_reference", "allow_missing_exclusions", "allow_carried_before", "allow_manual_substitute", "db_path"],
"finalize_settlement_draft": ["draft_id", "expected_version", "review_fingerprint", "idempotency_key", "db_path"],
"cancel_settlement_draft": ["draft_id", "expected_version", "cancelled_by", "reason", "db_path"],
}
@pytest.fixture
def db_path(tmp_path: Path) -> Path:
return tmp_path / "synthetic-settlement.sqlite3"
def test_gateway_exports_exact_contract_signature_and_delegates(monkeypatch, db_path):
module = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
service = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service")
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
calls = []
for name, expected_parameters in OPERATIONS.items():
gateway_fn = getattr(module, name)
service_fn = getattr(service, name)
assert list(inspect.signature(gateway_fn).parameters) == expected_parameters
assert inspect.signature(gateway_fn) == inspect.signature(service_fn)
marker = {"success": True, "operation": name}
def stub(*args, _marker=marker, _name=name, **kwargs):
calls.append((_name, kwargs.get("db_path")))
return _marker
monkeypatch.setattr(service, name, stub)
kwargs = {p: "synthetic" for p in expected_parameters}
kwargs.update(db_path=str(db_path))
kwargs["correction_of_run_id"] = ""
kwargs["correction_reason"] = ""
if "expected_version" in kwargs:
kwargs["expected_version"] = 1
if "included" in kwargs:
kwargs["included"] = True
if name == "create_settlement_draft":
kwargs.update(month="2026-09", basis_date="2026-09-30")
assert gateway_fn(**kwargs) == marker
assert calls[-1] == (name, str(db_path))
def test_gateway_exposes_existing_valuation_evidence_operations():
gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
for name in ("register_account_valuation", "list_account_valuations", "correct_account_valuation"):
assert callable(getattr(gateway, name)), name
def test_mcp_contract_and_fastmcp_tool_calls_preserve_service_result(monkeypatch, db_path):
contracts = importlib.import_module("jarca_ledger_mcp.adapters.mcp.tool_contracts")
server = importlib.import_module("jarca_ledger_mcp.adapters.mcp.server")
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
calls = []
tools = getattr(server, "mcp", getattr(server, "server", None))._tool_manager._tools
for name in OPERATIONS:
assert name in contracts.TOOL_CONTRACTS
assert name in tools
marker = {"success": True, "draft": {"id": "fixture-draft", "version": 1}}
def stub(*args, _marker=marker, _name=name, **kwargs):
calls.append(_name)
return _marker
monkeypatch.setattr(contracts, name, stub)
tool = tools[name]
call = getattr(tool, "fn", getattr(tool, "function", None))
assert call is not None
kwargs = {p: "synthetic" for p in OPERATIONS[name]}
kwargs.pop("db_path", None)
if "expected_version" in kwargs: kwargs["expected_version"] = 1
if "included" in kwargs: kwargs["included"] = True
if name == "create_settlement_draft": kwargs.update(month="2026-09", basis_date="2026-09-30")
assert call(**kwargs) == marker
assert calls[-1] == name
def test_read_only_gateway_calls_do_not_create_missing_database(monkeypatch, db_path):
gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
# Real delegation is required here: read paths must not create a missing file.
for name in ("list_settlement_drafts",):
getattr(gateway, name)(db_path=str(db_path))
assert not db_path.exists()
def test_dashboard_authentication_entrypoint_precedes_database_resolution():
app_source = (Path(__file__).parents[1] / "dashboard" / "app.py").read_text(encoding="utf-8")
assert app_source.index("require_dashboard_authentication()") < app_source.index("get_db_path()")
def test_service_reexports_every_lifecycle_operation():
service = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service")
assert all(callable(getattr(service, name, None)) for name in OPERATIONS)
@@ -0,0 +1,63 @@
"""Executable reproductions reconcile static audit hypotheses with real behavior."""
import sqlite3
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(result):
assert result['success'], result
return result
def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tmp_path):
path = str(tmp_path / 'empty.sqlite3')
init_db(path)
result = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))
draft = result['draft']
assert draft['status'] == 'active' and draft['version'] == 1
assert draft['basis_date'] == '2026-09-30' and draft['correction_of_run_id'] == ''
assert result['items'] == [] and not result['review']['complete']
assert result['review']['totals'] == {'total_assets': 0, 'total_liabilities': 0, 'net_worth': 0}
assert ok(s.get_settlement_draft(draft['id'], db_path=path)) == result
with sqlite3.connect(path) as conn:
assert conn.execute('SELECT COUNT(*) FROM settlement_drafts').fetchone()[0] == 1
assert conn.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
assert conn.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
def test_initial_draft_freezes_exact_verified_selector_compatibly(tmp_path):
path = str(tmp_path / 'selection.sqlite3')
init_db(path)
account = ok(s.create_ledger_account('Synthetic cash', db_path=path))['account']['id']
verified = ok(s.register_account_valuation(account, '2026-09-30', 100, verification_status='verified', idempotency_key='verified', db_path=path))['valuation']['id']
unverified = ok(s.register_account_valuation(account, '2026-09-30', 999, verification_status='unverified', idempotency_key='unverified', db_path=path))['valuation']['id']
with sqlite3.connect(path) as conn:
conn.execute("UPDATE account_valuations SET recorded_at='2026-10-01T00:00:00Z' WHERE id=?", (verified,))
conn.execute("UPDATE account_valuations SET recorded_at='2026-10-02T00:00:00Z' WHERE id=?", (unverified,))
selected = ok(s.select_account_valuation(account, '2026-09-30', as_of=False, db_path=path))
assert selected['valuation']['id'] == verified
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))
item = next(x for x in draft['items'] if x['source_id'] == account)
assert item['valuation_id'] == verified and item['amount'] == 100 and item['verification_status'] == 'verified'
def test_refresh_manual_target_preserves_retired_history_and_allows_explicit_refreeze(tmp_path):
path = str(tmp_path / 'retired.sqlite3')
init_db(path)
asset = ok(s.create_manual_asset('Synthetic property', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id']
created = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))
draft, original = created['draft'], created['items'][0]
ok(s.deactivate_manual_asset(asset, db_path=path))
retired = ok(s.refresh_settlement_draft_targets(draft['id'], draft['version'], 'refresher', db_path=path))
row = next(x for x in retired['items'] if x['source_id'] == asset)
assert row['id'] == original['id'] and not row['included'] and row['exclusion_reason']
assert row['state_hash'] == original['state_hash'] and row['candidate_amount'] == 100
refrozen = ok(s.set_settlement_draft_item(draft['id'], 'manual_asset', asset, retired['draft']['version'], 'refreezer', included=False, exclusion_reason='Synthetic retired source confirmed', db_path=path))
row = refrozen['items'][0]
assert row['id'] == original['id'] and not row['included'] and row['amount'] is None
assert row['state_hash'] != original['state_hash'] and not row['review_needed']
assert row['selected_by'] == 'refreezer'
invalid = s.set_settlement_draft_item(draft['id'], 'manual_asset', asset, refrozen['draft']['version'], 'selector', included=True, db_path=path)
assert not invalid['success']
assert ok(s.get_settlement_draft(draft['id'], db_path=path))['draft']['version'] == refrozen['draft']['version']
+181
View File
@@ -0,0 +1,181 @@
"""Synthetic regressions for frozen evidence and reviewed authority."""
import sqlite3
import pytest
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(result):
assert result['success'], result
return result
@pytest.fixture
def case(tmp_path):
path = str(tmp_path / 'authority.sqlite3')
init_db(path)
account = ok(s.create_ledger_account('Synthetic investment', type='investment', liquidity_bucket='investment', db_path=path))['account']['id']
val = ok(s.register_account_valuation(account, '2026-09-30', 500, valuation_kind='total_value', verification_status='verified', idempotency_key='evidence', db_path=path))['valuation']['id']
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft']
return path, account, val, draft
def approve(path, draft, **flags):
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'reviewer', 'synthetic reviewed', 'ref', db_path=path, **flags))
return review
def counts(path):
with sqlite3.connect(path) as conn:
return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'asset_snapshots'))
@pytest.mark.parametrize('sql', [
"UPDATE account_valuations SET amount=600 WHERE id=?",
"UPDATE account_valuations SET memo='changed' WHERE id=?",
"UPDATE account_valuations SET principal=123 WHERE id=?",
])
def test_full_valuation_drift_changes_fingerprint_and_blocks_write(case, sql):
path, account, val, draft = case
review = approve(path, draft)
with sqlite3.connect(path) as conn:
conn.execute(sql, (val,))
changed = ok(s.review_settlement_draft(draft['id'], db_path=path))
assert changed['review_fingerprint'] != review['review_fingerprint']
assert any(x.get('review_needed') for x in changed['items'])
result = s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path)
assert not result['success'], result
assert counts(path) == (0, 0)
def test_classification_drift_requires_explicit_reselection(case):
path, account, val, draft = case
review = approve(path, draft)
with sqlite3.connect(path) as conn:
conn.execute("UPDATE accounts SET liquidity_bucket='cash' WHERE id=?", (account,))
changed = ok(s.review_settlement_draft(draft['id'], db_path=path))
assert changed['review_fingerprint'] != review['review_fingerprint']
assert any(x.get('review_needed') for x in changed['items'])
assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path)['success']
assert counts(path) == (0, 0)
def test_corrected_parent_cannot_finalize_without_explicit_child_decision(case):
path, account, val, draft = case
previous = approve(path, draft)
child = ok(s.correct_account_valuation(val, amount=550, verification_status='verified', correction_reason='synthetic correction', idempotency_key='child', db_path=path))['valuation']['id']
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
assert review['review_fingerprint'] != previous['review_fingerprint']
result = s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'reviewer', 'review again', 'ref2', db_path=path)
if result['success']:
assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path)['success']
assert counts(path) == (0, 0)
updated = ok(s.set_settlement_draft_item(draft['id'], 'account', account, draft['version'], 'selector', valuation_id=child, db_path=path))['draft']
fresh = approve(path, updated)
final = ok(s.finalize_settlement_draft(updated['id'], updated['version'], fresh['review_fingerprint'], 'final2', db_path=path))
assert final['totals']['net_worth'] == 550
def test_create_replay_uses_original_payload_after_memo_edit(case):
path, account, val, draft = case
ok(s.update_settlement_draft(draft['id'], draft['version'], 'editor', 'edited', db_path=path))
replay = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))
assert replay['draft']['id'] == draft['id']
assert not s.create_settlement_draft('2026-09', 'creator', 'create', memo='different original request', db_path=path)['success']
def test_empty_draft_cannot_create_empty_close(tmp_path):
path = str(tmp_path / 'empty.sqlite3')
init_db(path)
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'empty', db_path=path))['draft']
review = approve(path, draft, allow_missing_exclusions=True)
assert review['complete'] is False
assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'empty-final', db_path=path)['success']
assert counts(path) == (0, 0)
def test_manual_reselection_explicitly_refreezes_semantic_evidence(tmp_path):
path = str(tmp_path / 'manual.sqlite3')
init_db(path)
asset = ok(s.create_manual_asset('Synthetic home', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id']
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'manual', db_path=path))['draft']
old = approve(path, draft)
ok(s.update_manual_asset(asset, valuation_amount=110, db_path=path))
drift = ok(s.review_settlement_draft(draft['id'], db_path=path))
assert drift['review_fingerprint'] != old['review_fingerprint']
assert not s.finalize_settlement_draft(draft['id'], draft['version'], old['review_fingerprint'], 'stale', db_path=path)['success']
refreshed = ok(s.set_settlement_draft_item(draft['id'], 'manual', asset, draft['version'], 'selector', db_path=path))['draft']
new = approve(path, refreshed)
assert not any(x.get('review_needed') for x in new['items'])
final = ok(s.finalize_settlement_draft(refreshed['id'], refreshed['version'], new['review_fingerprint'], 'manual-final', db_path=path))
assert final['totals']['net_worth'] == 110
@pytest.mark.parametrize('operation,args', [
('list_settlement_drafts', ()), ('get_settlement_draft', ('missing',)), ('review_settlement_draft', ('missing',)),
])
def test_draft_reads_preserve_missing_db_error_and_no_create(tmp_path, operation, args):
path = tmp_path / 'absent-parent' / 'db.sqlite3'
result = getattr(s, operation)(*args, db_path=str(path))
assert result['error_code'] == 'db_not_initialized', result
assert not path.parent.exists()
def test_target_refresh_refreezes_manual_retains_account_selection_and_retires_targets(tmp_path):
path = str(tmp_path / 'refresh.sqlite3')
init_db(path)
manual = ok(s.create_manual_asset('Synthetic property', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id']
account = ok(s.create_ledger_account('Synthetic investment', type='investment', liquidity_bucket='investment', db_path=path))['account']['id']
val = ok(s.register_account_valuation(account, '2026-09-30', 50, valuation_kind='total_value', verification_status='verified', idempotency_key='initial', db_path=path))['valuation']['id']
d = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft']
ok(s.update_manual_asset(manual, valuation_amount=110, db_path=path))
child = ok(s.correct_account_valuation(val, amount=60, verification_status='verified', correction_reason='synthetic', idempotency_key='child', db_path=path))['valuation']['id']
added = ok(s.create_manual_asset('Synthetic new', 20, '2026-09-30', 'other', db_path=path))['manual_asset']['id']
result = ok(s.refresh_settlement_draft_targets(d['id'], d['version'], 'refresh actor', db_path=path))
review = result['review']
property_row = next(x for x in review['items'] if x['source_id'] == manual)
assert property_row['amount'] == 110 and not property_row['review_needed']
account_row = next(x for x in review['items'] if x['source_id'] == account)
assert account_row['valuation_id'] == val and account_row['valuation_id'] != child
assert account_row['review_needed']
assert any(x['source_id'] == added and x['amount'] == 20 for x in review['items'])
ok(s.deactivate_manual_asset(added, db_path=path))
refreshed = ok(s.refresh_settlement_draft_targets(d['id'], result['draft']['version'], 'refresh actor', db_path=path))
retired = next(x for x in refreshed['items'] if x['source_id'] == added)
assert retired['included'] is False and retired['exclusion_reason']
assert retired['selected_by'] == 'refresh actor'
@pytest.mark.parametrize('action', ['memo', 'cancel', 'refresh', 'selection'])
def test_any_draft_mutation_clears_all_approval_version_state(case, action):
path, account, val, draft = case
approve(path, draft)
before = ok(s.get_settlement_draft(draft['id'], db_path=path))['draft']
assert before['approval_version'] == draft['version'] and before['approved_at']
if action == 'memo':
result = s.update_settlement_draft(draft['id'], draft['version'], 'editor', 'edited', db_path=path)
elif action == 'cancel':
result = s.cancel_settlement_draft(draft['id'], draft['version'], 'editor', 'synthetic cancellation', db_path=path)
elif action == 'refresh':
result = s.refresh_settlement_draft_targets(draft['id'], draft['version'], 'editor', db_path=path)
else:
result = s.set_settlement_draft_item(draft['id'], 'account', account, draft['version'], 'editor', valuation_id=val, db_path=path)
changed = ok(result)['draft']
assert changed['version'] == draft['version'] + 1
assert changed['approval_version'] == 0 and changed['approved_at'] == ''
assert all(changed[key] == '' for key in ('approved_by', 'approval_fingerprint', 'approval_reason', 'approval_reference'))
assert changed['approval_flags'] == '{}'
def test_finalize_same_key_different_payload_conflicts_without_partial_write(case):
path, account, val, draft = case
review = approve(path, draft)
final = ok(s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final-key', db_path=path))
before = counts(path)
assert s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final-key', db_path=path) == final
conflict = s.finalize_settlement_draft(draft['id'], draft['version'] + 1, review['review_fingerprint'], 'final-key', db_path=path)
assert not conflict['success'] and conflict['error_code'] == 'conflict'
assert counts(path) == before
@@ -0,0 +1,173 @@
"""Clause-level acceptance uses real synthetic SQLite and public consumers."""
import sqlite3
from concurrent.futures import ThreadPoolExecutor
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(result):
assert result['success'], result
return result
def account(path, name, **fields):
return ok(s.create_ledger_account(name, db_path=path, **fields))['account']['id']
def value(path, aid, key, when, amount, **fields):
return ok(s.register_account_valuation(aid, when, amount, idempotency_key=key, verification_status=fields.pop('verification_status', 'verified'), db_path=path, **fields))['valuation']['id']
def choose(path, draft, aid, vid, **fields):
return ok(s.set_settlement_draft_item(draft['id'], 'account', aid, draft['version'], 'synthetic selector', valuation_id=vid, db_path=path, **fields))['draft']
def approve(path, draft, **flags):
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'synthetic reviewer', 'synthetic review reason', 'synthetic reference', db_path=path, **flags))
return review
def test_date_metadata_public_contract_names_survive_all_closed_consumers(tmp_path):
path = str(tmp_path / 'dates.sqlite3')
init_db(path)
aid = account(path, 'Synthetic loan', type='loan', liquidity_bucket='loan')
vid = value(path, aid, 'loan', '2026-10-07', 40, valuation_kind='loan_balance', representative_liability_definition='principal_only')
with sqlite3.connect(path) as c:
c.execute("UPDATE account_valuations SET recorded_at='2026-10-08T00:00:00Z' WHERE id=?", (vid,))
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
d = choose(path, d, aid, vid, selection_mode='manual_substitute', selection_reason='Synthetic late loan statement')
review = approve(path, d, allow_manual_substitute=True)
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
report = ok(s.get_asset_snapshot_report('2026-09-30', db_path=path))
series = ok(s.get_net_worth_timeseries('2026-09-01', '2026-09-30', db_path=path))
monthly = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
expected = {'valuation_date': '2026-10-07', 'date_difference_days': 7, 'date_difference_direction': 'after', 'age_days': -7, 'recorded_at': '2026-10-08T00:00:00Z', 'representative_liability_definition': 'principal_only'}
rows = [review['included_items'][0], final['items'][0], report['close_metadata']['applied_evidence'][0], series['points'][0]['applied_evidence'][0], monthly['source_coverage']['assets']['applied_evidence'][0]]
for row in rows:
assert {key: row.get(key) for key in expected} == expected
for row in rows[1:]:
assert row['approval']['allow_manual_substitute'] is True
assert row['approval']['allow_missing_exclusions'] is False
assert row['approval']['allow_carried_before'] is False
def test_representative_integer_precision_no_double_sum_and_scope_rejection(tmp_path):
path = str(tmp_path / 'precision.sqlite3')
init_db(path)
aid = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
other = account(path, 'Synthetic other')
amount = 9007199254740993
representative = value(path, aid, 'total', '2026-09-30', amount, valuation_kind='total_value')
value(path, aid, 'cash', '2026-09-30', 100, valuation_kind='cash_balance')
wrong = value(path, other, 'other', '2026-09-30', 10)
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
review = ok(s.review_settlement_draft(d['id'], db_path=path))
item = next(x for x in review['included_items'] if x['source_id'] == aid)
assert item['valuation_id'] == representative and type(item['amount']) is int and item['amount'] == amount
assert review['totals']['net_worth'] == amount + 10
before = ok(s.get_settlement_draft(d['id'], db_path=path))
bad = s.set_settlement_draft_item(d['id'], 'account', aid, d['version'], 'selector', valuation_id=wrong, db_path=path)
assert not bad['success'] and bad['error_code'] == 'validation_error'
assert ok(s.get_settlement_draft(d['id'], db_path=path)) == before
def test_unverified_substitute_only_finalizes_as_explicit_approved_missing(tmp_path):
path = str(tmp_path / 'unverified.sqlite3')
init_db(path)
aid = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
vid = value(path, aid, 'candidate', '2026-10-05', 100, valuation_kind='total_value', verification_status='unverified')
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
d = choose(path, d, aid, vid, selection_mode='manual_substitute', selection_reason='Synthetic review-only candidate')
review = ok(s.review_settlement_draft(d['id'], db_path=path))
assert not review['included_items'] and review['missing_items'][0]['amount'] is None
bad = s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'reviewer', 'reason', 'ref', allow_manual_substitute=True, db_path=path)
assert not bad['success']
before = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
assert not before['success']
review = approve(path, d, allow_missing_exclusions=True)
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
assert not final['items'] and final['excluded_items'][0]['amount'] is None
assert final['excluded_items'][0]['candidate_amount'] == 100
assert final['totals']['net_worth'] == 0 and final['has_approved_missing']
assert not final['contains_substitutions'] and final['close_status'] == 'conditional'
def test_transaction_readiness_is_independent_of_asset_draft_close(tmp_path):
path = str(tmp_path / 'separation.sqlite3')
init_db(path)
aid = account(path, 'Synthetic cash')
value(path, aid, 'cash', '2026-09-30', 100)
tx = ok(s.create_ledger_transaction('2026-09-15', amount=5, account_id=aid, category='미분류', db_path=path))
assert tx['success']
ok(s.upsert_review_decision('transaction', tx['transaction']['id'], status='pending', operator='synthetic reviewer', db_path=path))
before = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
assert before['unresolved_blocker_count'] > 0
with sqlite3.connect(path) as c:
original = c.execute('SELECT * FROM transactions').fetchall()
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
review = approve(path, d)
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
after = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
assert final['totals']['net_worth'] == 100 and after['source_coverage']['assets']['is_closed_snapshot']
assert after['unresolved_blockers'] == before['unresolved_blockers'] and after['unresolved_blocker_count'] > 0
with sqlite3.connect(path) as c:
assert c.execute('SELECT * FROM transactions').fetchall() == original
def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollback_concurrent_retry(tmp_path):
path = str(tmp_path / 'september.sqlite3')
init_db(path)
cash = account(path, 'Synthetic cash')
pension = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
loan = account(path, 'Synthetic loan', type='loan', liquidity_bucket='loan')
rejected = account(path, 'Synthetic rejected')
foreign = account(path, 'Synthetic USD', currency='USD')
unknown = ok(s.create_manual_asset('Synthetic undated property', None, '', 'real_estate', db_path=path))['manual_asset']['id']
cv = value(path, cash, 'cash', '2026-09-30', 100)
pv = value(path, pension, 'pension', '2026-10-05', 120, valuation_kind='total_value')
lv = value(path, loan, 'loan', '2026-10-07', 40, valuation_kind='loan_balance', representative_liability_definition='principal_only')
value(path, rejected, 'rejected', '2026-09-30', 999, verification_status='rejected')
value(path, foreign, 'foreign', '2026-09-30', 77, currency='USD')
with sqlite3.connect(path) as c:
c.execute("UPDATE account_valuations SET recorded_at='2026-10-10T00:00:00Z' WHERE id=?", (cv,))
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
first = ok(s.review_settlement_draft(d['id'], db_path=path))
assert first['totals']['net_worth'] == 100
d = choose(path, d, pension, pv, selection_mode='manual_substitute', selection_reason='Synthetic Oct 05 pension')
d = choose(path, d, loan, lv, selection_mode='manual_substitute', selection_reason='Synthetic Oct 07 loan')
review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
assert review['totals']['net_worth'] == 180
assert {x['source_id'] for x in review['missing_items']} == {rejected, foreign, unknown}
assert all(x['amount'] is None for x in review['missing_items'])
assert any(w['code'] == 'non_krw' for w in review['warnings'])
with sqlite3.connect(path) as c:
c.execute("UPDATE account_valuations SET memo='Synthetic evidence drift' WHERE id=?", (cv,))
stale = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
assert not stale['success'] and stale['error_code'] == 'conflict'
d = choose(path, d, cash, cv)
review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
with sqlite3.connect(path) as c:
c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END")
failed = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
assert not failed['success'] and failed['error_code'] == 'db_error'
with sqlite3.connect(path) as c:
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
c.execute('DROP TRIGGER synthetic_failure')
assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['status'] == 'active'
def finish():
return s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(finish) for _ in range(2)]
results = [ok(f.result()) for f in futures]
assert results[0] == results[1]
final = results[0]
assert final['contains_substitutions'] and final['has_approved_missing'] and not final['contains_carried']
assert final['totals']['net_worth'] == 180 and len(final['items']) == 3
assert {x['valuation_date'] for x in final['items']} == {'2026-09-30', '2026-10-05', '2026-10-07'}
assert finish() == final
with sqlite3.connect(path) as c:
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 1
assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == final['inserted_count']
@@ -0,0 +1,67 @@
"""Acceptance contract for immutable, reason-linked settlement corrections."""
import sqlite3
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(r):
assert r['success'], r
return r
def counts(path):
with sqlite3.connect(path) as c:
return {table: c.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
for table in ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')}
def finish(path, draft):
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
approved = ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'synthetic reviewer', 'approved synthetic correction', 'acceptance', db_path=path))
return ok(s.finalize_settlement_draft(draft['id'], approved['draft']['version'], review['review_fingerprint'], 'close-' + draft['id'], db_path=path))
def test_correction_links_reason_preserves_prior_rows_and_replays_after_later_correction(tmp_path):
path = str(tmp_path / 'correction.sqlite3')
init_db(path)
aid = ok(s.create_ledger_account('Synthetic cash', db_path=path))['account']['id']
first_value = ok(s.register_account_valuation(aid, '2026-09-30', 100, verification_status='verified', idempotency_key='v1', db_path=path))['valuation']['id']
original_draft = ok(s.create_settlement_draft('2026-09', 'synthetic', 'draft-1', db_path=path))['draft']
original = finish(path, original_draft)
original_rows = counts(path)
corrected_value = ok(s.correct_account_valuation(first_value, 125, 'synthetic valuation correction', idempotency_key='v2', db_path=path))['valuation']['id']
assert counts(path)['asset_snapshot_runs'] == original_rows['asset_snapshot_runs']
assert counts(path)['asset_snapshots'] == original_rows['asset_snapshots']
run_id = original['snapshot_run_id']
bad_requests = [('', 'reason'), (run_id, ''), ('does-not-exist', 'reason')]
before = counts(path)
for i, (parent, reason) in enumerate(bad_requests):
result = s.create_settlement_draft('2026-09', 'synthetic', f'invalid-{i}', correction_of_run_id=parent, correction_reason=reason, db_path=path)
assert not result['success']
assert counts(path) == before
ordinary = s.create_settlement_draft('2026-09', 'synthetic', 'ordinary-after-close', db_path=path)
assert not ordinary['success'] and ordinary['error_code'] == 'validation_error'
assert counts(path) == before
correction = ok(s.create_settlement_draft('2026-09', 'synthetic', 'correction-1', correction_of_run_id=run_id, correction_reason='synthetic close correction', db_path=path))['draft']
active_conflict = s.create_settlement_draft('2026-09', 'synthetic', 'active-conflict', correction_of_run_id=run_id, correction_reason='another linked correction', db_path=path)
assert not active_conflict['success'] and active_conflict['error_code'] == 'conflict'
assert counts(path)['settlement_drafts'] == before['settlement_drafts'] + [counts(path)['settlement_drafts'][-1]]
assert correction['correction_of_run_id'] == run_id
selected = ok(s.set_settlement_draft_item(correction['id'], 'account', aid, correction['version'], 'synthetic selector', valuation_id=corrected_value, db_path=path))['draft']
corrected = finish(path, selected)
assert corrected['correction']['correction_of_run_id'] == run_id
assert corrected['correction']['correction_reason'] == 'synthetic close correction'
for table, rows in original_rows.items():
assert counts(path)[table][:len(rows)] == rows
third_value = ok(s.correct_account_valuation(corrected_value, 150, 'second synthetic correction', idempotency_key='v3', db_path=path))['valuation']['id']
later = ok(s.create_settlement_draft('2026-09', 'synthetic', 'correction-2', correction_of_run_id=corrected['snapshot_run_id'], correction_reason='second synthetic close correction', db_path=path))['draft']
later = ok(s.set_settlement_draft_item(later['id'], 'account', aid, later['version'], 'synthetic selector', valuation_id=third_value, db_path=path))['draft']
finish(path, later)
before_replay = counts(path)
replay = s.finalize_settlement_draft(correction['id'], selected['version'], corrected['review_fingerprint'], 'close-' + correction['id'], db_path=path)
assert replay == corrected
assert counts(path) == before_replay
+106
View File
@@ -0,0 +1,106 @@
"""Dashboard lifecycle contract tests using Streamlit AppTest when available.
All data is synthetic; renders must not mutate/create state or databases. This
suite deliberately asserts the Issue 15 lifecycle UI is present before it exists.
"""
from __future__ import annotations
from pathlib import Path
import pytest
VIEW = Path(__file__).parents[1] / "dashboard" / "page_views" / "settlement_drafts.py"
def _app_wrapper(tmp_path: Path) -> Path:
wrapper = tmp_path / "settlement_panel_app.py"
wrapper.write_text(
"import os\nfrom dashboard.page_views.settlement_drafts import render_settlement_drafts\n"
"render_settlement_drafts(db_path=os.environ.get(\"JARCA_LEDGER_DB_PATH\"))\n",
encoding="utf-8",
)
return wrapper
def test_reusable_settlement_draft_panel_exists_and_is_importable(tmp_path):
assert VIEW.is_file(), "shared Dashboard settlement draft panel is missing"
from streamlit.testing.v1 import AppTest
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
assert not app.exception
def test_monthly_and_assets_pages_invoke_shared_panel():
root = VIEW.parents[1]
pages = [p for p in root.rglob("*.py") if p.name in ("monthly_settlement.py", "assets.py")]
assert len(pages) == 2
for page in pages:
assert "render_settlement_drafts" in page.read_text(encoding="utf-8")
def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkeypatch, tmp_path):
pytest.importorskip("streamlit.testing.v1")
from streamlit.testing.v1 import AppTest
db_path = tmp_path / "dashboard-missing.sqlite3"
# Explicit synthetic DB path and no authenticated actor: render only.
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
assert not app.exception
page = "\n".join([e.value for e in app.markdown] + [e.value for e in app.caption] + [e.value for e in app.info] + [e.label for e in app.button])
for expected in ("초안", "검토", "승인", "최종", "취소", "정정", "평가자료", "제외 사유"):
assert expected in page
assert not db_path.exists(), "render-only Dashboard created a database"
def test_panel_actions_are_explicit_and_selection_state_bound_to_draft_version():
source = VIEW.read_text(encoding="utf-8")
for action in ("create_settlement_draft", "update_settlement_draft", "cancel_settlement_draft",
"refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "register_account_valuation",
"list_account_valuations", "correct_account_valuation"):
assert action in source, f"missing explicit action {action}"
assert "session_state" in source
assert "review_fingerprint" in source
assert "expected_version" in source or "version, actor" in source
assert "transaction" in source.lower() and "거래" in source
def test_closed_draft_view_exposes_immutable_evidence_and_correction_creation():
source = VIEW.read_text(encoding="utf-8")
for immutable in ("draft_version", "review_fingerprint", "final_run_id", "correction_of_run_id",
"basis_date", "created_by", "created_at", "updated_at", "memo"):
assert immutable in source, f"closed view omits {immutable}"
assert "correction_reason" in source
assert "completion" in source or "completed" in source
def test_render_path_contains_no_implicit_lifecycle_mutations():
source = VIEW.read_text(encoding="utf-8")
# All lifecycle mutations must be behind explicit submit/button branches;
# import/render must never invoke them unconditionally.
assert "if st.button" in source or "form_submit_button" in source
assert "render_settlement_drafts" in source
def test_app_test_create_review_approve_finalize_roundtrip(tmp_path, monkeypatch):
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.db import init_db
from jarca_ledger_mcp.application.assets_service import create_manual_asset
db_path = tmp_path / "lifecycle.sqlite3"
init_db(str(db_path))
seeded = create_manual_asset("Synthetic asset", 125, "2026-09-30", "other", source_note="fixture", db_path=str(db_path))
assert seeded.get("success"), seeded
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
assert not app.exception
app.button[0].click().run()
assert not app.exception
assert app.button[0].label == "초안 생성 / 정정 초안"
assert len(app.selectbox[0].options) == 1
assert "active" in " ".join(item.value for item in app.caption)
# Exercise read-only review through the rendered explicit button.
review = next(button for button in app.button if button.label == "검토 / 새로고침 대상")
review.click().run(timeout=10)
assert not app.exception
assert any("review_fingerprint" in item.value for item in app.caption)
assert not app.exception
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
import ast
import json
import re
from pathlib import Path
ROOT = Path(__file__).parents[1]
DOCS = ROOT / "docs"
REQ = DOCS / "requirements/req-003_account-valuation-history.md"
def text(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_settlement_requirement_revision_is_additive_and_traceable() -> None:
source = REQ.read_text(encoding="utf-8")
_, raw, body = source.split("---", 2)
metadata = json.loads(raw)
assert metadata["id"] == "REQ-003"
assert metadata["revision"] == "1.1"
assert metadata["issue"] == "#15"
assert metadata["implementation_pr"] == "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16"
assert "#12" in body and "#13" in body
assert re.findall(r"^### REQ-003\.(\d+) —", body, re.M) == [f"{i:02d}" for i in range(1, 12)]
assert re.findall(r"^## (\d+)\. ", body, re.M) == [str(i) for i in range(1, 12)]
assert "schema 11→12" in body and "schema 12→13" in body
def test_draft_docs_cover_authority_dates_atomicity_and_read_only_contracts() -> None:
docs = "\n".join(text(path) for path in (
"docs/requirements/req-003_account-valuation-history.md", "docs/data-model.md",
"docs/interface-contract.md", "docs/project-structure.md", "docs/runtime-flow.md",
))
for marker in ("date_difference_days = valuation_date - basis_date", "age_days", "manual_substitute",
"candidate_amount", "authority amount NULL", "refresh_settlement_draft_targets",
"BEGIN IMMEDIATE", "persisted response", "one active draft", "correction_reason",
"unverified", "no-create", "transaction readiness"):
assert marker in docs, marker
def test_new_public_operations_are_fully_listed_from_contract() -> None:
interface = text("docs/interface-contract.md")
for operation in ("create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
"update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item",
"review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft",
"cancel_settlement_draft"):
assert f"{operation}(" in interface, operation
assert "review_fingerprint" in interface and "selection_mode" in interface
def test_settlement_acceptance_cases_are_individually_named_and_real() -> None:
import ast
req = REQ.read_text(encoding="utf-8").split("---", 2)[2]
section = req.split("## 7. 인수 기준", 1)[1].split("## 8. 오류", 1)[0]
cases = re.findall(r"^### REQ-003\.11-AC(\d+) — (.+)$", section, re.M)
assert len(cases) == 17
assert [n for n, _ in cases] == [f"{i:02}" for i in range(1, 18)]
files = {str(p.relative_to(ROOT)): p for p in (ROOT / "tests").glob("test_*.py")}
for block in re.split(r"(?=^### REQ-003\.11-AC)", section, flags=re.M)[1:]:
ref = re.search(r"`(tests/[^`]+\.py)::(test_[A-Za-z0-9_]+)`", block)
assert ref, block.splitlines()[0]
path, func = ref.groups()
assert path in files, path
names = {n.name for n in ast.walk(ast.parse(files[path].read_text(encoding="utf-8"))) if isinstance(n, ast.FunctionDef)}
assert func in names, (path, func)
for label in ("판정 기준", "검증 분류", "상태 불변조건"):
assert label in block, (block.splitlines()[0], label)
assert "## 12." not in req
requirements = req.split("## 5. 요구사항", 1)[1].split("## 6.", 1)[0]
assert requirements.count("**영향 범위**") == 11
new_requirement = requirements.split("### REQ-003.11 —", 1)[1]
for dimension in ("인터페이스", "영속성", "후속 사용 주체", "마이그레이션", "호환성", "배포 전제 조건"):
assert f"- {dimension}:" in new_requirement
def test_settlement_metadata_names_match_actual_public_contract() -> None:
interface = text('docs/interface-contract.md').split('## settlement-draft-service', 1)[1]
model = text('docs/data-model.md').split('## settlement-drafts', 1)[1]
for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'representative_liability_definition'):
assert field in interface and field in model, field
for field in ('allow_missing_exclusions', 'allow_carried_before', 'allow_manual_substitute'):
assert field in interface
assert 'test_date_metadata_public_contract_names_survive_all_closed_consumers' in interface
def test_draft_mcp_signature_documents_internal_database_boundary() -> None:
interface = text("docs/interface-contract.md").split("## settlement-draft-service", 1)[1]
assert re.search(r"db_path.*(?:internal|service|gateway).*(?:not|omitted|제외).*(?:MCP|inputSchema)", interface, re.I | re.S), \
"document must distinguish internal db_path from MCP inputSchema"
assert "db_path: str | None = None" in interface
service = ast.parse(text("jarca_ledger_mcp/application/settlement_draft_service.py"))
names = {n.name for n in ast.walk(service) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
assert "create_settlement_draft" in names
def test_legacy_close_fields_are_scoped_separately_from_draft_finalize() -> None:
interface = text("docs/interface-contract.md")
legacy = interface.split("## D04 snapshot close output contract", 1)[1].split("## settlement-draft-service", 1)[0]
draft = interface.split("## settlement-draft-service", 1)[1]
assert re.search(r"legacy direct close", legacy, re.I), "exact field list must be scoped to legacy close"
assert re.search(r"additive settlement-draft finalize response", legacy, re.I) and re.search(r"preserving common aliases", draft, re.I), "draft finalize output extension and retained common aliases must be distinguished"
assert not re.search(r"new.*(?:identical|same).*shape", draft, re.I)
def test_unregistered_liability_contract_separates_registration_from_unknown_debt() -> None:
interface = text("docs/interface-contract.md")
clause = interface.split("### Draft evidence public fields", 1)[1].split("## ", 1)[0]
assert re.search(r"unregistered_liabilities.*(?:no|not).*registered.*liabilit", clause, re.I | re.S)
assert re.search(r"not.*(?:unknown|real).*debt", clause, re.I | re.S)
assert re.search(r"not.*(?:proof.*debt.free|prove.*debt.free)", clause, re.I | re.S)
assert re.search(r"exclu.*(?:missing|completeness)|(?:missing|completeness).*exclu", clause, re.I | re.S)
def test_requirement_authority_scope_distinguishes_approved_drafts_and_automatic_selection() -> None:
req = REQ.read_text(encoding="utf-8")
constraints = req.split("## 6. 제약과 불변조건", 1)[1].split("## 7.", 1)[0]
assert re.search(r"automatic.*`exact`.*`as_of`", constraints, re.I), "future exclusion must explicitly scope automatic exact/as_of selection"
assert re.search(r"legacy direct close", constraints, re.I), \
"future exclusion must explicitly scope legacy direct close"
assert re.search(r"verified.*future.*manual_substitute", constraints, re.I | re.S), \
"verified future valuation may only enter an explicitly approved draft substitute"
assert all(term in constraints for term in ("reason", "review", "approval", "original")), \
"draft substitute must preserve reason, review, approval, and original dates"
assert re.search(r"unverified.*rejected.*unknown.*(?:any|all).*path", constraints, re.I | re.S), \
"unverified, rejected, or unknown amounts are never authority on any path"
assert "future_manual_asset" not in constraints
def test_legacy_metadata_field_lists_are_required_retained_fields_not_exhaustive_response_keys() -> None:
interface = text("docs/interface-contract.md")
legacy = interface.split("## D04 snapshot close output contract", 1)[1].split("## settlement-draft-service", 1)[0]
assert re.search(r"legacy direct close", legacy, re.I)
assert re.search(r"required top-level metadata fields", legacy, re.I)
assert re.search(r"not (?:an )?exhaustive", legacy, re.I)
assert "additive settlement-draft finalize response" in legacy
def test_settlement_draft_project_ownership_and_shared_policy_wiring() -> None:
doc = text("docs/project-structure.md")
required_rows = {
"jarca_ledger_mcp/service.py": "public re-export",
"jarca_ledger_mcp/adapters/mcp/tool_contracts.py": "inputSchema",
"jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py": "auth-before-DB",
"dashboard/page_views/settlement_drafts.py": "render_settlement_drafts",
"dashboard/page_views/monthly_settlement.py": "page_monthly_settlement",
"dashboard/page_views/assets.py": "page_assets",
"jarca_ledger_mcp/application/account_valuation_service.py": "register_account_valuation",
"jarca_ledger_mcp/infrastructure/sqlite/schema.py": "schema 13",
}
for path, responsibility in required_rows.items():
rows = [row for row in doc.splitlines() if row.startswith("|") and f"`{path}`" in row]
assert rows and any(responsibility in row for row in rows), (path, responsibility)
assert (ROOT / path).is_file(), path
section = doc.split("### Shared settlement-draft policies", 1)[1]
for clause in ("one active draft per month", "idx_settlement_drafts_active_month", "WHERE status='active'",
"settlement_draft_service.py", "month-end", "unverified", "authority amount NULL",
"confirmed child", "explicit reselection", "review", "approval",
"tests/test_settlement_draft_adapters.py", "tests/test_monthly_settlement_drafts.py"):
assert clause in section, clause
def test_settlement_draft_runtime_cancel_and_recovery_contract() -> None:
doc = text("docs/runtime-flow.md")
assert "### Cancellation and reopening" in doc, "cancel/reopen lifecycle must be documented"
section = doc.split("### Cancellation and reopening", 1)[1]
for clause in ("cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason, db_path=None)",
"active", "nonempty", "cancelled_by", "cancellation_reason", "cancelled_at",
"approval_fingerprint", "approval_version", "version", "updated_at", "BEGIN IMMEDIATE",
"retained", "account_valuations", "asset_snapshot_runs", "asset_snapshots",
"rollback", "no-write", "completed", "cancelled", "fresh create key",
"same create key", "latest prior run", "correction_of_run_id", "correction_reason",
"tests/test_settlement_draft_correction_contract.py"):
assert clause in section, clause
source = ast.parse(text("jarca_ledger_mcp/application/settlement_draft_service.py"))
cancel = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "cancel_settlement_draft")
assert [arg.arg for arg in cancel.args.args] == ["draft_id", "expected_version", "cancelled_by", "reason", "db_path"]
@@ -0,0 +1,24 @@
"""Additive draft lineage must preserve legacy persisted evidence shapes."""
import pytest
from jarca_ledger_mcp.application.assets_service import _closed_run_metadata
@pytest.mark.parametrize('field,public_field', [
('excluded_evidence', 'excluded_items'), ('carried_evidence', 'carried_items'),
])
def test_legacy_run_evidence_does_not_gain_draft_identity_keys(field, public_field):
evidence = {'source_type': 'manual_asset', 'source_id': 'synthetic', 'reason': 'missing_valuation_amount', 'currency': 'KRW'}
metadata = {'snapshot_run_id': 'legacy-run', 'draft_id': '', 'draft_version': 0, 'review_fingerprint': '', field: [evidence]}
result = _closed_run_metadata(metadata)
assert result[public_field] == [evidence]
assert 'blocking' not in result[public_field][0]
assert not {'draft_id', 'draft_version', 'review_fingerprint'} & result[public_field][0].keys()
def test_draft_evidence_retains_its_persisted_lineage_and_selection_reason():
evidence = {'source_type': 'account', 'source_id': 'synthetic', 'draft_id': 'draft', 'draft_version': 2, 'review_fingerprint': 'fingerprint', 'selection_mode': 'manual_substitute', 'selection_reason': 'synthetic statement', 'item_version': 2}
metadata = {'snapshot_run_id': 'draft-run', 'applied_evidence': [evidence]}
result = _closed_run_metadata(metadata)
assert result['applied_evidence'] == [evidence]
assert result['contains_substitutions']
@@ -0,0 +1,56 @@
"""Registration semantics for draft unregistered-liability status."""
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(result):
assert result["success"], result
return result
def test_unregistered_liabilities_tracks_registered_targets_not_evidence_completeness(tmp_path):
path = str(tmp_path / "synthetic-liability-registration.sqlite3")
init_db(path)
empty = ok(s.create_settlement_draft("2026-09", "synthetic", "empty", db_path=path))["draft"]
empty_review = ok(s.review_settlement_draft(empty["id"], db_path=path))
assert empty_review["unregistered_liabilities"] is True
loan = ok(s.create_ledger_account("Synthetic loan", type="loan", liquidity_bucket="loan", db_path=path))["account"]["id"]
loan_draft = ok(s.create_settlement_draft("2026-10", "synthetic", "loan", db_path=path))["draft"]
loan_review = ok(s.review_settlement_draft(loan_draft["id"], db_path=path))
loan_item = next(item for item in loan_review["items"] if item["source_id"] == loan)
assert loan_item["amount"] is None
assert loan_review["unregistered_liabilities"] is False
manual = ok(s.create_manual_asset("Synthetic manual liability", 50, "2026-10-31", "loan", db_path=path))["manual_asset"]["id"]
refreshed = ok(s.refresh_settlement_draft_targets(loan_draft["id"], loan_draft["version"], "synthetic", db_path=path))["draft"]
manual_item = next(item for item in ok(s.get_settlement_draft(refreshed["id"], db_path=path))["items"] if item["source_id"] == manual)
excluded = ok(s.set_settlement_draft_item(refreshed["id"], "manual_asset", manual, refreshed["version"], "synthetic", included=False, exclusion_reason="synthetic excluded", db_path=path))["draft"]
excluded_review = ok(s.review_settlement_draft(excluded["id"], db_path=path))
assert manual_item["amount"] == 50
assert next(item for item in excluded_review["excluded_items"] if item["source_id"] == manual)
assert excluded_review["unregistered_liabilities"] is False
assert excluded_review["missing_items"]
def test_manual_only_frozen_liability_registration_and_missing_exclusion_are_independent(tmp_path):
path = str(tmp_path / "manual-only-frozen-liability.sqlite3")
init_db(path)
manual = ok(s.create_manual_asset("Manual-only debt", 75, "2026-10-31", "loan", db_path=path))["manual_asset"]["id"]
draft = ok(s.create_settlement_draft("2026-10", "synthetic", "manual-only", db_path=path))["draft"]
# Freeze the target list without any registered loan account.
frozen = ok(s.refresh_settlement_draft_targets(draft["id"], draft["version"], "synthetic", db_path=path))["draft"]
frozen_items = ok(s.get_settlement_draft(frozen["id"], db_path=path))["items"]
item = next(item for item in frozen_items if item["source_id"] == manual)
assert item["amount"] == 75
assert not any(item.get("source_type") == "account" for item in frozen_items)
review = ok(s.review_settlement_draft(frozen["id"], db_path=path))
assert review["unregistered_liabilities"] is False
assert isinstance(review["missing_items"], list)
excluded = ok(s.set_settlement_draft_item(frozen["id"], "manual_asset", manual, frozen["version"], "synthetic", included=False, exclusion_reason="not registered", db_path=path))["draft"]
excluded_review = ok(s.review_settlement_draft(excluded["id"], db_path=path))
assert any(item["source_id"] == manual for item in excluded_review["excluded_items"])
assert excluded_review["unregistered_liabilities"] is False
assert isinstance(excluded_review["missing_items"], list)
+166
View File
@@ -0,0 +1,166 @@
"""Exercise settlement MCP lifecycle through the repository's real stdio server."""
import asyncio
from contextlib import closing
import inspect
import json
import os
import sqlite3
import sys
from pathlib import Path
import pytest
mcp = pytest.importorskip('mcp')
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
OPS = ('create_settlement_draft', 'list_settlement_drafts', 'get_settlement_draft',
'update_settlement_draft', 'refresh_settlement_draft_targets',
'set_settlement_draft_item', 'review_settlement_draft',
'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft')
ROOT = Path(__file__).resolve().parents[1]
def payload(result):
assert not result.isError, result
assert len(result.content) == 1 and result.content[0].type == 'text'
data = json.loads(result.content[0].text)
assert isinstance(data, dict) and isinstance(data.get('success'), bool)
return data
def require_fields(value, fields, context):
missing = set(fields) - set(value)
assert not missing, f'{context} missing required public fields: {sorted(missing)}'
def test_required_public_field_assertion_detects_omission():
synthetic_contract_result = {'success': True, 'draft': {}}
require_fields(synthetic_contract_result, {'success', 'draft'}, 'stdio result')
dropped_field_copy = dict(synthetic_contract_result)
dropped_field_copy.pop('draft')
with pytest.raises(AssertionError, match='missing required public fields.*draft'):
require_fields(dropped_field_copy, {'success', 'draft'}, 'dropped-field mutation control')
def rows(db, table):
assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'}
with closing(sqlite3.connect(db)) as connection:
return connection.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
def test_real_stdio_startup_and_all_ten_lifecycle_tools(tmp_path):
asyncio.run(_exercise_stdio_lifecycle(tmp_path))
async def _exercise_stdio_lifecycle(tmp_path):
from jarca_ledger_mcp import service as service
db = tmp_path / 'mcp-runtime.sqlite3'
env = os.environ.copy()
env.update(JARCA_LEDGER_DB_PATH=str(db), JARCA_MCP_TRANSPORT='stdio', PYTHONDONTWRITEBYTECODE='1')
# The test owns this bounded stdio child; stdio_client reaps it on context exit.
params = StdioServerParameters(command=sys.executable, args=['-B', '-m', 'jarca_ledger_mcp.server'], env=env, cwd=str(ROOT))
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
initialized = await session.initialize()
assert initialized.serverInfo.name == 'jarca-ledger-mcp'
listed = await session.list_tools()
tool_map = {tool.name: tool for tool in listed.tools}
assert set(OPS) <= set(tool_map)
for name in OPS:
schema = tool_map[name].inputSchema
props = schema.get('properties', {})
assert 'db_path' not in props
parameters = inspect.signature(getattr(service, name)).parameters
assert set(props) == set(parameters) - {'db_path'}
assert set(schema.get('required', [])) == {key for key, value in parameters.items()
if key != 'db_path' and value.default is inspect.Parameter.empty}
# Seed through a real service database setup, never through MCP stubs.
from jarca_ledger_mcp.db import init_db
init_db(str(db))
account = service.create_ledger_account('Synthetic MCP cash', db_path=str(db))['account']['id']
service.register_account_valuation(account, '2026-09-30', 100, verification_status='verified', idempotency_key='mcp-value', db_path=str(db))
async def call(name, args):
return payload(await session.call_tool(name, args))
made = await call('create_settlement_draft', {'month':'2026-09','created_by':'stdio-test','idempotency_key':'mcp-draft'})
assert made['success']
require_fields(made, {'success', 'draft', 'items', 'review'}, 'create stdio result')
assert made['draft']['status'] == 'active' and made['draft']['version'] == 1
draft_id = made['draft']['id']
listed_drafts = await call('list_settlement_drafts', {'month':'2026-09'})
require_fields(listed_drafts, {'success', 'drafts'}, 'list stdio result')
assert listed_drafts['success'] and listed_drafts['drafts'][0]['id'] == draft_id
fetched = await call('get_settlement_draft', {'draft_id':draft_id})
require_fields(fetched, {'success', 'draft', 'items', 'review'}, 'get stdio result')
assert fetched['success'] and fetched['draft']['id'] == draft_id and fetched['draft']['version'] == 1
draft = fetched['draft']
updated = await call('update_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test','memo':'transport lifecycle'})
require_fields(updated, {'success', 'draft', 'items', 'review'}, 'update stdio result')
assert updated['success'] and updated['draft']['memo'] == 'transport lifecycle' and updated['draft']['version'] == 2
draft = updated['draft']
refreshed = await call('refresh_settlement_draft_targets', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test'})
require_fields(refreshed, {'success', 'draft', 'items', 'review'}, 'refresh stdio result')
assert refreshed['success'] and refreshed['draft']['version'] == 3
draft = refreshed['draft']
changed = await call('set_settlement_draft_item', {'draft_id':draft_id,'source_type':'account','source_id':account,'expected_version':draft['version'],'selected_by':'stdio-test','valuation_id':service.list_account_valuations(account, db_path=str(db))['valuations'][0]['id']})
require_fields(changed, {'success', 'draft', 'items', 'review'}, 'set-item stdio result')
assert changed['success'] and changed['draft']['version'] == 4
draft = changed['draft']
reviewed = await call('review_settlement_draft', {'draft_id':draft_id})
assert reviewed['success'], reviewed
require_fields(reviewed, {'success', 'draft_id', 'version', 'review_fingerprint', 'items', 'included_items', 'excluded_items', 'missing_items', 'warnings', 'approval_required', 'totals', 'complete', 'contains_substitutions', 'contains_carried', 'has_approved_missing', 'unregistered_liabilities'}, 'review stdio result')
selected = reviewed['included_items'][0]
for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'verification_status', 'evidence_kind', 'selection_mode', 'selection_reason', 'selected_by', 'selected_at', 'item_version'):
assert field in selected, f'review selected evidence missing {field}'
assert selected['valuation_date'] == '2026-09-30' and selected['age_days'] == 0 and selected['date_difference_days'] == 0
fingerprint = reviewed['review_fingerprint']
approved = await call('approve_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'approved_by':'stdio-test','approval_reason':'transport acceptance','approval_reference':'test'})
require_fields(approved, {'success', 'draft', 'items', 'review'}, 'approve stdio result')
require_fields(approved['draft'], {'approved_by', 'approved_at', 'approval_reason', 'approval_reference', 'approval_flags'}, 'approved draft')
assert approved['success']
assert approved['draft']['approved_by'] == 'stdio-test' and approved['draft']['approval_reason'] == 'transport acceptance' and approved['draft']['approval_reference'] == 'test'
closed = await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'})
require_fields(closed, {'success', 'draft_id', 'draft_version', 'review_fingerprint', 'snapshot_run_id', 'run_id', 'monthly_source_snapshot_id', 'basis_date', 'close_status', 'closed_at', 'source', 'is_closed_snapshot', 'selection_policy', 'inserted_count', 'inserted_snapshot_ids', 'items', 'excluded_items', 'totals', 'contains_substitutions', 'contains_carried', 'has_approved_missing', 'settlement_label', 'warnings', 'carried_items', 'approval', 'correction', 'is_correction', 'correction_of_run_id', 'correction_reason', 'provenance', 'total_assets', 'total_liabilities', 'net_worth'}, 'finalize stdio result')
assert closed['success'] and closed['totals']['net_worth'] == 100
assert closed['source'] == 'snapshot' and closed['is_closed_snapshot'] is True
assert closed['snapshot_run_id'] == closed['run_id'] == closed['monthly_source_snapshot_id']
assert closed['approval']['approved_by'] == 'stdio-test'
assert closed['approval']['allow_missing_exclusions'] is False and closed['approval']['allow_carried_before'] is False and closed['approval']['allow_manual_substitute'] is False
item = closed['items'][0]
for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'verification_status', 'evidence_kind', 'approval', 'draft_id', 'draft_version', 'review_fingerprint'):
assert field in item, f'finalized selected evidence missing {field}'
provenance = closed['provenance']
for field in ('valuation_date', 'age_days', 'carried_forward', 'verification_status', 'evidence_kind', 'approval', 'correction', 'warnings', 'exclusions'):
assert field in provenance, f'final provenance missing {field}'
assert provenance['valuation_date'] == item['valuation_date'] and provenance['age_days'] == item['age_days']
before = (await call('get_settlement_draft', {'draft_id':draft_id}))
tables = ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')
before_replay = {table: rows(db, table) for table in tables}
assert await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) == closed
archived = await call('get_settlement_draft', {'draft_id':draft_id})
require_fields(archived, {'success', 'draft', 'items', 'final_response'}, 'archived get stdio result')
assert archived['final_response'] == closed
assert archived == before
assert {table: rows(db, table) for table in tables} == before_replay
second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'})
assert second['success']
cancelled = await call('cancel_settlement_draft', {'draft_id':second['draft']['id'],'expected_version':second['draft']['version'],'cancelled_by':'stdio-test','reason':'synthetic cancellation'})
require_fields(cancelled, {'success', 'draft', 'items', 'review'}, 'cancel stdio result')
assert cancelled['success'] and cancelled['draft']['status'] == 'cancelled'
assert cancelled['draft']['cancelled_by'] == 'stdio-test' and cancelled['draft']['cancellation_reason'] == 'synthetic cancellation'
# Legacy close retains required contract fields; this is a subset check, not exact whole-key equality.
legacy_account = service.create_ledger_account('Synthetic legacy cash', db_path=str(db))['account']['id']
service.register_account_valuation(legacy_account, '2026-11-30', 31, verification_status='verified', idempotency_key='legacy-value', db_path=str(db))
preview = await call('preview_asset_snapshot_close', {'basis_date':'2026-11-30','selection_policy':'exact'})
assert preview['success'] and preview['source'] == 'live_authority' and preview['is_closed_snapshot'] is False
legacy = await call('close_asset_snapshot', {'basis_date':'2026-11-30','selection_policy':'exact','idempotency_key':'legacy-close','allow_missing_exclusions':True,'approved_by':'stdio-test','approval_reason':'legacy transport test','approval_reference':'legacy-test'})
legacy_fields = {'snapshot_run_id', 'close_status', 'approval', 'correction', 'warnings', 'excluded_items', 'carried_items', 'provenance', 'valuation_date', 'age_days', 'verification_status', 'evidence_kind', 'monthly_source_snapshot_id'}
require_fields(legacy, {'success'} | legacy_fields, 'legacy close stdio result')
assert not ({'draft_id', 'draft_version', 'review_fingerprint', 'contains_substitutions', 'contains_carried', 'has_approved_missing'} & set(legacy))
assert legacy['monthly_source_snapshot_id'] == legacy['snapshot_run_id']
@@ -0,0 +1,58 @@
"""Operator feedback distinguishes read failures and partially committed saves."""
import pytest
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
from jarca_ledger_mcp.db import init_db
def panel(monkeypatch, path):
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
app = AppTest.from_string('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n').run(timeout=20)
assert not app.exception
return app
def control(elements, label):
return next(element for element in elements if element.label == label)
def test_panel_shows_safe_error_when_draft_listing_fails(tmp_path, monkeypatch):
path = tmp_path / 'missing.sqlite3'
monkeypatch.setattr(settlement_gateway, 'list_settlement_drafts', lambda *args, **kwargs: {
'success': False, 'error_code': 'storage_error', 'message': 'SYNTHETIC_PRIVATE_DIAGNOSTIC',
})
app = panel(monkeypatch, path)
assert app.error and 'storage_error' in ' '.join(element.value for element in app.error)
assert not any('초안이 없습니다' in element.value for element in app.info)
visible = ' '.join(element.value for elements in (app.error, app.info, app.caption) for element in elements)
assert 'SYNTHETIC_PRIVATE_DIAGNOSTIC' not in visible
assert not path.exists()
def test_panel_reports_partial_save_when_item_update_fails(tmp_path, monkeypatch):
path = str(tmp_path / 'partial.sqlite3')
init_db(path)
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
assert s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified',
idempotency_key='synthetic-value', db_path=path)['success']
app = panel(monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
review_key = f"review_result_{draft['id']}"
assert app.session_state[review_key]['success']
control(app.text_input, '초안 메모 저장').set_value('Synthetic persisted memo')
control(app.text_input, '대상 ID').set_value('synthetic-nonexistent-target')
control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20)
assert not app.exception
current = s.get_settlement_draft(draft['id'], db_path=path)
assert current['draft']['memo'] == 'Synthetic persisted memo'
assert current['draft']['version'] == draft['version'] + 1
assert len(current['items']) == 1 and current['items'][0]['source_id'] == account
assert app.error
assert any('메모는 저장되었지만 항목 변경은 실패했습니다' in element.value for element in app.warning)
with pytest.raises(KeyError):
app.session_state[review_key]
assert not any(element.label in {'검토 결과 승인', '최종 마감'} for element in app.button)
@@ -0,0 +1,154 @@
"""Real AppTest controls use synthetic SQLite, not mocked gateway results.
These are lower-level panel tests; existing dashboard authentication suites own
entrypoint authentication acceptance.
"""
import pytest
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def control(elements, label):
return next(e for e in elements if e.label == label)
def app_for(tmp_path, monkeypatch, path):
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
appfile = tmp_path / 'panel.py'
appfile.write_text('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n')
app = AppTest.from_file(str(appfile)).run(timeout=20)
assert not app.exception
return app
def test_panel_selects_after_date_evidence_approves_and_finalizes_actual_database(tmp_path, monkeypatch):
path = str(tmp_path / 'panel.sqlite3')
init_db(path)
account = s.create_ledger_account('Synthetic retirement', type='investment', liquidity_bucket='investment', db_path=path)['account']['id']
value = s.register_account_valuation(account, '2026-10-05', 100, valuation_kind='total_value', verification_status='verified', idempotency_key='future', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.text_input, '대상 ID').set_value(account)
control(app.text_input, '평가자료 ID (비우면 해제)').set_value(value)
control(app.selectbox, '선택 방식').select('manual_substitute')
control(app.text_input, '선택 사유').set_value('Synthetic late statement')
control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20)
assert not app.exception and not app.error
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
assert any('대체 평가자료를 포함한 월정산' in w.value for w in app.warning)
control(app.text_input, '승인 사유').set_value('Synthetic reviewer accepted date')
control(app.text_input, '승인 근거').set_value('Synthetic reference')
control(app.checkbox, '대체자료 승인').check()
control(app.button, '검토 결과 승인').click().run(timeout=20)
assert not app.exception and not app.error
control(app.button, '최종 마감').click().run(timeout=20)
assert not app.exception and not app.error
# AppTest 1.58 retains pre-rerun controls in the click-run delta tree,
# including in a flat synthetic app. Check immediate history here;
# control absence below uses a fresh instance, not a claim about live DOM.
assert any('불변 이력' in e.value for e in app.markdown)
drafts = s.list_settlement_drafts('2026-09', db_path=path)['drafts']
assert len(drafts) == 1 and drafts[0]['status'] == 'completed'
final = s.get_settlement_draft(drafts[0]['id'], db_path=path)['final_response']
assert final['totals']['net_worth'] == 100 and final['contains_substitutions']
assert final['items'][0]['selection_reason'] == 'Synthetic late statement'
# Reusing the stale tree also raises KeyError for removed selectbox state.
# Reopen the real panel against the same completed SQLite draft instead.
app = app_for(tmp_path, monkeypatch, path)
assert not app.exception and '불변 이력' in ' '.join(e.value for e in app.markdown)
assert not any(e.label == '최종 마감' for e in app.button)
assert not any(e.label == '검토 결과 승인' for e in app.button)
assert not any('현재 승인 대상 fingerprint:' in e.value for e in app.caption)
def test_panel_valuation_listing_shows_real_selected_evidence_ids(tmp_path, monkeypatch):
path = str(tmp_path / 'list.sqlite3')
init_db(path)
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.text_input, '대상 ID').set_value(account)
control(app.button, '평가자료 조회').click().run(timeout=20)
assert not app.exception
assert vid in ' '.join(e.value for e in app.json)
def test_panel_supports_investment_total_and_explicit_loan_definition(tmp_path, monkeypatch):
path = str(tmp_path / 'register.sqlite3')
init_db(path)
loan = s.create_ledger_account('Synthetic loan', type='loan', liquidity_bucket='loan', db_path=path)['account']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
assert {'total_value', 'cash_balance'} <= set(control(app.selectbox, '평가 종류').options)
control(app.text_input, '평가 등록 계좌 ID').set_value(loan)
control(app.text_input, '평가일 YYYY-MM-DD').set_value('2026-09-30')
control(app.number_input, '금액').set_value(40)
control(app.selectbox, '평가 종류').select('loan_balance')
control(app.selectbox, '대표 부채 정의').select('principal_only')
control(app.selectbox, '확인 상태').select('verified')
control(app.text_input, '등록 idempotency key').set_value('synthetic-loan-value')
control(app.button, '평가자료 등록').click().run(timeout=20)
assert not app.exception and not app.error
values = s.list_account_valuations(loan, db_path=path)['valuations']
assert len(values) == 1 and values[0]['representative_liability_definition'] == 'principal_only'
assert values[0]['amount'] == 40
def test_panel_edit_invalidates_cached_review_before_approval(tmp_path, monkeypatch):
path = str(tmp_path / 'edit.sqlite3')
init_db(path)
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
key = f"review_result_{draft['id']}"
old = app.session_state[key]['review_fingerprint']
assert any(old in e.value and '현재 승인 대상' in e.value for e in app.caption)
control(app.text_input, '초안 메모 저장').set_value('Synthetic edited memo')
control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20)
assert not app.exception and not app.error
assert not any(old in e.value and '현재 승인 대상' in e.value for e in app.caption)
assert not any(e.label == '검토 결과 승인' for e in app.button)
assert not any(e.label == '최종 마감' for e in app.button)
with pytest.raises(KeyError):
app.session_state[key]
current = s.get_settlement_draft(draft['id'], db_path=path)['draft']
assert current['version'] == draft['version'] + 1 and current['memo'] == 'Synthetic edited memo'
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.text_input, '승인 사유').set_value('Synthetic fresh review')
control(app.text_input, '승인 근거').set_value('Synthetic reference')
control(app.button, '검토 결과 승인').click().run(timeout=20)
assert not app.exception and not app.error
control(app.button, '최종 마감').click().run(timeout=20)
assert not app.exception and not app.error
assert s.get_settlement_draft(draft['id'], db_path=path)['draft']['status'] == 'completed'
def test_panel_correction_invalidates_review_without_registration_account_input(tmp_path, monkeypatch):
path = str(tmp_path / 'correction.sqlite3')
init_db(path)
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
old = app.session_state[f"review_result_{draft['id']}"]['review_fingerprint']
assert control(app.text_input, '평가 등록 계좌 ID').value == ''
control(app.text_input, '정정할 valuation ID').set_value(vid)
control(app.number_input, '정정 금액').set_value(20)
control(app.text_input, '평가 정정 사유').set_value('Synthetic correction')
control(app.text_input, '평가 정정 키').set_value('synthetic-child')
control(app.selectbox, '정정 확인 상태').select('verified')
control(app.button, '평가자료 정정').click().run(timeout=20)
assert not app.exception and not app.error
assert not any(old in e.value and '현재 승인 대상' in e.value for e in app.caption)
assert not any(e.label == '검토 결과 승인' for e in app.button)
detail = s.get_settlement_draft(draft['id'], db_path=path)
assert detail['items'][0]['review_needed'] and detail['items'][0]['valuation_id'] == vid
assert len(s.list_account_valuations(account, db_path=path)['valuations']) == 2
@@ -0,0 +1,290 @@
"""Public-API regressions for frozen settlement-draft policy consistency."""
import json
import sqlite3
import pytest
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(value):
assert value["success"], value
return value
def draft(path, month="2026-09"):
return ok(s.create_settlement_draft(month, "synthetic creator", "draft-key", db_path=path))["draft"]
def frozen_items(path, did):
with sqlite3.connect(path) as db:
return db.execute("SELECT source_id, evidence_json FROM settlement_draft_items WHERE draft_id=? ORDER BY source_id", (did,)).fetchall()
def approve(path, d, **flags):
r = ok(s.review_settlement_draft(d["id"], db_path=path))
ok(s.approve_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "reviewer", "synthetic approval", "ref", db_path=path, **flags))
return r
@pytest.mark.parametrize("debts", [(-40,), (40,), (40, -10, -15), (10, -40)])
def test_signed_manual_loan_totals_remain_consistent_across_close_and_reports(tmp_path, debts):
path = str(tmp_path / "signed-loans.sqlite3")
init_db(path)
ids = [ok(s.create_manual_asset(f"debt {i}", amount, "2026-09-30", "loan", db_path=path))["manual_asset"]["id"] for i, amount in enumerate(debts)]
ok(s.create_manual_asset("asset", 100, "2026-09-30", "real_estate", db_path=path))
with sqlite3.connect(path) as db:
before = db.execute("SELECT id, valuation_amount, currency, source_note, memo, valuation_date FROM manual_assets ORDER BY id").fetchall()
d = draft(path)
frozen_before = frozen_items(path, d["id"])
review = ok(s.review_settlement_draft(d["id"], db_path=path))
liabilities = abs(sum(debts)); net = 100 - liabilities
for totals in (review["totals"], review):
assert totals["total_assets"] == 100
assert totals["total_liabilities"] == liabilities
assert totals["net_worth"] == net
ok(s.approve_settlement_draft(d["id"], d["version"], review["review_fingerprint"], "reviewer", "synthetic review", "ref", db_path=path))
closed = ok(s.finalize_settlement_draft(d["id"], d["version"], review["review_fingerprint"], "close-key", db_path=path))
for totals in (closed["totals"], closed):
assert totals["total_assets"] == 100 and totals["total_liabilities"] == liabilities and totals["net_worth"] == net
lookup = ok(s.get_settlement_draft(d["id"], db_path=path))
assert lookup["final_response"]["totals"] == closed["totals"]
replay = s.finalize_settlement_draft(d["id"], d["version"], review["review_fingerprint"], "close-key", db_path=path)
assert {k:v for k,v in replay.items() if k != "already_existing"} == {k:v for k,v in closed.items() if k != "already_existing"}
report = ok(s.get_asset_snapshot_report("2026-09-30", db_path=path))
assert (report["total_assets"], report["total_liabilities"], report["net_worth"]) == (100, liabilities, net)
assert report["close_metadata"]["draft_id"] == d["id"]
point = ok(s.get_net_worth_timeseries("2026-09-01", "2026-09-30", db_path=path))["points"][0]
assert (point["total_assets"], point["total_liabilities"], point["net_worth"]) == (100, liabilities, net)
monthly = ok(s.get_monthly_settlement_summary("2026-09", db_path=path))
assert monthly["source_coverage"]["assets"]["draft_id"] == d["id"]
with sqlite3.connect(path) as db:
after = db.execute("SELECT id, valuation_amount, currency, source_note, memo, valuation_date FROM manual_assets ORDER BY id").fetchall()
assert after == before and len(ids) == len(debts)
assert frozen_items(path, d["id"]) == frozen_before
assert "evidence_json" not in closed
def test_lowercase_supported_currency_normalizes_without_mutating_frozen_evidence(tmp_path):
path = str(tmp_path / "lowercase-currency.sqlite3")
init_db(path)
asset = ok(s.create_manual_asset("Synthetic KRW asset", 25, "2026-09-30", "real_estate", currency="krw", db_path=path))["manual_asset"]["id"]
with sqlite3.connect(path) as db:
raw_before = db.execute("SELECT valuation_amount, currency, source_note, memo FROM manual_assets WHERE id=?", (asset,)).fetchone()
assert raw_before[1] == "krw"
d = draft(path)
r = ok(s.review_settlement_draft(d["id"], db_path=path))
item = next(x for x in r["items"] if x["source_id"] == asset)
assert item["currency"] == "KRW" and item["amount"] == 25
assert not r["missing_items"] and not any(w.get("code") == "non_krw" for w in r["warnings"])
ok(s.approve_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "reviewer", "approve", "ref", db_path=path))
closed = ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "close", db_path=path))
frozen = dict(frozen_items(path, d["id"]))
assert json.loads(frozen[asset])["source"]["currency"] == "krw"
assert "evidence_json" not in closed
assert closed["totals"]["total_assets"] == closed["total_assets"] == 25
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == closed
assert ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "close", db_path=path)) == closed
assert ok(s.get_asset_snapshot_report("2026-09-30", db_path=path))["total_assets"] == 25
with sqlite3.connect(path) as db:
assert db.execute("SELECT valuation_amount, currency, source_note, memo FROM manual_assets WHERE id=?", (asset,)).fetchone() == raw_before
def test_verified_cash_balance_can_be_explicitly_reselected_without_losing_authority(tmp_path):
path = str(tmp_path / "cash-reselection.sqlite3")
init_db(path)
account = ok(s.create_ledger_account("Synthetic bank", type="bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
val = ok(s.register_account_valuation(account, "2026-09-30", 20, valuation_kind="cash_balance", verification_status="verified", idempotency_key="cash", db_path=path))["valuation"]["id"]
d = draft(path)
r = ok(s.review_settlement_draft(d["id"], db_path=path))
row = next(x for x in r["included_items"] if x["source_id"] == account)
assert row["valuation_id"] == val and row["amount"] == 20
selected = ok(s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, db_path=path))["draft"]
r2 = ok(s.review_settlement_draft(selected["id"], db_path=path))
row2 = next(x for x in r2["included_items"] if x["source_id"] == account)
assert row2["valuation_id"] == val and row2["amount"] == 20
ok(s.approve_settlement_draft(selected["id"], selected["version"], r2["review_fingerprint"], "reviewer", "approve", "ref", db_path=path))
final = ok(s.finalize_settlement_draft(selected["id"], selected["version"], r2["review_fingerprint"], "cash-close", db_path=path))
assert final["totals"]["total_assets"] == 20
assert ok(s.get_settlement_draft(selected["id"], db_path=path))["final_response"]["totals"] == final["totals"]
@pytest.mark.parametrize("mode,when,flags", [("exact", "2026-09-30", {}), ("carried_before", "2026-09-29", {"allow_carried_before": True}), ("manual_substitute", "2026-10-02", {"allow_manual_substitute": True})])
@pytest.mark.parametrize("kind", ["balance", "cash_balance"])
def test_bank_cash_balance_selection_modes_preserve_explicit_authority(tmp_path, mode, when, flags, kind):
path = str(tmp_path / (mode + ".sqlite3"))
init_db(path)
account = ok(s.create_ledger_account("Synthetic bank", type="bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
val = ok(s.register_account_valuation(account, when, 20, valuation_kind=kind, verification_status="verified", idempotency_key="cash", db_path=path))["valuation"]["id"]
d = draft(path)
kwargs = {"selection_mode": mode, "selection_reason": "dated bank statement"} if mode != "exact" else {}
d = ok(s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, db_path=path, **kwargs))["draft"]
r = ok(s.review_settlement_draft(d["id"], db_path=path))
row = next(x for x in r["included_items"] if x["source_id"] == account)
assert row["valuation_id"] == val and row["amount"] == 20
frozen_before = frozen_items(path, d["id"])
assert row["valuation_date"] == when
ok(s.approve_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "reviewer", "accepted dated evidence", "ref", db_path=path, **flags))
final = ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "mode-close", db_path=path))
assert final["totals"]["total_assets"] == final["total_assets"] == 20
assert final["contains_substitutions"] == (mode == "manual_substitute")
assert final["contains_carried"] == (mode == "carried_before")
assert frozen_items(path, d["id"]) == frozen_before
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == final
assert ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "mode-close", db_path=path)) == final
def test_invalid_future_exact_and_unverified_bank_candidate_cannot_supply_authority(tmp_path):
path = str(tmp_path / "invalid-bank.sqlite3")
init_db(path)
account = ok(s.create_ledger_account("Synthetic bank", type="bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
future = ok(s.register_account_valuation(account, "2026-10-02", 20, valuation_kind="cash_balance", verification_status="verified", idempotency_key="future", db_path=path))["valuation"]["id"]
d = draft(path)
bad = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=future, selection_mode="exact", db_path=path)
assert not bad["success"] and bad["error_code"] == "validation_error"
assert ok(s.get_settlement_draft(d["id"], db_path=path))["draft"]["version"] == d["version"]
@pytest.mark.parametrize("currency,amount,expected", [("USD", 25, None), ("KRW", 0, 0), ("KRW", None, None), (" krw ", 25, None)])
def test_manual_currency_and_missing_value_boundaries_match_legacy_policy(tmp_path, currency, amount, expected):
path = str(tmp_path / "manual-boundary.sqlite3")
init_db(path)
asset = ok(s.create_manual_asset("Synthetic boundary", amount, "2026-09-30", "real_estate", currency=currency, db_path=path))["manual_asset"]["id"]
d = draft(path)
before = frozen_items(path, d["id"])
r = ok(s.review_settlement_draft(d["id"], db_path=path))
row = next(x for x in r["items"] if x["source_id"] == asset)
assert row["amount"] == expected
assert row["status"] == ("missing" if expected is None else "ready")
assert r["total_assets"] == (expected or 0)
assert frozen_items(path, d["id"]) == before
@pytest.mark.parametrize("mode,when,reason", [("exact", "2026-10-02", ""), ("carried_before", "2026-09-30", "carry"), ("carried_before", "2026-10-02", "carry"), ("manual_substitute", "2026-10-02", "")])
def test_invalid_bank_date_or_missing_reason_keeps_draft_unchanged(tmp_path, mode, when, reason):
path = str(tmp_path / "invalid-selection.sqlite3")
init_db(path)
account = ok(s.create_ledger_account("Synthetic bank", db_path=path))["account"]["id"]
val = ok(s.register_account_valuation(account, when, 20, valuation_kind="cash_balance", verification_status="verified", db_path=path))["valuation"]["id"]
d = draft(path)
before = ok(s.get_settlement_draft(d["id"], db_path=path))
raw = frozen_items(path, d["id"])
result = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, selection_mode=mode, selection_reason=reason, db_path=path)
assert not result["success"] and result["error_code"] == "validation_error"
assert ok(s.get_settlement_draft(d["id"], db_path=path)) == before
assert frozen_items(path, d["id"]) == raw
def test_unverified_bank_evidence_remains_missing_after_explicit_review_selection(tmp_path):
path = str(tmp_path / "unverified.sqlite3")
init_db(path)
account = ok(s.create_ledger_account("Synthetic bank", db_path=path))["account"]["id"]
val = ok(s.register_account_valuation(account, "2026-09-30", 20, valuation_kind="cash_balance", verification_status="unverified", db_path=path))["valuation"]["id"]
d = draft(path)
r = ok(s.review_settlement_draft(d["id"], db_path=path))
assert r["items"][0]["valuation_id"] == "" and r["items"][0]["candidate_amount"] == 20
assert r["items"][0]["amount"] is None
rejected = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, db_path=path)
assert not rejected["success"] and rejected["error_code"] == "validation_error"
d = ok(s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, selection_mode="manual_substitute", selection_reason="review only", db_path=path))["draft"]
r = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
assert r["items"][0]["amount"] is None and r["total_assets"] == 0
closed = ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "unverified-close", db_path=path))
assert closed["total_assets"] == closed["totals"]["total_assets"] == 0
assert closed["excluded_items"][0]["amount"] is None
with sqlite3.connect(path) as db:
assert db.execute("SELECT verification_status FROM account_valuations WHERE id=?", (val,)).fetchone()[0] == "unverified"
@pytest.mark.parametrize("account_type,kind,definition,registration_allowed,selection_allowed", [("bank", "total_value", None, True, False), ("investment", "loan_balance", "principal_only", False, False), ("loan", "total_value", None, False, False), ("loan", "balance", None, False, False), ("loan", "balance", "principal_only", True, True), ("investment", "total_value", None, True, True)])
def test_account_type_purpose_and_representative_liability_boundaries(tmp_path, account_type, kind, definition, registration_allowed, selection_allowed):
path = str(tmp_path / "purpose.sqlite3")
init_db(path)
account = ok(s.create_ledger_account("Synthetic purpose", type=account_type, db_path=path))["account"]["id"]
registered = s.register_account_valuation(account, "2026-09-30", 20, valuation_kind=kind, representative_liability_definition=definition, verification_status="verified", db_path=path)
assert registered["success"] == registration_allowed
if not registration_allowed:
assert registered["error_code"] == "validation_error"
with sqlite3.connect(path) as db:
assert db.execute("SELECT COUNT(*) FROM account_valuations").fetchone()[0] == 0
return
d = draft(path)
before = ok(s.get_settlement_draft(d["id"], db_path=path))
selected = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=registered["valuation"]["id"], db_path=path)
assert selected["success"] == selection_allowed
if not selection_allowed:
assert selected["error_code"] == "validation_error"
assert ok(s.get_settlement_draft(d["id"], db_path=path)) == before
@pytest.mark.parametrize("amount", [None, True, 1.5])
def test_account_valuation_amount_requires_a_strict_integer(tmp_path, amount):
path = str(tmp_path / "strict-amount.sqlite3")
init_db(path)
account = ok(s.create_ledger_account("Synthetic strict", db_path=path))["account"]["id"]
rejected = s.register_account_valuation(account, "2026-09-30", amount, verification_status="verified", db_path=path)
assert not rejected["success"] and rejected["error_code"] == "validation_error"
with sqlite3.connect(path) as db:
assert db.execute("SELECT COUNT(*) FROM account_valuations").fetchone()[0] == 0
def test_legacy_amount_approval_requires_fresh_review_after_policy_correction(tmp_path):
import hashlib
path = str(tmp_path / "legacy-approval.sqlite3")
init_db(path)
ok(s.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path))
ok(s.create_manual_asset("Synthetic signed debt", -40, "2026-09-30", "loan", db_path=path))
d = draft(path)
frozen_before = frozen_items(path, d["id"])
# Historical persisted fingerprint schema: immutable baseline _review serial
# did not include normalized totals. Reconstruct its exact manual-only inputs,
# rather than treating the current fingerprint as a historical approval.
with sqlite3.connect(path) as db:
db.row_factory = sqlite3.Row
stored = [dict(x) for x in db.execute("SELECT * FROM settlement_draft_items WHERE draft_id=? ORDER BY source_type,source_id", (d["id"],))]
states = []
for item in stored:
current = {"source": dict(db.execute("SELECT * FROM manual_assets WHERE id=?", (item["source_id"],)).fetchone())}
states.append({"stored": item, "current": current, "candidates": []})
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
serial = json.dumps({"draft_id": d["id"], "version": d["version"], "basis_date": d["basis_date"], "states": states, "warnings": [], "latest_run": "", "correction_parent": d["correction_of_run_id"]}, sort_keys=True, ensure_ascii=False)
legacy_fingerprint = hashlib.sha256(serial.encode()).hexdigest()
flags = {"allow_missing_exclusions": False, "allow_carried_before": False, "allow_manual_substitute": False}
with sqlite3.connect(path) as db:
db.execute("UPDATE settlement_drafts SET approval_fingerprint=?,approval_version=?,approved_by=?,approved_at=?,approval_reason=?,approval_reference=?,approval_flags=? WHERE id=?", (legacy_fingerprint, d["version"], "synthetic legacy reviewer", "2026-10-10T00:00:00Z", "reviewed legacy net 140", "synthetic reference", json.dumps(flags), d["id"]))
rejected = s.finalize_settlement_draft(d["id"], d["version"], legacy_fingerprint, "legacy-close", db_path=path)
assert not rejected["success"], "a historical amount approval must not authorize corrected totals"
assert rejected["error_code"] == "conflict"
with sqlite3.connect(path) as db:
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert db.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
row = db.execute("SELECT status,version,final_run_id FROM settlement_drafts WHERE id=?", (d["id"],)).fetchone()
assert row == ("active", d["version"], "")
assert frozen_items(path, d["id"]) == frozen_before
reviewed = approve(path, d)
assert reviewed["review_fingerprint"] != legacy_fingerprint
final = ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "legacy-close", db_path=path))
assert final["totals"] == {"total_assets": 100, "total_liabilities": 40, "net_worth": 60}
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == final
assert ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "legacy-close", db_path=path)) == final
assert frozen_items(path, d["id"]) == frozen_before
def test_completed_close_replay_uses_immutable_response_without_revalidation(tmp_path, monkeypatch):
from jarca_ledger_mcp.application import settlement_draft_service
path = str(tmp_path / "completed-replay.sqlite3")
init_db(path)
ok(s.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path))
d = draft(path)
reviewed = approve(path, d)
final = ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "completed-close", db_path=path))
def forbidden_review(*args):
raise AssertionError("completed history must not be reinterpreted")
monkeypatch.setattr(settlement_draft_service, "_review", forbidden_review)
assert ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "completed-close", db_path=path)) == final
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == final
@@ -0,0 +1,42 @@
"""Consumer contracts for immutable draft-close evidence."""
import json
import sqlite3
from jarca_ledger_mcp import service
from jarca_ledger_mcp.db import init_db
def test_closed_draft_evidence_is_additive_and_strictly_redacted(tmp_path):
path = str(tmp_path / "synthetic.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,draft_id,draft_version,review_fingerprint,applied_evidence,excluded_evidence,carried_evidence,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (
"run-draft", "2026-09-30", "conditional", "exact", "draft-1", 7, "fingerprint-1",
json.dumps([{"source_type":"manual_asset","source_id":"asset-1","name":"Synthetic","amount":100,"currency":"KRW","valuation_date":"2026-09-29","age_days":1,"carried_forward":True,"selection_mode":"carried_before","reason":"accepted","selected_by":"reviewer","selected_at":"2026-09-30T01:00:00Z","item_version":3,"verification_status":"verified","source_ref":"ref-1","source_attributes":{"bucket":"real_estate","secret":"hidden"},"approval":{"reference":"approval-1","memo":"hidden"},"unknown":{"credential":"hidden"}}]),
json.dumps([{"source_type":"account","source_id":"acct-1","reason":"approved_missing","approval_reference":"ref-2","unknown_secret":"hidden"}]),
json.dumps([{"source_type":"manual_asset","source_id":"asset-1","valuation_date":"2026-09-29","age_days":1,"carried_forward":True,"selection_mode":"carried_before","reason":"accepted","selected_by":"reviewer","selected_at":"2026-09-30T01:00:00Z","item_version":3,"verification_status":"verified","source_ref":"ref-1"}]),
"2026-09-30T02:00:00Z"))
conn.execute("INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,snapshot_run_id,valuation_id,created_at) VALUES('snap-1','2026-09-30','manual_asset','asset-1','Synthetic',100,'KRW','real_estate','run-draft','','2026-09-30T02:00:00Z')")
conn.commit()
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
assert report["success"]
metadata = report["close_metadata"]
assert (metadata["draft_id"], metadata["draft_version"], metadata["review_fingerprint"]) == ("draft-1", 7, "fingerprint-1")
assert metadata["contains_carried"] is True and metadata["contains_substitutions"] is False and metadata["has_approved_missing"] is True
evidence = metadata["applied_evidence"][0]
for key in ("valuation_date", "age_days", "selection_mode", "reason", "selected_by", "selected_at", "item_version", "verification_status", "source_ref", "source_attributes", "approval"):
assert key in evidence
serialized = json.dumps(report)
assert "hidden" not in serialized and "credential" not in serialized and "unknown" not in serialized
series = service.get_net_worth_timeseries("2026-09-01", "2026-09-30", db_path=path)
assert series["points"][0]["draft_id"] == "draft-1"
assert series["points"][0]["contains_carried"] is True
monthly = service.get_monthly_settlement_summary("2026-09", db_path=path)
assert monthly["source_coverage"]["assets"]["draft_id"] == "draft-1"
def test_active_cancelled_drafts_do_not_create_timeseries_points(tmp_path):
path = str(tmp_path / "synthetic.sqlite3")
init_db(path)
result = service.get_net_worth_timeseries("2026-09-01", "2026-09-30", db_path=path)
assert result["success"] and result["points"] == []
+129
View File
@@ -0,0 +1,129 @@
"""Strict selection, conditional authority and public close regressions."""
import pytest
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(r):
assert r['success'], r
return r
def setup(tmp_path, currency='KRW'):
path = str(tmp_path / 'scope.sqlite3')
init_db(path)
account = ok(s.create_ledger_account('Synthetic investment', type='investment', currency=currency, liquidity_bucket='investment', db_path=path))['account']['id']
return path, account
def valuation(path, account, key='v', **kwargs):
values = dict(basis_date='2026-09-30', amount=100, valuation_kind='total_value', verification_status='verified', idempotency_key=key, db_path=path)
values.update(kwargs)
return ok(s.register_account_valuation(account, **values))['valuation']['id']
def draft(path):
return ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft']
def select(path, d, account, value, **kwargs):
return s.set_settlement_draft_item(d['id'], 'account', account, d['version'], 'selector', valuation_id=value, db_path=path, **kwargs)
def test_automatic_selection_preserves_representative_priority_and_verified_status(tmp_path):
path, account = setup(tmp_path)
representative = valuation(path, account)
valuation(path, account, 'cash', amount=30, valuation_kind='cash_balance')
valuation(path, account, 'candidate', amount=900, verification_status='unverified')
d = draft(path)
review = ok(s.review_settlement_draft(d['id'], db_path=path))
row = next(x for x in review['items'] if x['source_id'] == account)
assert row['valuation_id'] == representative
assert review['totals']['net_worth'] == 100
def test_automatic_ambiguous_representative_remains_missing_until_explicit_choice(tmp_path):
path, account = setup(tmp_path)
chosen = valuation(path, account)
valuation(path, account, 'other', amount=200)
d = draft(path)
r = ok(s.review_settlement_draft(d['id'], db_path=path))
assert r['missing_items'] and r['totals']['net_worth'] == 0
selected = ok(select(path, d, account, chosen))['draft']
assert ok(s.review_settlement_draft(selected['id'], db_path=path))['totals']['net_worth'] == 100
@pytest.mark.parametrize('status,mode', [('unverified','exact'), ('unverified','carried_before'), ('rejected','manual_substitute')])
def test_invalid_verification_never_selected_as_confirmed_authority(tmp_path, status, mode):
path, account = setup(tmp_path)
value = valuation(path, account, verification_status=status, basis_date='2026-09-29' if mode == 'carried_before' else '2026-09-30')
d = draft(path)
result = select(path, d, account, value, selection_mode=mode, selection_reason='synthetic reason')
assert not result['success'], result
assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['version'] == d['version']
def test_non_krw_candidate_is_missing_null_authority_not_converted(tmp_path):
path, account = setup(tmp_path, currency='USD')
value = valuation(path, account, currency='USD')
d = draft(path)
ok(select(path, d, account, value))
r = ok(s.review_settlement_draft(d['id'], db_path=path))
assert r['missing_items'] and not r['included_items']
assert r['items'][0]['amount'] is None
assert r['items'][0]['candidate_amount'] == 100
assert r['totals']['net_worth'] == 0 and r['warnings']
def test_manual_loan_is_representative_liability_and_future_manual_is_not_substitute(tmp_path):
path = str(tmp_path / 'manual-loan.sqlite3')
init_db(path)
ok(s.create_manual_asset('Synthetic liability', 40, '2026-09-30', 'loan', db_path=path))
future = ok(s.create_manual_asset('Future property', 100, '2026-10-05', 'real_estate', db_path=path))['manual_asset']['id']
d = draft(path)
r = ok(s.review_settlement_draft(d['id'], db_path=path))
assert r['totals']['total_assets'] == 0 and r['totals']['total_liabilities'] == 40
result = s.set_settlement_draft_item(d['id'], 'manual', future, d['version'], 'selector', selection_mode='manual_substitute', selection_reason='not a valuation id', db_path=path)
assert not result['success'], result
@pytest.mark.parametrize('bad', [None, True, 1, [], {}])
def test_invalid_creator_is_rejected_before_database_creation(tmp_path, bad):
path = tmp_path / 'absent' / 'db.sqlite3'
result = s.create_settlement_draft('2026-09', bad, 'key', db_path=str(path))
assert not result['success'] and result['error_code'] == 'validation_error', result
assert not path.parent.exists()
@pytest.mark.parametrize('bad', ['yes', 1, None])
def test_approval_flags_require_actual_boolean_and_preserve_state(tmp_path, bad):
path, account = setup(tmp_path)
valuation(path, account)
d = draft(path)
r = ok(s.review_settlement_draft(d['id'], db_path=path))
result = s.approve_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'reviewer', 'synthetic reason', 'ref', allow_missing_exclusions=bad, db_path=path)
assert not result['success'] and result['error_code'] == 'validation_error', result
assert not ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['approved_by']
def test_finalized_substitute_retains_dates_flags_and_public_consumer_evidence(tmp_path):
path, account = setup(tmp_path)
v = valuation(path, account, basis_date='2026-10-05')
d = draft(path)
d = ok(select(path, d, account, v, selection_mode='manual_substitute', selection_reason='synthetic after-date'))['draft']
r = ok(s.review_settlement_draft(d['id'], db_path=path))
ok(s.approve_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'reviewer', 'substitution accepted', 'ref', allow_manual_substitute=True, db_path=path))
final = ok(s.finalize_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'final', db_path=path))
assert final['contains_substitutions'] is True
assert final['has_approved_missing'] is False
assert final['contains_carried'] is False
assert final['valuation_date'] and 'age_days' in final and 'verification_status' in final and 'evidence_kind' in final
report = ok(s.get_asset_snapshot_report('2026-09-30', db_path=path))
assert report['net_worth'] == 100
assert report['close_metadata']['contains_substitutions'] is True
evidence = report['close_metadata']['applied_evidence'][0]
assert evidence['valuation_date'] == '2026-10-05'
assert evidence['date_difference_days'] == 5 and evidence['selection_mode'] == 'manual_substitute'
assert evidence['selection_reason'] == 'synthetic after-date'
assert s.finalize_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'final', db_path=path) == final
Generated
+1 -1
View File
@@ -469,7 +469,7 @@ wheels = [
[[package]]
name = "jarca-ledger-mcp"
version = "0.1.4"
version = "0.1.6"
source = { editable = "." }
dependencies = [
{ name = "mcp" },