test: pin public settlement response and liability registration contracts

This commit is contained in:
2026-10-10 03:22:37 +00:00
parent 92f44661c3
commit 6fbe81a5d3
4 changed files with 142 additions and 4 deletions
+4 -2
View File
@@ -424,9 +424,9 @@ Writer/bootstrap explicitly calls `init_db()`. `read_db()` uses SQLite `mode=ro`
## D04 snapshot close output contract ## D04 snapshot close output contract
Every successful **legacy direct close** output has these exact top-level fields (the transport envelope may additionally include `success`); this field list does not define the additive settlement-draft finalize response: `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 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` metadata fields (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. 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.
@@ -457,6 +457,8 @@ Create validates strict YYYY-MM and month-end date, nonempty actor/key and paylo
### Draft evidence public fields ### 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. `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. 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.
+18
View File
@@ -104,6 +104,15 @@ def test_legacy_close_fields_are_scoped_separately_from_draft_finalize() -> None
assert not re.search(r"new.*(?:identical|same).*shape", draft, re.I) 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: def test_requirement_authority_scope_distinguishes_approved_drafts_and_automatic_selection() -> None:
req = REQ.read_text(encoding="utf-8") req = REQ.read_text(encoding="utf-8")
constraints = req.split("## 6. 제약과 불변조건", 1)[1].split("## 7.", 1)[0] constraints = req.split("## 6. 제약과 불변조건", 1)[1].split("## 7.", 1)[0]
@@ -117,3 +126,12 @@ def test_requirement_authority_scope_distinguishes_approved_drafts_and_automatic
assert re.search(r"unverified.*rejected.*unknown.*(?:any|all).*path", constraints, re.I | re.S), \ 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" "unverified, rejected, or unknown amounts are never authority on any path"
assert "future_manual_asset" not in constraints 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
@@ -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)
+64 -2
View File
@@ -29,6 +29,20 @@ def payload(result):
return data 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): def rows(db, table):
assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'} assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'}
with closing(sqlite3.connect(db)) as connection: with closing(sqlite3.connect(db)) as connection:
@@ -74,31 +88,79 @@ async def _exercise_stdio_lifecycle(tmp_path):
made = await call('create_settlement_draft', {'month':'2026-09','created_by':'stdio-test','idempotency_key':'mcp-draft'}) made = await call('create_settlement_draft', {'month':'2026-09','created_by':'stdio-test','idempotency_key':'mcp-draft'})
assert made['success'] 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'] draft_id = made['draft']['id']
assert (await call('list_settlement_drafts', {'month':'2026-09'}))['drafts'][0]['id'] == 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}) 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'] draft = fetched['draft']
updated = await call('update_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test','memo':'transport lifecycle'}) 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'] draft = updated['draft']
refreshed = await call('refresh_settlement_draft_targets', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test'}) 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'] 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']}) 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'] draft = changed['draft']
reviewed = await call('review_settlement_draft', {'draft_id':draft_id}) reviewed = await call('review_settlement_draft', {'draft_id':draft_id})
assert reviewed['success'], reviewed 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'] 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'}) 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['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'}) 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['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})) before = (await call('get_settlement_draft', {'draft_id':draft_id}))
tables = ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items') tables = ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')
before_replay = {table: rows(db, table) for table in tables} 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 assert await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) == closed
assert await call('get_settlement_draft', {'draft_id':draft_id}) == before 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 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'}) second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'})
assert second['success'] 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'}) 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['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']