fix: preserve schema 15 compatibility for v0.1.8

This commit is contained in:
2026-10-17 18:27:53 +09:00
parent a97548c36b
commit 1134f058cc
61 changed files with 2650 additions and 644 deletions
+33 -16
View File
@@ -4,7 +4,7 @@
계좌 평가 증거, 수동 자산, append-only 자산 마감과 기존 ledger entities의 실제 SQLite 정본을 기록한다.
- 포함 범위: `accounts`, `transactions`, `manual_assets`, `account_valuations`, `account_snapshot`, `asset_snapshot_runs`, `reconciliation_sessions`, `review_decisions`, `import_batches`, `schema_meta`
- 포함 범위: `accounts`, `transactions`, `manual_assets`, `account_valuations`, `asset_snapshots`, `asset_snapshot_runs`, `reconciliation_sessions`, `review_decisions`, `import_batches`, `schema_meta`
- 제외 범위: auth cookie/session, external price/FX store, inferred transactions, actual credentials/data
## 정본 엔터티
@@ -21,7 +21,7 @@
|---|---|---|---|---|---|---|
| `id` | TEXT | 필수 | 없음 | generated account id | 식별자 | 아니오 |
| `name` | TEXT | 필수 | 없음 | non-empty | 없음 | 아니오 |
| `type` | TEXT | 필수 | `bank` | bank/card/cash/investment/loan/other/real_estate | enum | 아니오 |
| `type` | TEXT | 필수 | `bank` | bank/card/cash/investment/loan/real_estate/other | enum | 아니오 |
| `currency` | TEXT | 필수 | `KRW` | supported currency | ISO code | 아니오 |
| `last_balance` | INTEGER | 선택 | NULL | exact integer | currency unit | 아니오 |
| `last_balance_basis_date` | TEXT | 선택 | `''` | YYYY-MM-DD or unknown | date of the evidence represented by `last_balance`; empty/unknown is explicit | 아니오 |
@@ -169,11 +169,11 @@
## 검증
- 정상 데이터: `tests/test_account_valuation_service_actual.py`, `tests/test_account_snapshot_linking.py`
- 정상 데이터: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`
- 경계 데이터: NULL/0, stale carry-forward, correction, empty snapshot tables
- 오류 데이터: 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_valuation_service_actual.py tests/test_account_snapshot_linking.py`
- 마이그레이션: `test_*schema*` tests plus `python -m pytest tests/test_account_valuations.py tests/test_asset_snapshots.py`
## settlement-drafts — 월정산 초안
@@ -193,13 +193,7 @@ Frozen/public evidence retains `valuation_date`, original `recorded_at`, derived
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.
# Current schema 15 and historical compatibility
The local development source currently declares `SCHEMA_VERSION = "15"`. The published `v0.1.7` tag remains schema 14; this working tree is a dirty development state and does not establish a published schema-15 release. Historical schema 11–14 notes below are retained as migration context, not current schema claims.
Schema 15 renames `account_snapshot.month_settlement_id` in place to nullable `monthly_settlement_id`, preserving rows, and maps settlement cashflow rows to the canonical `monthly_settlement(id)` parent. The mapping is single-month and fails transactionally when ambiguous or missing. `approved_cashflow_fingerprint` is nullable legacy-compatible approval metadata; it is not a reserved future physical column.
# Issue #18 — schema 14 comprehensive settlement additions
# Issue #18 — schema 15 comprehensive settlement additions
### asset_snapshot_runs cashflow field contract
@@ -232,7 +226,8 @@ All twelve comprehensive-run columns are nullable with no SQL default. This pres
| Column | Type | Nullability | Default |
|---|---|---|---|
| `id` | TEXT | NULL | none |
| `monthly_settlement_id` | TEXT | NOT NULL | none |
| `monthly_settlement_id` | TEXT | NULL | none |
| `settlement_run_id` | TEXT | NULL | none |
| `cashflow_kind` | TEXT | NOT NULL | none |
| `category` | TEXT | NOT NULL | `''` |
| `subcategory` | TEXT | NOT NULL | `''` |
@@ -240,7 +235,7 @@ All twelve comprehensive-run columns are nullable with no SQL default. This pres
| `transaction_count` | INTEGER | NOT NULL | none |
| `currency` | TEXT | NOT NULL | none |
`id` is logically required although SQLite reports no explicit NOT NULL flag for this TEXT primary key. `monthly_settlement_id` references `monthly_settlement(id)` ON DELETE RESTRICT. The unique key is `(monthly_settlement_id, cashflow_kind, category, subcategory, currency)`. `transaction_count > 0` and amount/count are integers; `idx_settlement_cashflow_snapshots_month` indexes the canonical month.
`id` is logically required although SQLite reports no explicit NOT NULL flag for this TEXT primary key. Both parent columns are individually nullable: `monthly_settlement_id` references `monthly_settlement(id)` ON DELETE RESTRICT, and `settlement_run_id` references `asset_snapshot_runs(id)` ON DELETE RESTRICT. An exclusive-parent CHECK requires exactly one parent to be non-NULL. Separate UNIQUE constraints apply to `(monthly_settlement_id,cashflow_kind,category,subcategory,currency)` and `(settlement_run_id,cashflow_kind,category,subcategory,currency)`. `transaction_count > 0` and amount/count are integers. Fresh schema uses `idx_settlement_cashflow_snapshots_month` for canonical-month lookup; upgraded run-parent storage retains `idx_settlement_cashflow_snapshots_run` for legacy-run lookup.
### Comprehensive snapshot evidence inputs
@@ -250,10 +245,32 @@ Asset inputs preserve `source_id`, `source_type`, `valuation_id`, `valuation_dat
`asset_snapshot_runs` remains the run header. Schema 13→14 adds settlement month, currency, six nullable legacy-compatible INTEGER totals (`income_total`, `expense_total`, `net_cashflow`, `total_assets`, `total_liabilities`, `net_worth`), `cashflow_policy_json`, `aggregation_version`, `snapshot_schema_version`, and `source_snapshot_json`; preserve existing basis/approval/draft/correction/idempotency fields. Old asset-only run totals remain NULL; no current-ledger backfill. Migration is additive/idempotent; old unfinished drafts require fresh comprehensive review/approval.
`settlement_cashflow_snapshots`: eight columns `id`, `monthly_settlement_id`, `cashflow_kind`, `category`, `subcategory`, `amount`, `transaction_count`, `currency`; `cashflow_kind` is income/expense/excluded, `amount` signed INTEGER, count positive, category fields NOT NULL (empty string is explicit), FK to `monthly_settlement.id` with RESTRICT, unique `(monthly_settlement_id,cashflow_kind,category,subcategory,currency)`, and month lookup index. Rows are grouped details for exactly one canonical month.
`settlement_cashflow_snapshots`: nine columns `id`, `monthly_settlement_id`, `settlement_run_id`, `cashflow_kind`, `category`, `subcategory`, `amount`, `transaction_count`, `currency`; `cashflow_kind` is income/expense/excluded, `amount` signed INTEGER, count positive, and category fields are NOT NULL (empty string is explicit). The nullable parent columns reference `monthly_settlement.id` and `asset_snapshot_runs.id` with RESTRICT, while the exclusive-parent CHECK requires exactly one parent. The table has separate UNIQUE constraints on `(monthly_settlement_id,cashflow_kind,category,subcategory,currency)` and `(settlement_run_id,cashflow_kind,category,subcategory,currency)`, so rows remain grouped under exactly one canonical month or legacy run without merging the two parent domains.
Policy is stored on draft and header. Savings means only explicit category/subcategory pairs; transfer/voucher exclusion has priority; debt_payment is expense; refund is negative expense in refund month/original category. Unknown unresolved blocks approval; asset conditional flags cannot override it. KRW only/no FX. Source JSON freezes transaction values/classification/policy/decision and selected asset evidence/provenance. Six equations are validated against same-run details; excluded/duplicate/unknown amount do not sum. Header, both detail areas, evidence, completed draft linkage/response commit atomically; rollback all on failure. Runs are immutable; corrections append after latest-parent/single-child validation.
### monthly_settlement approval cashflow fingerprint
`monthly_settlement.approved_cashflow_fingerprint` is a nullable `TEXT` field used by the current approval/finalization flow to retain the approved cashflow fingerprint. It has no default; historical monthly settlements may remain `NULL`. It is distinct from the draft `review_fingerprint`.
### Additive schema 15 monthly lifecycle
`monthly_settlement` owns canonical month status, version, approvals and nullable
`approved_cashflow_fingerprint`. `account_snapshot.monthly_settlement_id` is a
nullable FK for canonical account evaluations. Legacy `asset_snapshots`,
`asset_snapshot_runs`, `manual_assets`, and `account_valuations` retain their
rows and contracts. `settlement_cashflow_snapshots` has nullable
`settlement_run_id` and `monthly_settlement_id` foreign keys, with exactly one
parent required and separate uniqueness constraints. Run corrections retain
separate frozen groups; migration never guesses a monthly parent from dates.
Schema 11–14 upgrades and schema 15 initialization are atomic and idempotent;
failed copies or foreign-key checks roll back all DDL and metadata.
### Immutable monthly settlement history read model
`asset_snapshot_runs` is the header source for append-only immutable settlement history.
Each public history row identifies `run_id`, `settlement_month`, frozen draft
`version`, `closed_at`, `close_status`, and optional correction lineage through
`correction_of_run_id` (published as `correction_target`) plus
`correction_reason` (published as `reason`). The exact-record read additionally
returns the frozen basis date and persisted cashflow/net-worth totals. Reads are
ordered newest-first and never synthesize totals from current transactions,
account values, or mutable draft state. A correction appends a new run and
retains the referenced run unchanged.
+32 -3
View File
@@ -341,7 +341,7 @@ loan accounts require representative_liability_definition for `loan`, `loan_bala
loan valuations map to liabilities and non-loan valuations map to assets. The output mapping preserves the existing envelopes and signatures: `success` remains the outcome field, failures include `error_code` and `error`, and validation or conflict failures do not partially write.
Account current-cache writes keep `last_balance_basis_date` (optional `YYYY-MM-DD`) separate from `last_synced_at` (execution/sync timestamp). Account types include `bank`, `card`, `cash`, `investment`, `loan`, `other`, and `real_estate`; a new `real_estate` account defaults its liquidity bucket to `real_estate` when no bucket is supplied. `create_ledger_account` keeps the established positional `db_path` argument and adds `last_balance_basis_date` after it as an optional keyword. `update_ledger_account` accepts the same field through its existing keyword-field boundary. A balance with no explicit basis advances only the current display cache, leaves the basis unknown, and creates no historical candidate. A dated older non-loan balance appends historical evidence but cannot roll back a newer current cache. loan cache writes without an explicit liability definition do not append a historical candidate; they preserve current-cache and import behavior without inferring a definition. Import and account writers use deterministic candidate keys.
Account current-cache writes keep `last_balance_basis_date` (optional `YYYY-MM-DD`) separate from `last_synced_at` (execution/sync timestamp). `create_ledger_account` keeps the established positional `db_path` argument and adds `last_balance_basis_date` after it as an optional keyword. `update_ledger_account` accepts the same field through its existing keyword-field boundary. A balance with no explicit basis advances only the current display cache, leaves the basis unknown, and creates no historical candidate. A dated older non-loan balance appends historical evidence but cannot roll back a newer current cache. loan cache writes without an explicit liability definition do not append a historical candidate; they preserve current-cache and import behavior without inferring a definition. Import and account writers use deterministic candidate keys.
### shared contract and adapter limits
@@ -392,7 +392,7 @@ preview_asset_snapshot_close(basis_date="2026-06-30", selection_policy="exact")
## 검증 추가
- 정상 계약: `tests/test_account_valuation_service_actual.py`, `tests/test_account_snapshot_linking.py`, `tests/test_comprehensive_settlement_adapters.py`
- 정상 계약: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`, `tests/test_contract_regression.py`
- 실패 계약: idempotency/correction/conditional approval tests and source validation
- 경계 계약: exact/as_of, NULL/0, future/unverified candidate, correction/re-close
- 구현·스키마·예시 정합성: `tests/test_requirement_docs.py`, actual service/MCP/gateway symbols
@@ -403,7 +403,7 @@ preview_asset_snapshot_close(basis_date="2026-06-30", selection_policy="exact")
The application contracts are these actual signatures (defaults included):
```python
register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None, db_path: str | None = None, supersedes_id: str | None = None, correction_reason: str = "") -> dict[str, Any]
register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None, db_path: str | None = None, supersedes_id: str | None = None, correction_reason: str = "", *, monthly_settlement_id: str | None = None, liquidity_bucket: str | None = None, selection_policy: str = "manual", valuation_source: str = "manual", source_note: str = "") -> dict[str, Any]
list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]
select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None, db_path: str | None = None) -> dict[str, Any]
correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any]
@@ -527,3 +527,32 @@ Saved reader root example:
```
A `validation_error` results when cashflow review blockers must be resolved before approval/finalize. Remediate policy or transaction classification, then re-review and re-approve. Examples use synthetic identifiers, not raw secrets.
Schema 15 preserves the positional v0.1.7 valuation API and manual asset and
snapshot run APIs. `register_account_valuation(..., monthly_settlement_id=...)`
explicitly selects canonical unverified account snapshot registration; omitted
monthly identity uses legacy valuation evidence and correction semantics.
Canonical monthly review/approval/close APIs remain separate from legacy draft
finalization. Neither finalizer rewrites the other's frozen records.
## Public immutable monthly settlement history
The read-only application contracts are:
```python
list_monthly_settlement_history(month: str = "", db_path: str | None = None) -> dict[str, Any]
get_monthly_settlement_history_record(run_id: str, db_path: str | None = None) -> dict[str, Any]
```
Both operations are public MCP tools. MCP signatures omit internal `db_path`:
the list schema exposes optional `month`, while the exact-record schema requires
`run_id`. Successful list responses contain `history`, normalized `month`, and
`read_only=true`; successful exact reads contain one `history` record and
`read_only=true`. The list is newest-first and exposes run/version, close time,
close status, and correction linkage without consulting or rewriting live data.
The authenticated Dashboard wrapper is unchanged. In the completed/cancelled
draft panel, `마감내역보기` opens read-only history state, lists immutable runs
for the draft month, lets the operator select a run, and fetches that exact
record. Opening or changing this selection performs no lifecycle mutation.
+2 -2
View File
@@ -140,8 +140,8 @@ jarca_ledger_mcp/
| `jarca_ledger_mcp/application/account_valuation_service.py` | source | validation, selection, idempotency, correction | named valuation functions | SQLite through shared db | source review + focused tests | developer |
| `jarca_ledger_mcp/application/assets_service.py` | source | preview, formula, close/correction | `preview_asset_snapshot_close`, `close_asset_snapshot` | snapshot transaction | source review + focused tests | developer |
| `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | source | schema 12 additive migration | `init_db`, `migrate_db` | no UI/MCP imports | migration review | developer |
| `tests/test_account_valuation_service_actual.py` | test | valuation and account snapshot contracts | pytest | synthetic DB | paired with source | developer |
| `tests/test_account_snapshot_linking.py` | test | preview/close/report contracts | pytest | synthetic DB | paired with source | developer |
| `tests/test_account_valuations.py` | test | valuation contracts | pytest | synthetic DB | paired with source | developer |
| `tests/test_asset_snapshots.py` | test | preview/close/report contracts | pytest | synthetic DB | paired with source | developer |
| `docs/data-model.md` | canonical doc | entity/schema authority | document | no secrets | update with schema | developer |
### 의존 방향 추가
@@ -15,8 +15,6 @@
# REQ-003 — 계좌 기준일 평가 이력과 자산 마감 연동
> **현재 구현 정합성 안내 (비규범적):** 승인된 REQ-003와 acceptance criteria는 보존한다. 현재 local dirty 개발 상태는 `SCHEMA_VERSION = "15"`이며, `v0.1.7` tag는 schema 14를 가리킨다. 따라서 이 문서는 published schema-15 release를 주장하지 않는다. `account_snapshot` 및 `monthly_settlement_id`는 현재 정본 명칭이고, schema 11–14 및 이전 명칭은 역사적 호환성 맥락이다. 이 안내는 새 acceptance criterion이 아니다.
## 1. 목적
계좌의 기준일 평가 증거를 append-only 이력으로 저장·선택하고, 검증 가능한 자산만 자산/마감 및 월정산의 역사적 순자산에 반영한다.
@@ -29,7 +27,7 @@
### 포함
- `account_valuations`, `asset_snapshot_runs`, `account_snapshot` 확장과 exact/as_of 선택
- `account_valuations`, `asset_snapshot_runs`, `asset_snapshots` 확장과 exact/as_of 선택
- idempotency, append-only correction, 투자 optional metrics, loan 대표 부채
- read-only preview, explicit approval conditional close, immutable evidence, correction/re-close
- 계좌·import 후보, reconciliation, MCP·Dashboard·월정산 공통 read model
@@ -54,7 +52,7 @@
### REQ-003.01 — additive 이력과 legacy 보존
schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 13의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `account_snapshot`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다. Issue #18의 유일한 schema 확장은 additive/idempotent 13→14이며 기존 자산 전용 run의 미저장 종합 총계는 NULL로 유지하고 backfill하지 않는다.
schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 13의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다. Issue #18의 유일한 schema 확장은 additive/idempotent 13→14이며 기존 자산 전용 run의 미저장 종합 총계는 NULL로 유지하고 backfill하지 않는다.
**영향 범위**
- 인터페이스: 기존 service/MCP 응답 envelope 유지, 신규 valuation/snapshot fields additive
@@ -133,7 +131,7 @@ preview는 DB를 쓰지 않고 `complete_exact`, `incomplete_draft`, `conditiona
- 영속성: `correction_of_run_id`, `is_correction`, `closed_at`
- 후속 사용 주체: timeseries와 월정산
- 마이그레이션: asset snapshot columns/indexes
- 호환성: legacy `account_snapshot` rows are readable
- 호환성: legacy `asset_snapshots` rows are readable
- 배포 전제 조건: close transaction atomicity
### REQ-003.08 — reconciliation과 monthly propagation
@@ -874,7 +872,7 @@ REQ-001의 auth gate·cookie/cleanup/logout, REQ-002의 메뉴 10개와 order(`
기존 요구사항·AC 식별자 및 공통 섹션은 유지하고 REQ-003.11만 확장한다. 월 수입·지출은 활성 거래의 [월초, 다음 월초) 범위, 자산·부채는 기존 월말 기준이다. 초안 정책은 `draft.cashflow_policy`에 저장하며 savings는 사용자가 명시한 category/subcategory pair만 인정한다. 생활비·선택 savings·`debt_payment`은 expense; 일반 transfer와 voucher top-up 제외가 우선하고 `investment` 전체 포함은 금지한다. Refund는 환급월 원래 지출 분류에 signed negative expense로 기록하며 과거 run을 수정하지 않는다. Unresolved unknown은 승인 blocker이며 asset conditional flag로 우회할 수 없고, 확정 제외는 blocker가 아니다. KRW only, no FX.
Run header는 settlement month/currency, 여섯 총계, policy JSON, aggregation/schema version, 당시 source JSON을 보존한다. `settlement_cashflow_snapshots`는 `id, monthly_settlement_id, cashflow_kind, category, subcategory, amount, transaction_count, currency` 8개 필드, signed INTEGER amount, positive count, `monthly_settlement(id)` FK RESTRICT, 5-field unique(monthly_settlement_id, cashflow_kind, category, subcategory, currency), month index를 갖는다. category/subcategory는 NOT NULL이며 빈 문자열은 그대로 구별한다. 기존 자산 상세와 authority/verified·exact/carry/substitute·missing/exclusion 근거를 유지한다.
Run header는 settlement month/currency, 여섯 총계, policy JSON, aggregation/schema version, 당시 source JSON을 보존한다. `settlement_cashflow_snapshots`는 `id, settlement_run_id, cashflow_kind, category, subcategory, amount, transaction_count, currency` 8개 필드, signed INTEGER amount, positive count, FK RESTRICT, 5-field unique(run, cashflow_kind, category, subcategory, currency), run index를 갖는다. category/subcategory는 NOT NULL이며 빈 문자열은 그대로 구별한다. 기존 자산 상세와 authority/verified·exact/carry/substitute·missing/exclusion 근거를 유지한다.
동일 run의 income/expense grouped 합이 각 총계와 일치하고 `net_cashflow=income_total-expense_total`; 자산·부채는 포함 상세 합이며 `net_worth=total_assets-total_liabilities`다. Review fingerprint는 거래 당시 값·분류·정책/버전·grouped 결과/여섯 총계·선택 평가자료·포함/제외/미결정 상태를 포함하고 drift 시 finalize를 거부한다. Header, 두 상세 영역, evidence 및 draft completion/final response는 한 transaction이며 오류 시 모두 rollback. Equal idempotent request replays persisted response; changed request conflicts. Correction validates latest single parent and appends run/details; no historical overwrite. Legacy totals stay NULL, incomplete draft needs fresh review/approval.
+2 -2
View File
@@ -211,7 +211,7 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
## REQ-003 검증
- 정상 흐름: `tests/test_account_valuation_service_actual.py`, `tests/test_account_snapshot_linking.py`, `tests/test_comprehensive_settlement_adapters.py`
- 정상 흐름: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`, `tests/test_contract_regression.py`
- 실패 흐름: conflict, conditional approval, validation tests
- 경계 흐름: exact/as_of, stale/carry-forward, correction/re-close, empty fallback
- 동시성 흐름: SQLite unique/idempotency and transaction rollback review
@@ -266,7 +266,7 @@ Draft create/update/finalize use transaction-scoped writes; any DB failure rolls
| 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 `account_snapshot` 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-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.