feat(core-A): reconcile-warning durable store + blocking gate (BAT-050, REQ-102/103/104/107/108)

B/C 불일치 시 슬롯을 durable 차단(ReconcileWarningStore)하고 전략 intent 경로에
pause∨reconcile_warned 게이트를 추가한다. 안전망(safety-sell/kill-switch/watchdog)
경로는 SlotService 직접 호출로 게이트를 구조적으로 우회(OQ-B 확정).
SlotState enum/전이표/wire 계약 필드는 불변, reconcile_warned 필드만 additive 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-06-26 00:43:04 +09:00
co-authored by Claude Sonnet 4.6
parent 35f3147250
commit c193388198
17 changed files with 1410 additions and 27 deletions
+52
View File
@@ -14,6 +14,11 @@ dcy-020 (BAT-030): admission is asset-centric only — the legacy pool path
cutover. Buy is keyed by slot_id (REQ-061 canonical; portfolio_id is the
legacy dual-accept mapping) and admits FCFS under the `max_active_slots` cap
M when configured (no cap when unset).
BAT-050 / REQ-104: reconcile-warning gate added to _admit_buy/_admit_sell.
warn_store is Optional — when None, gate is skipped (backward compat).
Safety-sell / kill-switch / watchdog bypass this gate by design (OQ-B):
those paths call SlotService.sell() directly, not through IntentService.
"""
from __future__ import annotations
@@ -104,6 +109,7 @@ class IntentService:
instance_key: str = "default",
max_active_slots: Optional[int] = None,
default_order_timeout_sec: Optional[int] = None,
warn_store=None, # Optional[ReconcileWarningStore] — BAT-050 / REQ-104
) -> None:
self._svc = slot_service
self._repo = slot_repo
@@ -119,6 +125,9 @@ class IntentService:
# dcy-022 / REQ-068: default timeout when intent omits timeout_sec.
# None = OFF = indefinite wait (legacy behavior). Per-intent value overrides.
self._default_order_timeout_sec = default_order_timeout_sec
# BAT-050 / REQ-104: reconcile-warning gate store (Optional).
# When None, gate is skipped (backward compat, NFR-4).
self._warn_store = warn_store
# ------------------------------------------------------------
def lookup(self, intent_id: str) -> Optional[IntentDecision]:
@@ -176,6 +185,23 @@ class IntentService:
return IntentDecision(intent_id, "rejected", None, "duplicate_in_flight")
return IntentDecision(intent_id, rec.status, rec.slot_id, rec.reason)
def _check_reconcile_warned(self, slot_id) -> Optional[IntentDecision]:
"""BAT-050 / REQ-104: return rejected decision if slot is reconcile-warned.
Returns None if not warned (gate passes). The warn_store check is best-effort:
if the store raises, we allow the intent through (fail-open for resilience).
Safety-sell / kill-switch / watchdog bypass this gate — they call
SlotService.sell() directly without going through IntentService (OQ-B).
"""
if self._warn_store is None:
return None
try:
if self._warn_store.is_warned(str(slot_id)):
return None # placeholder — intent_id unknown here; caller provides it
except Exception: # noqa: BLE001 - fail-open
pass
return None # not warned (or store error — fail-open)
def _admit_buy(self, intent: BuyIntent) -> IntentDecision:
"""dcy-020 / REQ-059·REQ-061 + dcy-021/REQ-066: activate or size up the
asset's permanent slot.
@@ -207,6 +233,19 @@ class IntentService:
return IntentDecision(intent.intent_id, "rejected", None, "slot_id_or_portfolio_required")
if slot is None:
return IntentDecision(intent.intent_id, "rejected", None, miss_reason)
# BAT-050 / REQ-104: reconcile-warning gate (external strategy intent only).
# Safety-sell / kill-switch / watchdog bypass this gate by design (OQ-B):
# those paths call SlotService.sell() directly.
if self._warn_store is not None:
try:
if self._warn_store.is_warned(str(slot.slot_id)):
return IntentDecision(
intent.intent_id, "rejected", None, "reconcile_warned"
)
except Exception: # noqa: BLE001 - fail-open
pass
# dcy-021: a buy on a HOLDING slot is a position-sizing increase.
is_increase = slot.status is SlotState.HOLDING
if slot.status in (SlotState.PENDING_BUY, SlotState.PENDING_SELL):
@@ -260,6 +299,19 @@ class IntentService:
slot = self._repo.fetch_slot(intent.slot_id)
if slot is None:
return IntentDecision(intent.intent_id, "rejected", None, "slot_not_found")
# BAT-050 / REQ-104: reconcile-warning gate for external strategy sell intent.
# Internal safety-sell / kill-switch / watchdog bypass this gate: they call
# SlotService.sell() / transit_slot() directly without IntentService (OQ-B).
if self._warn_store is not None:
try:
if self._warn_store.is_warned(str(slot.slot_id)):
return IntentDecision(
intent.intent_id, "rejected", None, "reconcile_warned"
)
except Exception: # noqa: BLE001 - fail-open
pass
if slot.status is not SlotState.HOLDING:
return IntentDecision(
intent.intent_id, "rejected", None, f"slot_not_holding:{slot.status.value}"