From 853943f6763e2d4b90903c72fb5df8c02e1acfad Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 12 Sep 2026 22:58:11 +0900 Subject: [PATCH] fix: fail closed on unknown reconcile warnings --- .proofline/criteria/ac-0023.md | 12 +++-- docs/interface-contract.md | 12 ++++- .../test_reconcile_warning_blocking.py | 46 +++++++++++++++++++ .../test_slots_handler_quantity_mismatch.py | 18 ++++++++ trex_core/application/intents.py | 5 ++ trex_core/application/messaging_handlers.py | 2 + trex_core/application/portfolio.py | 4 +- 7 files changed, 92 insertions(+), 7 deletions(-) diff --git a/.proofline/criteria/ac-0023.md b/.proofline/criteria/ac-0023.md index e0e01d7..b4fe994 100644 --- a/.proofline/criteria/ac-0023.md +++ b/.proofline/criteria/ac-0023.md @@ -3,20 +3,22 @@ id: "ac-0023" status: active --- -# C-case quantity mismatch — strategy RPC exclusion and intent blocking +# B/C reconcile warning — strategy RPC exclusion and intent blocking ## Criterion -KIS 계좌 잔고(`hldg_qty`)와 같은 instance가 관리하는 동일 symbol의 portfolio-hub `asset_quantity` 합계가 일치하지 않으면(`case_code=C` reconcile-warning 활성), 해당 symbol의 모든 `holding` slot을 `trex.slots.req.` 응답에서 제외해야 한다. 수량 대조는 **instance_key + symbol 단위 합계**로 수행하며, 개별 portfolio 수량을 KIS 종목 합계와 직접 비교해서는 안 된다. 응답에는 `excluded_slots.reconcile_quantity_mismatch` 필드로 제외된 slot 수를 포함한다. warn-store를 읽지 못하면 경고 유무를 모르는 상태로 slot 목록을 반환하지 않고 `degraded=true` / `reason="warn_store_unavailable"`으로 응답해야 한다(fail-closed). 신규 buy/sell intent는 durable warning을 재확인하여 `reconcile_warned` 사유로 거부한다. C-case 경고 해제는 symbol 단위 합계가 다시 일치한 경우에만 수행하며, 해당 symbol에 걸린 모든 C-case 경고를 함께 해제한다. 이 경로는 주문 submit/cancel, ledger write, slot lifecycle mutation을 유발하지 않아야 한다. +`case_code=B`(core slot↔KIS 수량 불일치) 또는 `case_code=C`(portfolio-hub↔KIS 수량 불일치) reconcile-warning이 활성인 `holding` slot은 `trex.slots.req.` 응답에서 제외해야 한다. 동일 slot의 B/C 원인은 서로 덮어쓰지 않고 동시에 보존하며, 응답에는 제외된 slot 수와 활성 case별 count를 포함한다. 신규 buy/sell intent는 같은 durable warning 원인 집합을 재확인하여 `reconcile_warned` 사유로 거부한다. warn-store를 읽지 못하거나 warning metadata가 비어 있거나 지원하지 않는 case를 포함하면 안전한 eligibility를 입증할 수 없으므로 slots RPC는 `degraded=true` / `reason="warn_store_unavailable"`, intent는 `reconcile_warning_unavailable`(조회 실패) 또는 `reconcile_warned`(empty/unsupported metadata)로 fail-closed 처리해야 한다. `sync`는 수량 일치 확인 후 B-case만 해제하고, `verify`는 instance+symbol 단위 KIS와 portfolio-hub 합계가 다시 일치한 경우 C-case만 해제하여 다른 활성 원인을 보존한다. C-case 수량 대조는 **instance_key + symbol 단위 합계**로 수행하며, 개별 portfolio 수량을 KIS 종목 합계와 직접 비교해서는 안 된다. 이 경로는 주문 submit/cancel, ledger write, slot lifecycle mutation을 유발하지 않아야 한다. ## Verification - 성공 조건: KIS 440주, hub portfolio A 200주 + B 240주(합계 440) → A·B 모두 반환되고 `excluded_slots.reconcile_quantity_mismatch=0`. -- 불일치 조건: KIS 440주, hub A 200주 + B 235주(합계 435) → A·B 모두 제외되고 `excluded_slots.reconcile_quantity_mismatch=2`. 다른 symbol 정상 slot은 같은 응답에 포함된다. +- 불일치 조건: KIS 440주, hub A 200주 + B 235주(합계 435) → A·B 모두 제외되고 `excluded_slots.reconcile_quantity_mismatch=2`, `active_reconcile_warning_cases.C=2`. 다른 symbol 정상 slot은 같은 응답에 포함된다. +- 복합 원인: 동일 slot에 B와 C가 동시에 활성일 때 두 원인이 모두 조회·차단되며, B 또는 C 하나를 해제해도 다른 원인은 보존된다. +- B-case: B warning이 활성인 holding slot은 slots RPC에서 제외되고 신규 buy/sell intent도 거부된다. - 단일 portfolio 불일치: hub 단일 portfolio qty != KIS → 해당 symbol 전체 slot 제외. -- fail-closed (slots RPC): warn-store가 `list_c_case_warned()` 중 예외를 throw하면 `slots=[], degraded=true, reason="warn_store_unavailable"` 반환. +- fail-closed (slots RPC): warn-store 조회가 실패하거나 empty/unknown case metadata를 반환하면 `slots=[], degraded=true, reason="warn_store_unavailable"` 반환. - fail-closed (intent): intent 처리 시 `warn_store.is_warned()` 호출이 실패하면 `reconcile_warning_unavailable` 사유로 거부해야 한다 — fail-open 허용 불가. -- intent race: 마지막 slots RPC 이후 경고가 설정된 경우에도 intent 처리 시점의 durable warning을 재확인해 `reconcile_warned`로 거부. +- intent race: 마지막 slots RPC 이후 경고가 설정된 경우에도 intent 처리 시점의 durable warning을 재확인해 `reconcile_warned`로 거부. unknown case도 거래 허용 근거로 사용하지 않는다. - 해제: symbol 합계가 다시 일치하면 해당 symbol의 모든 **C-case 경고만** 함께 해제. 같은 symbol의 B-case 등 다른 case 경고는 보존해야 한다. - 해제 대상: HOLDING 뿐 아니라 모든 상태의 slot에 걸린 C-case 경고를 포함한다. - 해제 실패: 해제 도중 예외가 발생하면 `result='clear_failed'`를 반환하고 완전 성공으로 보고하지 않아야 한다(부분 성공 = 실패). diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 05b00df..7f020b2 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -110,13 +110,20 @@ "last_seen": "ISO-8601 | null" }, "paused": "bool", + "reconcile_warned": "bool", // durable B/C warning 또는 unknown case 존재 + "reconcile_warning_cases": ["B", "C"], // 활성 case code 목록 + "trade_eligible": "bool", // known blocking/unknown case가 없을 때만 true "strategy_name": "str | null", // REQ-116 additive (BAT-055) — portfolio-hub PortfolioItem 출처 "strategy_params": "dict", // REQ-116 additive (BAT-055) — portfolio-hub opaque dict; {} if absent "markets": ["str"] // REQ-116 additive (BAT-055) — portfolio-hub markets list; [] if absent } ], + "excluded_slots": { + "reconcile_quantity_mismatch": "int", + "active_reconcile_warning_cases": {"B": "int", "C": "int"} + }, "degraded": "bool", - "reason": "portfolio_hub_unavailable | snapshot_fetch_failed | cash_position_stale | null", + "reason": "portfolio_hub_unavailable | snapshot_fetch_failed | cash_position_stale | warn_store_unavailable | null", "message": "str | null" // sanitized — no credentials/raw exceptions } ``` @@ -138,11 +145,14 @@ | `warned` | 파생 | `slot_quantity != asset_quantity` (REQ-074 AC-2) | | `watchdog` | WatchdogStore | 만료 여부 + last_seen; null=store 미연결 | | `paused` | PauseStore | durable pause overlay (REQ-032) | +| `reconcile_warned`, `reconcile_warning_cases` | ReconcileWarningStore | 동일 slot의 durable B/C 원인을 동시에 보존·노출 | +| `trade_eligible` | 파생(safety gate) | 활성 B/C 또는 지원하지 않는 warning case가 있으면 false | | `strategy_name` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. portfolio-hub 정본. hub 미제공 시 null | | `strategy_params` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. opaque dict; hub 미제공 시 {} | | `markets` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. list[str]; hub 미제공 시 [] | - **slot-origin strategy 필드 없음** (REQ-074 AC-5, REQ-116 additive 재도입 후에도 유지): `strategy_id`, `sell_strategy_id`, `active_strategy_id` 는 응답에 포함되지 않는다. `strategy_name`/`strategy_params`/`markets` 는 PortfolioItem(hub) 출처이므로 AC-5 제외 대상과 구분된다 — REQ-116 Notes 참조. +- **Reconcile warning fail-closed**: B/C warning이 활성인 holding slot은 응답에서 제외된다. warning store 조회 실패, empty metadata, unknown case는 `slots=[]`, `degraded=true`, `reason="warn_store_unavailable"`로 처리한다. intent admission도 동일 durable source를 재확인하며 unknown case를 거래 허용 근거로 사용하지 않는다. - **Breaking change (holding `qty`) rollout**: 기존 전략 클라이언트가 `qty=slot mirror`로 가정했다면 `slot_quantity`를 읽도록 전환해야 한다. 권장 경로는 (1) 클라이언트에 `qty_source`/`broker_quantity_available` 체크 추가 → (2) fail-closed (`qty is null`) 처리 배포 → (3) `qty`를 broker 정본으로 사용하도록 cutover 순서다. - `spy=false`(기본): holding 슬롯 응답 후 `WatchdogStore.update(slot_id)` 호출 — 전략 폴링 = 생존 신호. - `spy=true`: watchdog 갱신 건너뜀 — 운영자 CLI(`universe`/`slots`) 가 항상 사용. diff --git a/tests/application/test_reconcile_warning_blocking.py b/tests/application/test_reconcile_warning_blocking.py index 8acff52..ed9f761 100644 --- a/tests/application/test_reconcile_warning_blocking.py +++ b/tests/application/test_reconcile_warning_blocking.py @@ -340,6 +340,52 @@ def test_intent_service_sell_rejected_when_reconcile_warned(): assert d.reason == "reconcile_warned" +def test_unknown_warning_case_blocks_buy_and_sell_intents(): + repo = InMemorySlotRepository() + svc = SlotService(repo) + warn_store = InMemoryReconcileWarningStore() + slot = _make_holding_slot(repo, svc, qty=10) + warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="X") + + intent_svc = IntentService( + svc, + repo, + InMemoryIntentStore(), + instance_key="default", + warn_store=warn_store, + ) + + buy = intent_svc.submit_buy(BuyIntent( + intent_id="b-unknown-case", + slot_id=slot.slot_id, + entry_price=100.0, + qty=1.0, + )) + sell = intent_svc.submit_sell(SellIntent( + intent_id="s-unknown-case", + slot_id=slot.slot_id, + reason="strategy_exit", + )) + + assert buy.status == "rejected" + assert buy.reason == "reconcile_warned" + assert sell.status == "rejected" + assert sell.reason == "reconcile_warned" + + +def test_slot_to_dict_marks_unknown_warning_case_ineligible(): + repo = InMemorySlotRepository() + svc = SlotService(repo) + warn_store = InMemoryReconcileWarningStore() + slot = _make_holding_slot(repo, svc, qty=10) + warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="X") + + row = slot_to_dict(slot, warn_store=warn_store) + + assert row["reconcile_warning_cases"] == ["X"] + assert row["trade_eligible"] is False + + def test_reconcile_warned_buy_emits_alarm_with_allowlisted_fields_after_finalize(): repo = InMemorySlotRepository() svc = SlotService(repo) diff --git a/tests/application/test_slots_handler_quantity_mismatch.py b/tests/application/test_slots_handler_quantity_mismatch.py index c374ced..333574b 100644 --- a/tests/application/test_slots_handler_quantity_mismatch.py +++ b/tests/application/test_slots_handler_quantity_mismatch.py @@ -371,6 +371,24 @@ def test_ac7_warn_store_failure_fail_closed(): assert "excluded_slots" in resp.payload +def test_unknown_warning_case_fails_closed(): + repo = InMemorySlotRepository() + svc = SlotService(repo) + warn_store = InMemoryReconcileWarningStore() + slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10) + warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="X") + + resp = _make_handler(repo, warn_store)(_req()) + + assert resp.payload["slots"] == [] + assert resp.payload["degraded"] is True + assert resp.payload["reason"] == "warn_store_unavailable" + assert resp.payload["excluded_slots"] == { + "reconcile_quantity_mismatch": 0, + "active_reconcile_warning_cases": {}, + } + + def test_ac23_exclusion_precedes_broker_and_strategy_state_readers(): repo = InMemorySlotRepository() svc = SlotService(repo) diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py index 26afb70..7297d06 100644 --- a/trex_core/application/intents.py +++ b/trex_core/application/intents.py @@ -287,6 +287,11 @@ class IntentService: case_codes = {case_code} if case_code else set() if not case_codes: return True + # The warning row is authoritative. Unknown cases are not evidence + # that trading is safe: they may come from corrupt data or a newer + # producer that this core version cannot interpret. + if not case_codes.issubset(BLOCKING_RECONCILE_CASES): + return True return bool(case_codes & BLOCKING_RECONCILE_CASES) # ------------------------------------------------------------ diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index 527dbb1..f525d95 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -280,6 +280,8 @@ def make_slots_rpc_handler( codes = {case_code} if case_code else set() if not codes: raise RuntimeError(f"empty warning cases for slot_id={sid}") + if not codes.issubset(BLOCKING_RECONCILE_CASES): + raise RuntimeError(f"unsupported warning cases for slot_id={sid}") warning_cases_by_slot[str(sid)] = codes except Exception: # noqa: BLE001 _log.exception("warn_store warning query failed — fail-closed") diff --git a/trex_core/application/portfolio.py b/trex_core/application/portfolio.py index ba11a89..5e9e9ea 100644 --- a/trex_core/application/portfolio.py +++ b/trex_core/application/portfolio.py @@ -660,7 +660,9 @@ def slot_to_dict( reconcile_warned = bool(warning_cases) d["reconcile_warned"] = reconcile_warned d["reconcile_warning_cases"] = sorted(warning_cases) - d["trade_eligible"] = not bool(warning_cases & BLOCKING_RECONCILE_CASES) + known_blocking = bool(warning_cases & BLOCKING_RECONCILE_CASES) + unknown_cases = bool(warning_cases - BLOCKING_RECONCILE_CASES) + d["trade_eligible"] = not (known_blocking or unknown_cases) return d