10 Commits
Author SHA1 Message Date
Hermes Agent f87350d415 chore(release): bump version to 0.9.0 2026-09-20 22:08:44 +09:00
genichin 903267189c Merge pull request #102 from fourblocks2020/feat/issue-97-outbox-idle-observability
feat(ledger): quiet idle outbox polling
2026-09-20 21:59:35 +09:00
Hermes Agent a2be76b199 feat(ledger): quiet idle outbox polling 2026-09-20 21:57:30 +09:00
genichin 62e4f57c2b Merge pull request #101 from fourblocks2020/feat/issue-95-sell-event-owner
fix: preserve SELL event routing owner
2026-09-20 21:10:54 +09:00
Hermes Agent c66acf6e47 fix: preserve sell event routing owner 2026-09-20 20:34:00 +09:00
genichin 3f6a967aa1 Merge pull request #100 from fourblocks2020/feat/issue-87-release-operation
feat(ops): verify release provenance evidence pairs
2026-09-20 11:50:22 +09:00
Hermes Agent db2bdd7d4a feat(ops): verify release provenance evidence pairs 2026-09-20 11:47:05 +09:00
genichinandHermes Agent cd2342f2c6 Forward ledger venue provenance to Portfolio Hub (#99)
Co-authored-by: Hermes Agent <noreply@nousresearch.com>
2026-09-19 19:30:57 +09:00
Hermes Agent bbdacb121c chore(release): prepare v0.8.0 2026-09-15 16:44:53 +09:00
Hermes Agent c70ac0fb24 feat: auto-reconcile stale holding mirrors
Closes #94
2026-09-14 12:55:04 +09:00
66 changed files with 3476 additions and 133 deletions
+2 -1
View File
@@ -7,7 +7,7 @@ status: active
## Criterion
`case_code=B`(core slot↔KIS 수량 불일치) 또는 `case_code=C`(portfolio-hub↔KIS 수량 불일치) reconcile-warning이 활성인 `holding` slot은 `trex.slots.req.<instance>` 응답에서 제외해야 한다. 동일 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을 유발하지 않아야 한다.
`case_code=B`(core slot↔KIS 수량 불일치) 또는 `case_code=C`(portfolio-hub↔KIS 수량 불일치) reconcile-warning이 활성인 `holding` slot은 `trex.slots.req.<instance>` 응답에서 제외해야 한다. 동일 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 처리해야 한다. B-case는 수량 일치 확인을 마친 operator `sync` 또는 AC-0032~AC-0034를 모두 만족해 qty·avg_price·audit·B warning clear가 한 transaction으로 commit된 `auto_reconcile`만 해제할 수 있다. `verify`는 instance+symbol 단위 KIS와 portfolio-hub 합계가 다시 일치한 경우 C-case만 해제하여 다른 활성 원인을 보존한다. C-case 수량 대조는 **instance_key + symbol 단위 합계**로 수행하며, 개별 portfolio 수량을 KIS 종목 합계와 직접 비교해서는 안 된다. 이 경로는 주문 submit/cancel, portfolio-hub 또는 ledger write, slot lifecycle mutation을 유발하지 않아야 한다.
## Verification
@@ -23,5 +23,6 @@ status: active
- 해제 대상: HOLDING 뿐 아니라 모든 상태의 slot에 걸린 C-case 경고를 포함한다.
- 해제 실패: 해제 도중 예외가 발생하면 `result='clear_failed'`를 반환하고 완전 성공으로 보고하지 않아야 한다(부분 성공 = 실패).
- 시간이 지났다는 이유만으로 자동 해제하지 않음.
- 자동 해제: AC-0032의 authoritative evidence와 eligibility를 통과하고 AC-0033 transaction이 성공한 경우에만 B-case를 해제한다. CAS 실패·불완전 evidence·write 실패에서는 B-case를 유지한다.
- 비침습 조건: slots RPC 호출 전후로 slot 상태 전이, broker mutation, ledger write side effect가 없어야 한다.
- AC-0022 우선순위: B 또는 C reconcile warning으로 제외된 slot에는 broker-authoritative `qty` 응답 형식(AC-0022)을 적용하지 않는다.
+2 -2
View File
@@ -7,11 +7,11 @@ status: active
## Criterion
`trex-core`는 B-case(core slot quantity와 broker quantity 불일치)를 stable slot별로, C-case(같은 instance·symbol의 portfolio-hub `asset_quantity` 합계와 broker quantity 불일치)를 `instance_key + symbol`별로 하나의 durable notification lifecycle로 추적해야 한다. 새 mismatch와 동일 identity의 case별 비교 quantity tuple 변경은 sanitized `trex.alerts` detected/changed event를 각각 한 번 발행하고, 성공한 publish fingerprint를 process restart 뒤에도 보존하여 unchanged scheduler tick을 억제해야 한다. B-case는 operator `sync`, C-case는 operator `verify`의 authoritative durable warning clear에서만 resolved event를 한 번 발행해야 하며 scheduler 관찰만으로 recovery를 발행해서는 안 된다. Event는 lifecycle state, `instance_key`, `symbol`, `case_code`, broker quantity와 case별 비교 quantity를 포함하고 account number/hash, credential, token, DSN, raw payload와 exception 본문을 포함해서는 안 된다. Publish 성공 뒤에만 fingerprint를 전진시키고 publish 또는 notification-state persistence 실패는 reconciliation, warning set/clear, sync/verify, scheduler와 slot lifecycle을 중단하거나 바꾸지 않아야 한다. Resolved publish 실패 시 notification state가 writable이면 pending transition을 durable하게 보존하여 후속 retry opportunity에서 재발행한다. Notification-state persistence 자체가 실패하면 authoritative warning clear를 유지하고 event 유실 가능성을 sanitized log로 남긴다.
`trex-core`는 B-case(core slot quantity와 broker quantity 불일치)를 stable slot별로, C-case(같은 instance·symbol의 portfolio-hub `asset_quantity` 합계와 broker quantity 불일치)를 `instance_key + symbol`별로 하나의 durable notification lifecycle로 추적해야 한다. 새 mismatch와 동일 identity의 case별 비교 quantity tuple 변경은 sanitized `trex.alerts` detected/changed event를 각각 한 번 발행하고, 성공한 publish fingerprint를 process restart 뒤에도 보존하여 unchanged scheduler tick을 억제해야 한다. B-case는 operator `sync` 또는 AC-0033 transaction이 성공한 `auto_reconcile`, C-case는 operator `verify`의 authoritative durable warning clear에서만 resolved event를 한 번 발행해야 하며 수량 일치의 단순 scheduler 관찰만으로 recovery를 발행해서는 안 된다. Event는 lifecycle state, `instance_key`, `symbol`, `case_code`, broker quantity와 case별 비교 quantity를 포함하고 account number/hash, credential, token, DSN, raw payload와 exception 본문을 포함해서는 안 된다. Publish 성공 뒤에만 fingerprint를 전진시키고 publish 또는 notification-state persistence 실패는 reconciliation, warning set/clear, sync/verify/auto_reconcile, scheduler와 slot lifecycle을 중단하거나 바꾸지 않아야 한다. Resolved publish 실패 시 notification state가 writable이면 pending transition을 durable하게 보존하여 후속 retry opportunity에서 재발행한다. Notification-state persistence 자체가 실패하면 authoritative warning clear를 유지하고 event 유실 가능성을 sanitized log로 남긴다.
## Verification
- 성공 조건: 새로운 B-case slot mismatch는 B detected event 한 건, 새로운 C-case symbol aggregate mismatch는 portfolio 수와 무관하게 C detected event 한 건을 만든다. 동일 fingerprint의 반복 tick은 추가 event를 만들지 않고 quantity tuple 변경은 changed event 한 건을 만든다. process restart 모사 뒤 unchanged mismatch는 억제되며, B `sync`와 C aggregate `verify` clear는 각 identity의 resolved event 한 건만 만든다.
- 성공 조건: 새로운 B-case slot mismatch는 B detected event 한 건, 새로운 C-case symbol aggregate mismatch는 portfolio 수와 무관하게 C detected event 한 건을 만든다. 동일 fingerprint의 반복 tick은 추가 event를 만들지 않고 quantity tuple 변경은 changed event 한 건을 만든다. process restart 모사 뒤 unchanged mismatch는 억제되며, B `sync` 또는 성공한 `auto_reconcile`과 C aggregate `verify` clear는 각 identity의 resolved event 한 건만 만든다.
- publish 실패 조건: transport failure에서는 detected/changed/resolved fingerprint가 전진하지 않아야 한다. detected/changed는 다음 동일 detection에서 재시도하고, resolved는 notification state가 writable한 경우 warning clear를 유지하면서 pending transition을 durable하게 보존해 후속 retry opportunity에서 재시도해야 한다. Notification-state persistence 실패도 business path를 중단하지 않아야 하며 resolved event 유실 가능성을 credential·DSN·raw exception 없는 log로 남겨야 한다.
- 경계 조건: C-case 동일 symbol의 여러 portfolio/slot이 중복 event를 만들거나, scheduler의 단순 quantity 일치가 durable warning clear·resolved event를 만들거나, B slot 간 fingerprint가 섞이거나, restart 뒤 unchanged event가 재발행되거나, publish 전 fingerprint 기록으로 event가 유실되면 FAIL이다.
- 보안·비변경 조건: 모든 lifecycle event의 allowlist fields와 rendered payload에 account number/hash, credential, token, DSN, raw KIS/portfolio-hub payload 및 exception 본문이 없어야 한다. 알림 전후 broker call mutation, portfolio-hub write, slot lifecycle/quantity mutation과 기존 AC-0023 blocking/clear semantics 변화가 없어야 한다.
+18
View File
@@ -0,0 +1,18 @@
---
id: "ac-0032"
status: active
---
# B-case auto-reconcile authoritative evidence와 eligibility
## Criterion
`reconcile_core_holdings`는 하나의 성공한 broker balance snapshot에서 `broker_qty > 0`이고, 같은 scheduler refresh의 portfolio-hub item이 slot의 `instance_key`·`portfolio_id`·`symbol`과 일치하며 `asset_quantity == broker_qty`일 때만 B-case 자동 동기화를 검토한다. 별도 canonical cost-basis 응답은 같은 portfolio·symbol·current quantity를 증명하고, fetch 시각이 60초 이내이며, `data_completeness_warning`이 없고, 양의 finite `cost_basis_per_unit`과 수량×평단에 일치하는 양의 finite `total_cost_basis`를 제공해야 한다. 대상 slot은 `holding`, 미퇴역, broker dispatch/pending metadata 없음이어야 한다. 첫 구현에서 `inactive`, `pending_buy`, `pending_sell`, `error`, retired slot은 자동 동기화하지 않는다. 어느 evidence든 missing·stale·invalid·불일치이거나 read/set-warning가 실패하면 fail-closed하여 slot과 warning을 변경하지 않는다. Operator `sync`의 legacy/graceful-degrade 계약은 변경하지 않는다.
## Verification
- hub·broker 수량과 canonical cost-basis identity/current quantity가 모두 일치하는 holding만 통과한다.
- hub/broker mismatch, cost-basis warning·stale·legacy-only·missing·non-finite·0 이하·identity mismatch·total mismatch는 모두 자동 변경 없이 B warning을 유지한다.
- broker balance는 reconcile pass당 한 번만 읽고 판단과 write가 같은 snapshot을 사용한다.
- pending/dispatch metadata, retired 또는 holding 외 상태는 fail-closed한다.
- 기존 `sync_slot_to_broker`의 canonical/legacy diagnostics와 inactive onboarding 회귀 테스트는 그대로 통과한다.
+18
View File
@@ -0,0 +1,18 @@
---
id: "ac-0033"
status: active
---
# Holding mirror qty·avg_price·B warning·audit 원자적 갱신
## Criterion
AC-0032를 통과한 자동 동기화는 instance advisory lock과 database transaction 안에서 관측한 slot identity/status/qty/avg_price 및 dispatch metadata를 CAS 재검증하고, `qty=broker_qty`, `avg_price=portfolio-hub cost_basis_per_unit` 갱신, `case_code=B` warning만 삭제, `actor=auto_reconcile`·`reason=hub_broker_matched_core_mirror_stale` transition audit 추가를 하나의 commit으로 수행해야 한다. Audit extra에는 allowlist된 before/after qty·avg_price와 hub/broker quantity, portfolio_id, symbol만 포함한다. CAS 실패, B warning 부재 또는 어느 write든 실패하면 네 변경 모두 없어야 한다. C/unknown warning과 다른 instance·symbol·slot은 보존한다. 같은 evidence 재실행은 no-op이며 audit를 중복 추가하지 않는다.
## Verification
- 성공 시 slot status는 holding으로 유지되고 qty·avg_price, B clear, audit 한 건이 함께 반영된다.
- C와 기타 warning은 보존된다.
- CAS race와 강제 예외에서 transaction rollback 후 slot·warning·audit가 모두 이전 상태다.
- PostgreSQL 실제 transaction 테스트와 in-memory parity 테스트로 commit, rollback, isolation, idempotency를 검증한다.
- broker order submit/cancel, portfolio-hub write, ledger/outbox write는 발생하지 않는다.
+17
View File
@@ -0,0 +1,17 @@
---
id: "ac-0034"
status: active
---
# Auto-reconcile scheduler·daemon wiring과 오류 격리
## Criterion
Production daemon은 기존 portfolio-hub read adapter, authoritative reconciliation broker, slot persistence, durable warning/notification store를 `SnapshotScheduler`의 B-case auto-reconcile 경로에 명시적으로 연결해야 한다. Scheduler는 성공한 universe refresh의 items와 core-holding worker가 한 번 읽은 complete broker snapshot을 사용하며 ad-hoc broker/Hub write client 또는 legacy cost-basis fallback을 만들지 않는다. Auto-reconcile evidence/read/CAS/transaction 오류는 해당 slot을 `qty_warning`으로 유지하고 scheduler·다른 slot·C-case reconciliation을 중단하지 않는다. 성공 commit 뒤에만 B resolved notification을 발행하며 publish 실패의 pending retry·fingerprint·instance isolation 및 sanitized payload 계약은 AC-0024를 유지한다.
## Verification
- daemon assembly test가 read port, reconciliation broker, atomic store, warn/notification store의 production 연결을 확인한다.
- scheduler test가 현재 refresh items 전달, broker 단일 read, auto-sync 성공/실패 격리와 다음 tick idempotency를 확인한다.
- resolved notification은 commit/B clear 뒤 한 번 발생하고 publish 실패는 business commit을 되돌리지 않으며 pending retry된다.
- synthetic 회귀: symbol `019685`, hub=broker=726, core=1452, canonical WAC≈2172.46829187에서 core만 726/WAC로 갱신되고 B만 해제되며 주문·Hub·ledger side effect가 없다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0035"
status: active
---
# Observed provenance evidence와 개별 gate
## Criterion
`release_provenance_gate.py`는 운영 계층이 제공한 versioned observed evidence JSON을 strict하게 검증하고 그 source를 `operator_observed`로 표시해야 한다. Schema version 1은 exact field `schema_version`, `target_id`, `target_kind`, `image_id`, `image_digest`, `oci_revision`, `observed_at`만 허용한다. `schema_version`은 integer `1`, target field는 non-empty string, image 값은 lowercase `sha256:<64 hex>`, revision은 lowercase 40-hex, 관측 시각은 UTC RFC 3339여야 한다. Schema·duplicate-key·secret validation 실패는 입력을 record에 복사하지 않고 record/receipt 없이 non-zero로 종료해야 한다. 유효 evidence 이후 gate가 직접 관측한 audit checkout HEAD/detached/dirty 상태, revision별 runtime-file blob 일치와 read-only state capture는 caller evidence와 분리하고 REQ-0010의 gate record v1 exact schema로 기록한다. Observed revision과 checkout HEAD가 다르거나 checkout, runtime file 또는 필수 capture가 실패하면 `gate.passed=false`의 immutable failure record를 남기고 non-zero로 종료해야 한다. Record write 뒤 CLI는 canonical path와 raw bytes SHA-256을 REQ-0010 gate receipt v1 JSON으로 출력한다. Core는 target/runtime을 inspect하거나 caller evidence를 직접 관측값으로 주장하지 않는다.
## Verification
- 성공 조건: 유효한 evidence와 clean detached checkout, 일치 runtime files, 성공 capture를 사용하면 REQ-0010 gate record v1에 `source=operator_observed`, caller evidence와 gate-observed evidence가 분리되고 `gate.passed=true`이며 receipt의 SHA-256이 저장된 raw bytes와 같고 CLI가 0으로 종료한다.
- 실패·경계 조건: 필수 field 각각의 누락, unknown/duplicate key, bool을 포함한 잘못된 type, empty target, uppercase·길이 오류 digest/revision, non-UTC 또는 timezone 없는 timestamp와 secret-like key/value는 record/receipt 없이 non-zero로 종료한다. 유효 evidence 이후 revision mismatch, dirty/attached checkout, runtime-file mismatch와 capture 실패는 immutable failure record·receipt와 non-zero를 만들며 success로 승격되지 않는다.
- 검사 방법: evidence parser와 `collect_release_provenance()` 단위 테스트, CLI subprocess/fixture 테스트로 record schema·source 표기·verdict·exit code를 확인하고 legacy unversioned evidence가 거부되는지 검증한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0036"
status: active
---
# PRE/POST pair 검증과 immutable manifest
## Criterion
Pair validator는 운영 계층이 gate receipt에서 보존해 명시적으로 전달한 PRE/POST canonical record path와 expected raw-bytes SHA-256만 읽고 filesystem scan이나 latest-file 추정을 하지 않아야 한다. Current record bytes의 SHA-256은 expected 값과 일치해야 한다. PRE는 `phase=pre`, POST는 `phase=post`이고 두 record는 REQ-0010 gate record v1 exact schema와 success 불변식—동일한 schema version·operation ID·instance·target kind/ID, clean detached checkout, checkout HEAD와 observed revision 일치, non-empty runtime verification 전부 성공, 세 command output return code 0, broker capture 성공, gate summary `true,false,true,true,true`—을 충족해야 한다. 성공하면 REQ-0010 pair manifest v1 exact schema로 manifest를 checkout 밖에 exclusive-create하고 read-only로 전환한다. Image 동일성·변경 허용과 PRE→POST command-class transition 정책은 판정하지 않는다. Expected digest 불일치·불완전·record 불일치·failed gate 또는 output collision에서는 manifest를 생성·overwrite하지 않고 non-zero로 종료해야 한다.
## Verification
- 성공 조건: 동일 operation·instance·target의 유효한 성공 PRE/POST canonical path와 gate receipt의 expected SHA-256을 전달하면 current raw bytes와 digest를 재검증하고 REQ-0010 pair manifest v1 read-only file 하나를 생성하며 CLI가 0으로 종료한다.
- 실패·경계 조건: PRE 없는 POST, path 또는 expected digest 누락, 동일 path 두 번, phase 역전, unsupported/unknown/duplicate schema key, operation/instance/target mismatch, failed/incomplete success invariant, expected digest 발행 뒤 record byte 변경과 기존 output path 충돌은 새 manifest를 남기지 않고 기존 file을 변경하지 않으며 non-zero로 종료한다.
- 검사 방법: parser/validator 단위 테스트와 신규 pair CLI subprocess 테스트로 success manifest, exact digest binding, explicit path 사용, tamper detection, collision 불변성을 검증한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0037"
status: active
---
# Dirty checkout isolation 무결성
## Criterion
Dirty audit checkout은 기존과 같이 state capture 전에 fail-closed해야 하며, isolation은 tracked modified file, untracked regular file과 symlink를 checkout 밖의 operation-specific directory에 보존하되 source를 삭제·reset·checkout하지 않아야 한다. REQ-0010 isolation manifest v1 exact schema는 각 dirty relative path의 source type, size, SHA-256 또는 symlink target-text SHA-256, copied relative path, copied type/size/hash와 `matches`를 기록해야 한다. File content와 symlink 대상 content는 manifest에 넣지 않는다. Directory·FIFO/socket/device 같은 unsupported type, checkout 또는 symlink escape, copy/hash mismatch와 output collision은 성공으로 표시하지 않고 gate를 non-zero로 유지해야 한다. Manifest는 exclusive-create 후 read-only mode여야 한다.
## Verification
- 성공 조건: modified/untracked regular file, empty file, nested path와 checkout 내부 symlink를 격리하면 source와 copied metadata/hash가 같고 `matches=true`이며 manifest가 versioned/read-only이고 source bytes·type이 그대로 남는다.
- 실패·경계 조건: directory·special file, `../`/absolute path, checkout 밖을 가리키는 symlink, injected copy/hash mismatch와 기존 manifest collision은 성공으로 기록되지 않고 source를 변경·삭제하지 않으며 non-zero 결과를 만든다.
- 검사 방법: 임시 Git repository에서 regular/untracked/empty/nested/symlink 및 adversarial path/type fixtures를 사용해 isolation helper와 gate CLI를 검증하고, source before/after hash·mode·symlink target과 manifest content를 비교한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0038"
status: active
---
# SELL event owner 보존과 lifecycle 일관성
## Criterion
SELL admission은 non-empty `SellIntent.sell_strategy_id`, slot에 보존된 non-empty `sell_strategy_id`, non-empty `strategy_id` 순서로 event owner를 확정하여 durable `sell_strategy_id`에 저장해야 한다. Full fill과 partial fill 후 external cancel은 terminal 처리 직전의 이 canonical field를 routing hint로 캡처하여, 이후 `INACTIVE` 전이가 ownership field를 지우더라도 같은 owner를 사용해야 한다. Dispatch 전 daemon 재시작·재조회 후에도 owner가 같아야 한다. Zero-fill cancel/reject는 fill event 없이 `HOLDING`으로 rollback하면서 owner를 보존하고, 이후 명시적 새 owner가 없는 SELL admission은 보존 owner를 재사용하며 명시적 새 owner만 이를 교체해야 한다. Whitespace-only 값은 owner로 인정하지 않는다.
## Verification
- 성공 조건: BUY owner `A`와 명시적 SELL owner `B`가 다른 주문의 full fill 및 partial fill 후 external cancel event가 모두 `slot_filled_sell.B.<symbol>`로 발행된다. 별도 SELL owner가 없는 safety/kill-switch sell은 보존된 SELL owner가 있으면 이를, 없으면 `A`를 사용한다. Zero-fill cancel 뒤 owner `B`가 `HOLDING`에 남고, owner를 생략한 재매도가 `B`를 사용하며, admission 뒤 durable row를 새 service/repository instance에서 다시 읽어 처리해도 owner가 같다.
- 실패·경계 조건: fill 뒤 clear된 field에서 owner를 재추측하거나, `sell_strategy_id=B`인데 `strategy_id=A`로 route하거나, zero-fill cancel/reject가 fill event를 만들거나 owner를 지우거나, owner 생략 재매도가 보존된 `sell_strategy_id`를 무시하면 FAIL이다. 공백 문자열은 owner로 인정하지 않는다.
- 검사 방법: `IntentService` admission과 `ExecutionService` 단위·통합 테스트로 owner 우선순위, full/partial/zero-fill, safety sell, cancel→재매도, repository reload/restart simulation을 검증하고 disposable PostgreSQL 경로에서 `sell_strategy_id` 영속 round-trip을 확인한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0039"
status: active
---
# owner 없는 SELL event의 명시적 unroutable 처리와 전달 분리
## Criterion
SELL terminal 처리에서 `sell_strategy_id`와 `strategy_id`가 모두 없거나 공백이면 정상 `trex.events` strategy event 또는 DLQ synthetic-owner message를 publish하지 않고 reason code `sell_event_owner_missing`으로 처리해야 한다. 해당 execution tick 결과의 dedicated counter를 증가시키고, instance·slot identity·symbol·event type·reason을 포함하되 secret, credential, raw broker payload나 raw exception text가 없는 structured ERROR log와 best-effort alarm을 남겨야 한다. 이 전달 실패와 정상 owner event의 transport publish 실패는 이미 완료된 broker fill, durable slot 전이, ledger outbox enqueue 및 dispatch 정리를 rollback하지 않아야 한다. 정상 owner event의 기존 payload `slot_id`, `event_type`, `sequence_at`은 publish/retry 경계에서 바꾸지 않아야 하며, consumer 구현 또는 새로운 replay·exactly-once 보장은 요구하지 않는다.
## Verification
- 성공 조건: owner 없는 full fill 및 partial fill 후 external cancel에서 `trex.events` publish 호출과 `_` strategy routing key가 모두 0회이고 counter·stable reason ERROR·alarm이 각각 관측된다. 동시에 slot/ledger/dispatch 결과는 owner가 있는 동일 체결과 같게 완료된다. owner가 있는 event의 최초 publish가 실패하고 transport가 재시도하는 경우 payload identity와 `sequence_at`이 변하지 않는다.
- 실패·경계 조건: `slot_filled_sell._.<symbol>` 또는 다른 sentinel owner가 정상 exchange에 발행되거나, owner 미확정 또는 publish exception이 금융 state/outbox를 rollback하거나, 관측 reason이 동적 exception 문자열이면 FAIL이다. alarm transport 실패는 금융 결과를 변경하지 않아야 한다.
- 검사 방법: fake emitter/transport와 `caplog`·alarm recorder를 사용한 application/runtime 테스트로 publish call count, counter, reason, sanitized fields 및 금융 비변경을 검증한다. 기존 messaging retry/DLQ 테스트와 disposable RabbitMQ integration으로 mandatory publish·confirm·동일 payload retry·전략 queue 격리를 확인한다. RabbitMQ fixture가 없으면 integration은 SKIP으로 명시하고 단위 검증을 통과로 대체 주장하지 않는다.
+23
View File
@@ -0,0 +1,23 @@
---
id: "ac-0040"
status: active
---
# Ledger outbox idle poll 로그 억제와 운영 metric
## Criterion
Ledger outbox writer는 pending row가 없는 poll마다 instance-local process-lifetime empty poll counter를 정확히 증가시키되 empty claim INFO를 생성하지 않아야 한다. non-empty batch claim은 configured `instance_key`, non-zero `count`, `limit`, 해당 batch의 non-empty correlation ID를 repository claim 순서와 동일한 `correlation_ids` JSON string array로 INFO에 남겨야 한다. empty poll은 claim INFO 자체를 생성하지 않으므로 `correlation_ids` field도 출력하지 않는다.
`trex.slots.req`의 `spy=true` 응답은 top-level `ledger_outbox_metrics`를 포함해야 한다. 정상 snapshot은 `available=true`, `scope=process_lifetime`, configured `instance_key`를 포함하고, `claims` object에서 `empty_polls_total`, `claimed_batches_total`, `unavailable_total`, `lost_total`, `foreign_instance_total`을, `outcomes` object에서 `completed_total`, `duplicate_completed_total`, `retryable_failure_total`, `conflict_blocked_total`, `permanent_failure_total`, `dead_lettered_total`을 각각 누락 없는 0 이상의 정수로 제공해야 한다. snapshot은 해당 daemon process의 configured instance writer만 집계하고 내부 mutable counter와 분리된 새 값이어야 한다. reader가 없거나 조회가 실패하면 `available=false`, `scope=process_lifetime`, configured `instance_key`, `reason=ledger_outbox_metrics_unavailable`만 포함한 snapshot을 반환하며 slots 조회 자체는 성공해야 한다. `spy=false` 응답에는 `ledger_outbox_metrics` key를 포함하지 않아야 한다.
`TrexDaemon._ledger_loop()`에서 writer가 예상 밖 예외를 던지면 `reason=ledger_writer_run_once_failed`, configured `instance_key`, exception class name인 `exc_type`만 ERROR로 남기고 다음 configured poll을 계속해야 한다. empty/non-empty/failure 관측 어디에도 credential, account identifier, token, password, DSN, provider payload, raw exception message, traceback 또는 `exc_info`가 포함되어서는 안 된다. fixed polling cadence, retry scheduling, claim lease, ownership/filtering과 기존 outcome 의미는 바뀌지 않아야 한다.
## Verification
- 성공 조건: 기본 `ledger_interval_sec=1.0`의 24시간에 해당하는 86,400회 empty `run_once()`를 실제 sleep 없이 `instance-a`와 `instance-b` writer에 각각 실행했을 때 각 `empty_polls_total`이 정확히 86,400이고 rendered INFO의 `claim_result=empty` 및 `correlation_ids`가 0건인지 확인한다. 한 개 이상의 item을 claim하면 batch INFO에 configured instance, non-zero count, limit와 repository claim 순서 그대로인 `correlation_ids` JSON string array가 있고 `claimed_batches_total` 및 처리 outcome totals가 맞는지 확인한다.
- 운영 조회 조건: `spy=true`에서 정상 schema의 모든 key와 zero/default 정수, instance/process 격리, 매 호출 새 snapshot을 확인한다. reader absent 및 reader exception에서는 stable unavailable snapshot과 slots 응답 성공을 확인한다. `spy=false`에는 metrics key가 없음을 확인한다.
- 실패·보안 조건: sentinel secret을 포함한 writer exception을 두 번의 loop tick 중 첫 tick에 주입해 ERROR reason·instance·exception type, 두 번째 tick 실행, raw sentinel·exception message·traceback·`exc_info` 부재를 확인한다. 기존 claim-lost, foreign/unowned, retryable, conflict, permanent, dead-letter, success와 duplicate outcome의 severity·counter·상태 전이가 유지되는지 확인한다.
- 변경 없음 조건: `AC-0029`의 InMemory/PostgreSQL ownership, active lease exclusion, expiry recovery, stale-token fencing과 foreign/unowned isolation을 회귀 검증한다. `ledger_interval_sec`, retry `next_retry_at`, DB schema, ledger API와 dead-letter policy의 diff가 없는지 확인한다.
- 문서 조건: `docs/runtime-flows.md`와 `docs/interface-contract.md`가 empty claim INFO suppression, spy operator-only metrics, process-lifetime 및 restart-reset scope, fixed cadence 유지, sanitized writer exception과 loop survival을 명시하는지 직접 검사한다.
- 검사 방법: focused writer·messaging handler·daemon unit tests, InMemory parity tests, disposable PostgreSQL multi-instance tests, 전체 `pytest`, Ruff, `compileall`, `proofline validate`, `git diff --check`를 실행한다. PostgreSQL fixture가 없어서 skip되면 통과로 치환하지 않고 환경 미검증으로 보고한다.
+2 -2
View File
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"next_line_number": 10,
"next_ac_number": 32
"next_line_number": 13,
"next_ac_number": 41
}
+91
View File
@@ -0,0 +1,91 @@
---
id: "dcy-0010"
status: confirmed
---
# 운영 provenance 검증 계약과 audit checkout 증거 무결성
## Problem
PR #93은 audit checkout의 clean/detached/expected revision, runtime file hash, image digest 형식과 PRE/POST 상태 capture를 검사하는 `release_provenance_gate.py`를 추가했다. 현재 gate는 개별 record를 생성하지만, 실행 환경이 관측한 target·image identity를 명시적인 expected/observed 구조로 보존하지 않고 같은 operation의 PRE/POST record가 한 쌍인지 검증하거나 최종 manifest로 묶지 않는다. Dirty checkout 격리는 파일을 checkout 밖에 복사하지만 각 파일의 hash·type·복사 성공 여부를 manifest에 남기지 않아 보존본이 원본과 같은 bytes인지 사후 검증하기 어렵다.
실제 Docker Compose/container 또는 host process 선택, runtime inspect, stop/start/recreate/deploy 실행, 전역 operation lock, audit checkout 교체와 maintenance window 승인은 배포 환경을 소유한 운영 계층의 책임이다. `trex-core`가 특정 runtime을 선택하거나 lifecycle command를 실행하면 application repository가 production orchestration과 권한 경계를 소유하게 된다. 이 저장소가 제공해야 하는 것은 운영 계층이 수집한 evidence를 엄격한 schema로 검증하고, 실패를 machine-readable non-zero 결과로 반환하며, 개별 PRE/POST record와 dirty isolation manifest의 무결성을 증명할 수 있게 하는 source-owned verification contract다.
기대 상태는 `trex-core` provenance gate가 운영 계층이 제공한 observed target/image evidence와 audit checkout evidence를 명시적으로 구분해 기록하고, 누락·형식 오류·checkout mismatch·capture 실패를 fail-closed하는 것이다. 별도 pair validator는 명시적으로 전달된 PRE/POST record 두 개의 operation identity와 gate 성공 여부를 검증하고 immutable manifest를 생성해야 한다. Dirty isolation은 source를 삭제하거나 정리하지 않으면서 regular file과 symlink의 보존 결과를 hash/type 기준으로 검증 가능하게 남겨야 한다.
## Evidence
- GitHub Issue [#87](https://github.com/fourblocks2020/trex-core/issues/87)은 running artifact와 audit checkout의 provenance 불일치, PRE/POST 증거 연결, dirty checkout 무손실 보존 문제를 추적한다. Issue 본문의 lifecycle wrapper·runtime inspect·global lock 항목은 현재 책임 재검토 결과 운영 계층 소유로 분리해야 한다.
- PR [#93](https://github.com/fourblocks2020/trex-core/pull/93)은 `main`에 병합되었고 조사 기준 revision은 `cd2342f2c69267c471b5e81658c58332e00857de`다. 관련 변경은 `f3a6b2c`, `8d9d627`, `619feff`, `8378e03`, `7dc2f65`에 걸쳐 있다.
- `scripts/release_provenance_gate.py`는 command class와 read-only capture command 형태를 제한하고 dirty checkout에서 state capture를 실행하지 않은 채 exit 2로 차단한다. 하지만 `--expected-oci-revision`과 `--image-digest`는 caller가 제공한 평면 문자열이며 target identity, evidence source와 expected/observed 구분을 기록하지 않는다.
- `trex_core/adapters/persistence/ops_checks.py`의 `collect_release_provenance()`는 audit checkout HEAD와 caller가 제공한 revision을 비교하고 image digest의 `sha256:<64 hex>` 형식을 검사한다. Runtime 파일은 working-tree bytes와 지정 revision의 Git blob hash를 비교한다.
- `scripts/release_provenance_gate.py`는 PRE와 POST record를 각각 exclusive-create하고 read-only mode로 전환하지만, 동일 operation ID·instance·target identity를 검증하는 pair validator와 pair manifest는 없다.
- `isolate_dirty_checkout_files()`의 현재 `manifest.json`은 `dirty_paths`와 `copied_paths`만 기록한다. 각 source/copied path의 file type, SHA-256, size와 복사 일치 여부는 기록하지 않는다. 따라서 path-only manifest가 현재 baseline이며 hash manifest는 이번 Line의 신규 범위다.
- `docs/operations.md`의 현재 실행 절차는 host `trex-core-daemon`과 `pkill`을 설명하지만 Issue #87은 container identity를 예로 든다. 저장소에는 production Compose/service definition이나 canonical lifecycle executor가 없으므로 이 Line에서 특정 runtime을 선택하거나 lifecycle command를 추가할 직접 근거가 없다.
- 현재 `main`에서 `tests/adapters/persistence/test_ops_checks.py`와 `tests/scripts/test_release_provenance_gate.py`를 실행한 결과 `19 passed, 3 skipped`다. 이는 기존 개별 gate baseline을 확인하지만 observed evidence schema, pair integrity와 hash isolation manifest를 검증하지 않는다.
- Duplicate sweep 결과 Issue #87 관련 open PR과 `release provenance`·`lifecycle wrapper` open PR은 없고 PR #93만 merged 상태다.
- 현재 active ProofLine AC 전체와 `.proofline/lines/**`를 `release_provenance`, `audit checkout`, `재기동`, `lifecycle wrapper`로 검색한 결과 이 검증 계약을 소유하는 AC는 없다. Image publication을 소유하는 `req-0002`·`req-0004`는 production restart와 deployment를 비범위로 두므로 update/satisfy 대상이 아니다.
## Scope
- 이 Line은 기존 PR #93 gate를 기반으로 version-independent 동작을 소유하는 신규 AC 세 건을 `create`한다. 기존 active AC의 `update`, `retire`, `satisfy`, release evidence 대상은 없다.
### 신규 AC-A: observed provenance evidence와 개별 gate
- 운영 계층이 수집해 전달하는 evidence는 최소 `target_id`, `target_kind`, `image_id`, `image_digest`, `oci_revision`, `observed_at`을 가진 versioned JSON object다. `target_kind`는 extensibility를 위한 식별값이며 `trex-core`가 Docker·Compose·host runtime을 선택하거나 inspect하지 않는다.
- Gate는 evidence source를 `operator_observed`로 명시하고, caller가 제공한 observed 값과 gate가 직접 확인한 checkout HEAD/detached/dirty 상태 및 runtime-file Git blob 비교를 별도 field로 기록한다. Caller evidence를 gate 직접 관측값으로 표시하지 않는다.
- `target_id`와 `target_kind`는 non-empty string, `oci_revision`은 40-hex commit, `image_id`와 `image_digest`는 `sha256:<64 hex>`, `observed_at`은 timezone을 가진 UTC timestamp여야 한다. 누락·중복 key, 잘못된 type/형식, secret-like key/value, checkout revision mismatch, dirty/attached checkout, runtime-file mismatch와 필수 state capture 실패는 record에 원인을 남기고 non-zero로 종료한다.
- 정상 검증은 gate record에 `schema_version`, evidence의 expected/observed provenance, checkout/runtime/capture 결과와 `gate.passed=true`를 남긴다. 실패 record도 exclusive-create read-only로 남기되 lifecycle 허용을 주장하지 않는다.
- Verification은 정상 evidence, 각 필수 field 누락·잘못된 type/형식, unknown key, secret-like 값, revision mismatch, dirty/attached checkout, runtime-file mismatch와 capture 실패를 독립적으로 포함한다.
### 신규 AC-B: PRE/POST pair validator와 immutable manifest
- Pair validator는 filesystem scan으로 record를 추정하지 않고 운영 계층이 명시적으로 전달한 정확한 PRE path와 POST path만 읽는다.
- PRE는 `phase=pre`, POST는 `phase=post`이며 두 record는 동일한 `schema_version`, `operation_id`, `instance`, `target_kind`, `target_id`를 가져야 한다. 각 command class는 기존 allowlist에 속해야 하지만 실제 PRE→POST lifecycle transition policy와 image 변경 허용 여부는 운영 계층이 소유한다.
- 두 record 모두 `gate.passed=true`이고 필수 checkout/runtime/state capture evidence가 완전해야 한다. PRE/POST의 image identity는 각각 manifest에 보존하되 deploy 여부를 모르는 core validator가 임의로 동일성을 강제하지 않는다.
- Pair 검증 성공 시 두 input record의 canonical path, SHA-256, phase, command class, observed image identity와 검증 시각을 포함한 versioned manifest를 checkout 밖에 exclusive-create하고 read-only mode로 전환한다. 동일 output path 재사용은 overwrite하지 않고 실패한다.
- PRE 없는 POST, phase 역전, identity mismatch, failed gate, 변조된/비정상 record, 필수 evidence 누락에서는 manifest를 생성하지 않고 non-zero로 종료한다.
- Verification은 성공 pair, PRE 없는 POST, phase mismatch, operation/instance/target mismatch, failed gate, input record byte 변경, output collision을 독립적으로 포함한다.
### 신규 AC-C: dirty isolation 무결성과 비밀 안전성
- Dirty checkout이면 기존과 같이 state capture 전에 fail-closed하고 tracked modified file, untracked regular file과 symlink를 checkout 밖의 operation-specific isolation directory에 복사한다. Source를 삭제·reset·checkout하지 않는다.
- Isolation manifest는 각 dirty path에 대해 relative path, source type, source SHA-256 또는 symlink target SHA-256, size, copied relative path, copied type/hash/size와 `matches`를 기록한다. Directory·unsupported special file·checkout escape·symlink escape는 성공으로 추정하지 않고 오류 상태를 기록하며 gate를 실패시킨다.
- Manifest 자체는 versioned JSON이며 exclusive-create 후 read-only mode로 전환한다. Secret-like file content는 manifest에 넣지 않고 hash·size·type만 저장한다.
- Verification은 modified/untracked regular file, symlink, empty file, nested path, checkout escape, unsupported type, copy/hash mismatch와 output collision을 포함하고 source bytes가 변경·삭제되지 않았음을 확인한다.
### Source 및 문서 변경 경계
- 예상 source/test owners는 `scripts/release_provenance_gate.py`, 신규 pair verification CLI, `trex_core/adapters/persistence/ops_checks.py`, provenance 관련 tests와 `docs/operations.md`다.
- Public trading/domain API, daemon composition root, persistence schema와 runtime service lifecycle에는 변경이 없다.
- `docs/operations.md`는 `trex-core` gate가 lifecycle을 실행하거나 runtime truth를 직접 관측하지 않는다는 경계를 명시하고, 운영 계층이 exact observed evidence를 제공하고 non-zero 결과를 lifecycle 차단 조건으로 사용해야 한다고 설명한다.
- Production runtime validation은 운영 계층 구현과 별도 사용자 승인을 받은 maintenance window가 있어야 하며 이번 source delivery의 완료 증거가 아니다.
## Out of Scope
- Docker Compose/container 또는 host process 중 canonical production runtime 선택
- Runtime/container/process inspect, 실제 stop/start/recreate/deploy/recovery command 실행
- Lifecycle wrapper, global operation lock, stale-lock 해제와 maintenance window orchestration
- Audit checkout의 branch/worktree 생성·교체·reset, dirty source 삭제 또는 recovery branch commit
- Trading/domain contract, 주문 lifecycle, database schema 또는 migration 변경
- 자동 주문 제출·취소, 자동 DB recovery, 자동 `sync --live` 또는 `verify --live`
- 여러 instance의 일괄·병렬 재기동 또는 실패 뒤 다음 instance 자동 진행
- Production credential·raw broker payload를 evidence에 저장하거나 Issue/PR에 게시
- 사용자 승인 없는 production 변경, 배포 완료 또는 runtime 검증 완료 주장
- Container registry publication, image signing, SBOM, package visibility 또는 release workflow 변경
## Risks and Unknowns
### Risks
- 운영 계층이 잘못된 observed evidence를 제공하면 `trex-core`는 실제 runtime을 독립적으로 재관측하지 않으므로 그 진실성을 증명할 수 없다. Record는 evidence source를 명시하고 운영 계층은 runtime-specific inspect와 lifecycle 차단을 별도 계약으로 구현해야 한다.
- Deploy operation은 PRE와 POST image identity가 달라질 수 있으므로 core pair validator가 동일 image를 강제하면 정상 deploy를 막는다. Core는 두 identity와 record hash를 보존하고 transition policy는 운영 계층에 맡긴다.
- Hash manifest는 dirty bytes의 보존 동일성을 증명하지만 그 내용의 안전성이나 배포 적합성을 승인하지 않는다. Recovery와 clean checkout 준비는 운영자가 별도로 수행한다.
- 기존 record consumer가 새 versioned schema를 전제로 하지 않을 수 있다. CLI와 parser는 기존 record를 pair success로 암묵 승격하지 않고 unsupported schema로 fail-closed해야 한다.
### Assumptions
- 운영 계층은 실제 runtime-specific inspect로 evidence JSON을 생성하고 gate의 non-zero 종료를 lifecycle 차단 신호로 사용한다.
- `target_id`는 운영 계층이 한 operation의 PRE/POST에 안정적으로 유지하는 logical target identity이며 runtime-specific 의미는 core가 해석하지 않는다.
- Existing `trex-core slots ... --json`, `rabbitmqctl list_consumers`와 broker log capture는 PR #93의 approved read-only capture boundary를 유지한다.
- Issue #87 구현 지시는 source/test/docs/PR delivery를 승인하지만 production restart와 checkout mutation 승인을 포함하지 않는다.
@@ -0,0 +1,35 @@
# Activity Log — line-0010
## 2026-09-20
- 사용자 확인으로 `dcy-0010`을 confirmed 처리했다.
- 사용자 승인으로 `req-0010`을 approved, `ac-0035`~`ac-0037`을 active 처리했다.
- `proofline validate` 통과. 기존 line-0001~0009 status 누락 warning만 존재한다.
- 구현 계획:
1. observed evidence strict parser, versioned gate record와 raw-byte digest receipt
2. explicit PRE/POST path와 trusted digest 기반 pair validator/manifest
3. regular file/symlink isolation type·size·hash manifest
4. operations-owned lifecycle 경계 문서화
5. focused/full/ProofLine 검증과 독립 review
- 실제 production lifecycle command, runtime inspect, global lock과 checkout 복구는 범위 밖이며 실행하지 않는다.
### 구현 및 검증
- `release_provenance_gate.py`를 versioned `--observed-evidence-file` 입력과 machine-readable raw-byte digest receipt로 전환했다.
- Strict observed evidence parser, v1 gate record, trusted-digest PRE/POST pair validator와 `scripts/verify_release_provenance_pair.py`를 추가했다.
- Dirty checkout isolation을 regular file/symlink type·size·SHA-256 copy integrity manifest로 강화했다.
- `docs/operations.md`에 source/operations 책임 경계와 새 gate/pair 절차를 반영했다.
- Focused 검증: `32 passed, 3 skipped`; 변경 파일 Ruff 통과.
- 전체 회귀: `1247 passed, 96 skipped`.
- 저장소 전체 `ruff check .`는 변경 전부터 존재하는 광범위한 baseline 위반 719건으로 실패했다. 이번 변경 파일에 한정한 Ruff 결과는 PASS다.
- 다음 작업: canonical ProofLine/diff 재검증, 독립 runtime/impact review, PR delivery.
### 독립 리뷰 차단사항 수정
- AC/security와 runtime/downstream 독립 리뷰가 동일하게 checkout 밖 symlink target 허용을 차단 결함으로 재현했다.
- 원인은 symlink target text의 복사 무결성만 검사하고 target 해석 경계를 검사하지 않은 것이었다.
- 절대 symlink target과 상대 `..` escape를 복사 전에 거부하고, 상대 target resolve 결과가 checkout 내부인지 검증하도록 수정했다.
- 외부 absolute/relative escape fail-closed 및 source 불변 회귀와 내부 상대 symlink 성공 회귀를 추가했다.
- 수정 후 focused 검증: `35 passed, 3 skipped`; 전체 회귀 `1250 passed, 96 skipped`.
- 변경 파일 Ruff·compile, `proofline validate`, `git diff --check` 통과. ProofLine에는 기존 line-0001~0009 status warning만 남아 있다.
- 차단사항 독립 재검토 PASS: focused isolation `4 passed`, 관련 persistence/CLI `22 passed, 3 skipped`, chained symlink escape 수동 probe도 fail-closed. 잔여 blocker 없음.
+4
View File
@@ -0,0 +1,4 @@
---
id: "line-0010"
status: discovery
---
+85
View File
@@ -0,0 +1,85 @@
---
id: "req-0010"
status: approved
discovery: "dcy-0010"
criteria:
create:
- "ac-0035"
- "ac-0036"
- "ac-0037"
update: []
retire: []
satisfy: []
---
# 운영 provenance 검증 계약과 audit checkout 증거 무결성
## Objective
`trex-core`가 운영 lifecycle을 실행하거나 특정 runtime을 선택하지 않으면서도 운영 계층이 제공한 observed target/image evidence, audit checkout 검증, PRE/POST 상태 capture와 dirty isolation 결과를 재현 가능한 형식으로 검증하게 한다. 개별 gate record는 caller evidence와 gate 직접 관측값을 구분하고 fail-closed하며, 명시적인 PRE/POST pair validator는 같은 operation의 성공 record 두 개만 immutable manifest로 결합한다. Dirty checkout 보존본은 path뿐 아니라 type·size·SHA-256 일치 여부로 무결성을 증명한다.
## Scope
- `release_provenance_gate.py`는 versioned observed evidence JSON을 입력받아 strict schema·secret 검사를 수행하고, target/image evidence를 `operator_observed` source로 기록한다.
- Gate가 직접 관측하는 audit checkout HEAD/detached/dirty 상태, revision blob과 working-tree runtime file hash, read-only capture 결과는 caller evidence와 별도 namespace에 기록한다.
- 개별 record는 versioned schema, operation metadata, observed evidence, checkout/runtime/capture 결과와 gate verdict를 포함하고 checkout 밖에 exclusive-create read-only file로 저장한다.
- Gate CLI는 record를 생성한 직후 raw file bytes의 SHA-256과 canonical path를 machine-readable receipt로 출력한다. 신규 pair verification CLI는 운영 계층이 보존한 PRE/POST canonical path와 expected SHA-256을 명시적으로 받아 검증한다.
- Pair validator는 두 record가 같은 schema version, operation ID, instance, target kind/ID를 가지며 각각 성공한 pre/post record이고 current raw bytes가 expected SHA-256과 같을 때만 versioned immutable manifest를 생성한다.
- Pair manifest는 두 input의 canonical path·verified SHA-256·phase·command class·observed image identity와 검증 시각을 저장한다. Image 변경과 command-class transition의 운영 정책은 판정하지 않는다.
- Dirty isolation은 modified/untracked regular file과 symlink를 checkout 밖에 보존하고 source/copied type·size·hash와 `matches`를 versioned manifest에 기록한다. Source bytes를 삭제·reset·checkout하지 않는다.
- Existing read-only capture allowlist, broker log 원문 비저장, actor/reason secret-like material 거부와 audit checkout 밖 output 원칙을 유지한다.
- `docs/operations.md`는 trex-core gate가 runtime inspect나 lifecycle command를 수행하지 않으며 운영 계층이 observed evidence 생성과 non-zero 결과의 enforcement를 소유한다고 명시한다.
- Repository-local tests는 정상·실패·collision·tamper 경계를 검증한다.
## Constraints
모든 schema object는 아래에 명시된 key만 허용하고 unknown/duplicate key를 거부한다. `sha256`은 prefix 없는 lowercase 64-hex, `image_id`와 `image_digest`는 `sha256:<64 lowercase hex>`, revision은 lowercase 40-hex, 모든 timestamp는 UTC RFC 3339다. Record·manifest hash는 JSON 재직렬화 값이 아니라 저장된 raw file bytes를 대상으로 한다.
**Observed evidence v1**
- Exact keys: `schema_version`(integer `1`), `target_id`(non-empty string), `target_kind`(non-empty string), `image_id`, `image_digest`, `oci_revision`, `observed_at`.
**Gate record v1**
- Exact top-level keys: `schema_version`(integer `1`), `timestamp_utc`, `actor`, `reason`, `instance`, `command_class`, `operation_id`, `phase`(`pre|post`), `observed_evidence`, `checkout`, `runtime_file_verification`, `state_capture`, `gate`; dirty checkout이면 `dirty_checkout_isolation`을 추가로 허용한다.
- `observed_evidence`: observed evidence v1 exact object에 `source`=`operator_observed`만 추가한다.
- `checkout`: exact keys `path`, `head_revision`, `detached_head`, `git_status_porcelain`, `git_clean`, `dirty_paths`. Pair success에는 `head_revision == observed_evidence.oci_revision`, `detached_head=true`, `git_clean=true`, empty `dirty_paths`가 필요하다.
- `runtime_file_verification`: non-empty list이며 각 item exact keys는 `path`, `path_within_checkout`, `exists`, `current_sha256`, `expected_revision`, `expected_sha256`, `matches_expected_revision`이다. Pair success에는 모든 boolean이 true이고 `expected_revision == observed_evidence.oci_revision`, 두 SHA-256이 같아야 한다.
- `state_capture.command_outputs`: exact keys `pending_slots`, `reconciliation_slots`, `queue_consumers`; 각 output의 exact keys는 `command`, `returncode`, `stdout`, `stdout_format`, `stderr`이며 pair success에는 `returncode=0`이 필요하다. `stdout` payload의 application-owned nested key는 schema 통제 대상이 아니지만 secret sanitization 대상이다.
- `state_capture.broker_side_effect_logs`: `capture_status=captured|captured_empty`이면 exact keys `path`, `exists`, `capture_status`, `file_sha256`, `scanned_lines`, `matches`; `unavailable`이면 exact keys `path`, `exists`, `capture_status`, `matches`; `error`이면 exact keys `path`, `exists`, `capture_status`, `error_type`, `matches`다. Pair success에는 success variant가 필요하고 `matches` item exact keys는 `event`, `line_number`, `line_sha256`이다.
- `gate`: exact keys `passed`, `deployment_blocked`, `runtime_files_verified`, `state_captures_succeeded`, `broker_log_captured`. Pair success에는 각각 `true,false,true,true,true`가 필요하다.
- Gate receipt stdout: exact JSON object `schema_version`(integer `1`), `record_path`(canonical absolute path), `record_sha256`(raw record bytes SHA-256), `gate_passed`(boolean). Failure record를 정상적으로 쓴 경우에도 receipt를 출력하되 CLI는 non-zero다.
**Pair manifest v1**
- Exact top-level keys: `schema_version`(integer `1`), `verified_at`, `operation_id`, `instance`, `target_kind`, `target_id`, `pre`, `post`.
- `pre`/`post` exact keys: `record_path`, `record_sha256`, `phase`, `command_class`, `image_id`, `image_digest`, `oci_revision`, `observed_at`; `phase`는 각 object 이름과 일치해야 한다.
**Isolation manifest v1**
- Exact top-level keys: `schema_version`(integer `1`), `timestamp_utc`, `checkout_dir`, `isolate_dir`, `entries`.
- 각 entry exact keys: `path`, `source_type`, `source_size`, `source_sha256`, `copied_path`, `copied_type`, `copied_size`, `copied_sha256`, `matches`. Type은 `regular|symlink`; symlink size/hash는 target text의 UTF-8 bytes 기준이다.
- Observed evidence schema의 필수 field는 `schema_version`, `target_id`, `target_kind`, `image_id`, `image_digest`, `oci_revision`, `observed_at`이다. Unknown/duplicate key, 누락, 잘못된 type/형식과 secret-like material은 fail-closed한다.
- `schema_version`은 integer `1`, target field는 non-empty string, `image_id`와 `image_digest`는 lowercase `sha256:<64 hex>`, `oci_revision`은 lowercase 40-hex Git commit, `observed_at`은 timezone을 가진 UTC RFC 3339 timestamp다.
- `target_kind`의 의미와 target/runtime 실재 여부는 운영 계층이 소유한다. Core는 Docker·Compose·host runtime을 선택하거나 inspect하지 않고 caller evidence를 직접 관측했다고 표시하지 않는다.
- Gate는 observed `oci_revision`과 audit checkout HEAD가 같아야 하고 runtime file working bytes가 해당 revision Git blob과 같아야 성공한다.
- Observed evidence JSON 자체의 schema·duplicate key·secret validation이 실패하면 해당 입력을 record에 복사하지 않고 record/receipt 없이 non-zero로 종료한다. Evidence validation을 통과한 뒤 checkout/runtime/capture gate가 실패하면 immutable failure record와 receipt를 남긴다.
- Pair validator는 filesystem scan이나 latest-file 선택을 하지 않는다. PRE/POST canonical path와 gate receipt에서 보존한 expected SHA-256은 필수 명시 입력이며, input raw bytes를 다시 hash해 일치해야 한다.
- Pair validator는 실제 deploy/restart가 수행됐는지, image 변경이 허용되는지 또는 command-class transition이 운영 정책에 맞는지 주장하지 않는다.
- Legacy unversioned record를 새 pair success로 암묵 승격하지 않는다. Unsupported schema는 fail-closed한다.
- Record와 manifest는 overwrite하지 않고 exclusive-create하며 성공한 write 뒤 read-only mode로 전환한다.
- Dirty isolation의 hash manifest는 file content를 포함하지 않는다. Unsupported special file, checkout/symlink escape, copy/hash mismatch와 output collision은 성공으로 기록하지 않는다.
- Normative Design Documents: 없음. 이 REQ와 AC-0035~AC-0037이 이번 specification의 normative behavior를 소유한다.
## Non-Goals
- Docker Compose/container 또는 host process 중 canonical production runtime 선택
- Runtime/container/process inspect, 실제 stop/start/recreate/deploy/recovery command 실행
- Lifecycle wrapper, global operation lock, stale-lock 처리와 maintenance orchestration
- Audit checkout worktree 생성·교체·reset, dirty source 삭제 또는 recovery branch commit
- Image transition과 command-class transition의 운영 정책 판정
- Trading/domain contract, 주문 lifecycle, database schema·migration 또는 daemon composition 변경
- 자동 주문·취소, DB recovery, `sync --live`, `verify --live`
- Production deployment, restart 또는 runtime validation 완료 주장
- Registry publication, image signing, SBOM, package visibility 또는 release workflow 변경
+80
View File
@@ -0,0 +1,80 @@
---
id: "dcy-0011"
status: confirmed
---
# SELL 체결 이벤트 owner 보존과 unroutable 차단
## Problem
SELL 주문이 체결되면 broker fill, slot 상태 전이, ledger outbox 처리는 정상 완료되더라도 전략 event의 routing owner가 비어 `slot_filled_sell._.<symbol>`로 발행될 수 있다. `trex.events`의 전략 queue는 `*.{strategy_id}.#`로 binding되므로 `_`에 대응하는 consumer가 없고, RabbitMQ mandatory publish는 해당 event를 `NO_ROUTE`로 판단해 DLQ로 보낸다. 금융 처리는 완료됐지만 전략은 체결 전이를 받지 못해 실제 포지션과 전략 상태가 어긋날 수 있다.
현재 `SellIntent.sell_strategy_id`와 slot의 `sell_strategy_id`는 SELL admission 및 persistence 경계에 존재하지만 `ExecutionService._on_filled()`와 `_on_partial_cancel()`은 event hint로 `slot.strategy_id`만 캡처한다. 이전 수정 `de97bc8`은 `PENDING_SELL → INACTIVE`가 `strategy_id`를 지우기 전에 값을 보존했지만, SELL owner가 `sell_strategy_id`에 있거나 `strategy_id`가 admission 전부터 비어 있는 경우에는 효력이 없다. Daemon emitter도 owner를 찾지 못하면 `_`를 정상 strategy routing segment처럼 사용한다.
기대 상태는 SELL admission 시 event owner를 한 번 확정해 pending order lifecycle 동안 durable slot state로 보존하고, full fill·partial fill 후 cancel·zero-fill cancel 뒤 재매도·daemon 재시작에서도 같은 owner를 사용하는 것이다. Owner를 확정할 수 없는 event는 금융 처리와 slot transition을 되돌리지 않되 정상 strategy event로 가장하지 않고 stable reason, metric, log와 alarm으로 관측되어야 한다.
## Evidence
- `E-001` GitHub Issue [#95](https://github.com/fourblocks2020/trex-core/issues/95)는 2026-09-16~18 운영에서 symbol `003780`·`000390`의 SELL fill 두 건이 `trex.events.strategy._` 계열 key로 발행되어 `NO_ROUTE`/DLQ가 된 반면 broker fill, outbox completion과 portfolio ledger 저장은 성공했다고 기록한다.
- `E-002` 조사 기준 revision은 `main`의 `3f6a967aa14e7feedbff20967c4e870b46189167`이다. Issue #95 body와 comments 전체를 확인했으며 comment, 연결 PR과 open duplicate PR은 없다. `#95`, `SELL routing owner`, `NO_ROUTE strategy event` 검색에서도 중복 구현 PR이 발견되지 않았다.
- `E-003` `trex_core/application/intents.py`의 `SellIntent`는 `sell_strategy_id`를 받고, `_admit_sell()`은 `HOLDING → PENDING_SELL` 전이에 이를 넘긴다. `trex_core/domain/slot/service.py`는 non-`None` 값을 slot에 보존하며 `Slot.sell_strategy_id`는 PostgreSQL `_SLOT_COLUMNS`, update와 row materialization에 포함되어 재시작 후에도 유지된다.
- `E-004` `trex_core/application/execution.py`의 `_on_filled()`와 `_on_partial_cancel()`은 transition 전에 `routing_strategy_id = slot.strategy_id`만 저장한다. Full SELL은 `INACTIVE` 전이에서 `strategy_id`와 `sell_strategy_id`를 모두 지우며 partial SELL은 `HOLDING`으로 돌아가므로, event hint를 owner field에서 먼저 확보하지 않으면 full fill의 post-transition slot만으로 복구할 수 없다.
- `E-005` `trex_core/runtime/daemon.py::_make_event_emitter()`는 hint, `slot.strategy_id`, `_` 순서로 routing segment를 선택한다. 따라서 owner가 없을 때도 `topology.event_routing_key()`와 `publish_event(mandatory=True)`를 호출하고, RabbitMQ adapter는 `UnroutableError`를 DLQ에 적재한다.
- `E-006` commit `de97bc842d48599e4eb26b6e32130798ec97d5c9`은 sell transition 전 `strategy_id` 캡처로 이전 `NO_ROUTE`를 수정했다. 해당 diff와 commit 설명은 `sell_strategy_id`를 owner 후보로 다루지 않아 이번 운영 재현과 일치하는 부분 수정임을 확인했다.
- `E-007` `trex_core/application/execution.py::_rollback()`은 zero-fill reject/cancel에서 `PENDING_SELL → HOLDING`으로 되돌리고 `sell_strategy_id`를 보존한다. 따라서 새 cancel event를 만드는 대신 owner 보존과 다음 SELL admission의 동일 owner 사용을 검증할 수 있다.
- `E-008` 현재 event·RabbitMQ baseline인 `tests/application/test_messaging_event_publisher.py`, `tests/adapters/messaging/test_publish_reliability.py`, `tests/adapters/messaging/test_rabbitmq_transport.py` 실행 결과는 `22 passed, 5 skipped`다. Existing regression은 `strategy_id`가 있는 경로만 검증하고 `sell_strategy_id`만 있는 slot, owner 없음, restart, zero-fill cancel 후 재매도를 검증하지 않는다.
- `E-009` 현재 active ProofLine AC 중 `ac-0013`은 cancel/fill 경합의 slot·ledger exactly-once를, `ac-0015`는 미수락 SELL의 broker-authoritative 상태 수렴을, `ac-0020`은 non-recordable ledger fill 감사를 소유한다. Strategy event owner 확정, `_` 금지와 delivery observability를 소유하는 active AC는 없다.
## Scope
- 이 Line은 version-independent SELL event ownership 동작을 소유하는 신규 AC 두 건을 `create`한다. `ac-0013`·`ac-0015`의 slot/ledger lifecycle 의미는 변경하지 않고 회귀 대상으로 검증한다. 기존 active AC의 `update`, `retire`, `satisfy` 또는 release evidence 대상은 없다.
### 신규 AC-A: SELL event owner 확정과 lifecycle 보존
- SELL admission은 non-empty `SellIntent.sell_strategy_id`를 우선하고, 없으면 slot에 이미 지정된 non-empty `sell_strategy_id`, 기존 호환을 위해 non-empty `strategy_id` 순서로 event owner를 확정한다. Whitespace-only 값은 owner가 아니다.
- 확정된 owner는 `PENDING_SELL` slot의 durable `sell_strategy_id`에 저장되어 dispatch, polling, full fill, partial fill/cancel, zero-fill cancel 후 `HOLDING`, 다음 SELL admission과 daemon 재시작에서도 유지된다. 별도 schema migration이나 새 identity registry를 만들지 않고 기존 column을 canonical owner field로 사용한다.
- Full fill과 partial-fill terminal event는 transition 전 `sell_strategy_id`를 캡처해 정확히 그 owner의 routing key를 사용한다. `PENDING_SELL → INACTIVE`가 slot identity field를 지운 뒤에도 routing hint는 유지된다.
- Event owner는 routing destination을 선택하는 opaque ID이며 core가 전략 종류, registration 또는 authorization mapping으로 해석하지 않는다. 다른 instance/strategy queue로 broadcast하거나 fallback하지 않는다.
- Zero-fill external cancel/reject는 기존처럼 event를 새로 만들지 않고 `HOLDING`으로 수렴하되 owner를 보존해야 한다. 같은 slot의 재매도는 명시적 새 owner가 없는 한 보존한 owner를 다시 사용한다.
- 동일 broker execution의 ledger exactly-once와 현재 best-effort event semantics를 유지한다. 재시작·반복 polling은 같은 terminal execution에 대한 event 재발행을 새로 보장하지 않으며, 기존 dispatch clear와 ledger execution identity 경계에서 중복 side effect가 없음을 검증한다.
- Verification은 explicit `sell_strategy_id`, pre-existing slot `sell_strategy_id`, legacy `strategy_id` fallback, full fill, partial-fill cancel, zero-fill cancel→재매도, repository round-trip/restart와 다른 strategy queue 미전달을 포함한다.
### 신규 AC-B: owner 없는 strategy event의 fail-closed routing과 분리 관측
- Strategy event owner를 확정할 수 없으면 `trex.events`에 `_` 또는 다른 synthetic owner를 넣어 publish하지 않는다. 정상 strategy queue와 DLQ에 owner 없는 message를 가장해 보내지 않는다.
- Execution 결과는 stable reason `sell_event_owner_missing`에 대응하는 tick-level counter를 증가시키고 structured ERROR log를 남긴다. Alarm channel이 있으면 같은 reason과 instance/slot/symbol/event type만 포함한 alarm을 best-effort로 발행하며 credential·raw broker payload를 포함하지 않는다.
- Owner 없음 또는 event transport publish 실패는 이미 성공한 broker fill, slot transition, ledger outbox append를 rollback하지 않는다. Financial processing, ledger enqueue, strategy event publish/skip 결과를 서로 독립적으로 검증한다.
- Owner가 있어도 mandatory publish가 unroutable이면 기존 RabbitMQ DLQ와 ERROR 관측 계약을 유지한다. 이번 Line은 topology가 존재하는 정상 owner delivery와 owner 없음의 pre-publish skip을 구분한다.
- Verification은 owner 없음에서 strategy publish call 0건, `_` routing key 0건, stable counter/log/alarm, fill·transition·ledger 성공 유지와 alarm 실패 격리를 포함한다. Disposable RabbitMQ가 사용 가능할 때 올바른 owner는 정확한 queue에 1회 전달되고 다른 strategy queue에는 0회이며, subscriber가 없는 정상 owner는 mandatory publish에 의해 기존 DLQ 경로로 가는 회귀를 검증한다.
### Source·test·문서 경계
- 예상 owner는 `trex_core/application/intents.py`, `trex_core/application/execution.py`, `trex_core/runtime/daemon.py`, 기존 slot persistence parity, messaging/execution tests와 `docs/interface-contract.md`다.
- Public wire field 이름 `sell_strategy_id`, event type, routing key 형식과 RabbitMQ topology는 유지한다. 신규 database column, migration, consumer registry와 cross-repository 변경은 없다.
- Issue #95와 legacy source comment의 `REQ-095`는 번호가 우연히 같을 뿐 별개다. 이 Line의 canonical identity는 `line-0011` 및 이후 allocator가 부여하는 신규 REQ/AC다.
## Out of Scope
- 기존 운영 DLQ 97건 또는 과거 SELL event의 자동 replay·삭제·재발행
- 이미 누락된 전략 상태의 운영 보정, broker 주문 제출·취소 또는 production daemon 재시작
- Strategy consumer의 idempotency 구현, 새 event ID schema 또는 exactly-once messaging 보장
- RabbitMQ exchange/queue/binding 형식 변경, `_`를 위한 catch-all strategy queue 생성
- Core-side strategy registry, producer authentication 또는 `strategy_id ↔ sell_strategy_id` 매핑
- BUY event owner 의미 변경, ledger/outbox schema 변경 또는 slot lifecycle 재설계
- Zero-fill cancel/reject에 신규 strategy event type 추가
- 사용자 승인 없는 production credential 접근, live broker/RabbitMQ mutation 또는 DLQ 조작
## Risks and Unknowns
### Risks
- 기존 caller가 `sell_strategy_id` 없이 `strategy_id`에만 의존할 수 있다. 명시적 precedence와 legacy fallback을 함께 테스트해 호환성을 유지하되, 둘이 다르면 explicit/current `sell_strategy_id`가 routing authority가 된다.
- Event delivery는 금융 처리 뒤 best-effort이며 broker execution과 원자적이지 않다. 이번 변경은 owner 오류를 제거하고 실패를 분리 관측하지만 exactly-once strategy notification을 새로 보장하지 않는다.
- In-memory transport만으로는 RabbitMQ mandatory routing과 DLQ를 증명할 수 없다. Disposable RabbitMQ test는 실행 가능 환경에서 수행하고 사용할 수 없으면 skip을 성공 증거로 해석하지 않는다.
- Alarm 발행 자체가 실패할 수 있으므로 alarm은 금융 처리와 execution loop를 중단해서는 안 된다. Stable counter와 structured log가 독립 관측 경로로 남아야 한다.
### Assumptions
- Issue #95의 `sell_strategy_id` 보존 요구와 기존 `SellIntent`·slot column은 SELL strategy event owner의 canonical source를 정한다. 별도 owner field나 migration은 필요하지 않다.
- 현재 event consumer는 기존 `slot_filled_sell.<strategy_id>.<symbol>` wire 형식을 소비하므로 key 형식이 아닌 strategy segment의 값과 missing-owner 차단만 바꾼다.
- Source·test·docs 구현과 PR delivery 요청은 production replay, restart 또는 DLQ mutation 승인을 포함하지 않는다.
@@ -0,0 +1,21 @@
# Activity Log — line-0011
## 2026-09-20
- 사용자 확인으로 `dcy-0011`을 confirmed 처리했다.
- digest-bound 독립 specification review의 blocker를 수정하고 재검토 PASS를 받았다.
- 사용자 승인으로 `req-0011`을 approved, `ac-0038`·`ac-0039`를 active 처리했다.
- 구현 계획:
1. SELL admission owner 우선순위와 durable `sell_strategy_id` 보존
2. full/partial terminal event의 canonical owner routing
3. owner 없음의 `sell_event_owner_missing` tick counter·ERROR·alarm과 publish skip
4. 금융 state·ledger·dispatch 비rollback 회귀
5. focused/full/disposable RabbitMQ 및 독립 영향 검토
- production broker 주문, production lifecycle, production daemon 재기동과 기존 DLQ 조작은 실행하지 않는다.
- SELL admission owner precedence, safety-sell fallback, terminal event pre-transition capture, ownerless fail-closed counter/log/alarm 및 runtime 방어를 구현했다.
- full fill, partial-fill cancel, zero-fill cancel 후 재매도, restart/reprocessing, alarm 실패 격리, 중복 tick, PostgreSQL round-trip, sanitized publish failure log 회귀 테스트를 추가했다.
- RabbitMQ retry의 동일 payload·mandatory flag 단위 검증과 disposable broker integration test를 추가했다. `TREX_RABBITMQ_TEST_URL`이 없어 integration은 명시적으로 SKIP했다.
- focused 검증: `29 passed, 15 skipped`; messaging blocker 검증: `21 passed, 6 skipped`.
- 전체 suite: `1261 passed, 98 skipped in 7.85s`.
- 변경 경로 Ruff, `proofline validate`, `git diff --check`를 통과했다. PostgreSQL과 RabbitMQ 실 backend 검증은 fixture 부재로 skipped 상태다.
- 변경 전 base `3f6a967`에 신규 핵심 regression 3개를 적용한 sabotage 검증은 `3 failed`로 원래 결함을 재현했고 수집 오류 없이 종료했다.
+4
View File
@@ -0,0 +1,4 @@
---
id: "line-0011"
status: discovery
---
+43
View File
@@ -0,0 +1,43 @@
---
id: "req-0011"
status: approved
discovery: "dcy-0011"
criteria:
create:
- "ac-0038"
- "ac-0039"
update: []
retire: []
satisfy: []
---
# SELL 체결 이벤트 owner 보존과 unroutable 차단
## Objective
SELL 주문 admission에서 확정된 전략 owner를 체결 event routing까지 보존하여 해당 전략 queue로만 전달한다. owner를 결정할 수 없는 SELL 체결은 정상 전략 event로 위장해 `_` routing key로 발행하지 않고 명시적 전달 실패로 관측하되, 이미 성공한 broker 체결, slot 전이, ledger outbox 처리는 되돌리지 않는다.
## Scope
- `HOLDING → PENDING_SELL` admission에서 non-empty `SellIntent.sell_strategy_id`, slot에 보존된 non-empty `sell_strategy_id`, non-empty `strategy_id` 순서로 SELL event owner를 확정하고 기존 durable `sell_strategy_id`에 저장한다. Whitespace-only 값은 owner로 인정하지 않는다.
- 확정된 owner는 기존 durable slot field를 통해 dispatch 전후와 daemon 재시작 후에도 보존하고, full fill 및 partial fill 후 external cancel의 `slot_filled_sell` routing hint로 사용한다.
- zero-fill cancel 또는 reject는 fill event를 만들지 않고 기존 rollback 계약을 유지하며 확정 owner를 `HOLDING`에 보존한다. 이후 명시적 새 owner가 없는 SELL admission은 이 owner를 다시 사용하고, 명시적 새 owner가 있으면 그것으로 교체한다.
- owner 미확정 SELL 체결은 `trex.events` 정상 전략 topic 또는 DLQ에 synthetic owner message를 publish하지 않는다. stable reason code `sell_event_owner_missing`, tick-level counter, ERROR log, best-effort alarm으로 구분해 관측한다.
- 정상 owner가 있는 event의 publish 실패도 금융 처리를 rollback하지 않는다. 기존 payload의 `slot_id`, `event_type`, `sequence_at`을 변경하지 않으며, 재시작·반복 polling에서 같은 terminal execution의 event 재발행을 새로 보장하지 않는다.
- `docs/interface-contract.md`와 `docs/runtime-flows.md`의 RabbitMQ event routing 및 slot event 발행 구간에 owner 선택, pre-publish skip, 전달 실패 의미를 구현과 함께 동기화한다.
## Constraints
- `sell_strategy_id`와 `strategy_id`의 의미를 합치거나 legacy mapping을 추가하지 않는다.
- 새 persistence column이나 strategy-event outbox를 추가하지 않는다. restart 보장은 terminal 처리 전에 durable `PENDING_SELL` row를 다시 읽는 범위이며, terminal commit 뒤 실패한 event의 재생 저장소를 이번 Line에서 도입하지 않는다.
- publisher confirm과 1회 reconnect/retry, RabbitMQ mandatory publish 및 기존 transport DLQ 계약은 유지한다.
- alarm payload와 log는 secret, credential, connection string 또는 raw exception text를 포함하지 않는다.
- production broker 주문, production lifecycle, production daemon 재기동은 검증에서 실행하지 않는다.
## Non-Goals
- 이미 발생한 과거 DLQ message의 복구 또는 재발행
- 전략 consumer 구현 변경 및 전역 exactly-once 보장
- RabbitMQ topology, queue TTL/max-length, exchange 이름 변경
- BUY event owner 계약 변경
- durable strategy-event outbox 또는 운영 replay command 추가
+55
View File
@@ -0,0 +1,55 @@
---
id: "dcy-0012"
status: confirmed
---
# Ledger outbox idle poll 로그 억제와 관측 보존
## Problem
`LedgerWriter.run_once()`는 처리할 `ledger_outbox` row가 없는 정상 idle poll에도 매번 `claim_result=empty` INFO 로그를 남긴다. daemon 기본 주기 1초와 결합되어 실제 원장 work item이 거의 없는 운영 환경에서도 대량의 정상 로그가 생성되고, claim 상실·retry·conflict·delivery failure 같은 조치 가능한 신호가 묻힌다.
현재 `claim:empty` in-process counter는 증가하지만 writer 객체 밖의 운영 조회 표면에 연결되지 않는다. 따라서 empty INFO를 단순 삭제하면 로그 폭증은 해결되지만 empty poll 총량을 metric 또는 status에서 조회해야 한다는 Issue #97의 요구를 충족하지 못한다. 한편 polling cadence를 adaptive backoff나 event-driven wakeup으로 바꾸면 신규 item 처리 지연, process 간 알림, lease 만료·재claim에 대한 별도 계약이 필요하다.
## Evidence
- `E-001` GitHub Issue #97 (`https://github.com/fourblocks2020/trex-core/issues/97`)은 2026-09-16 00:00 KST부터 2026-09-18 21:31 KST까지 두 instance에서 `claim_result=empty` INFO 494,029줄이 발생했고 실제 신규 work item은 8건이었다고 기록한다.
- `E-002` 기준 revision `62e4f57c2b80ce28db87421c5e7b72a4316b8c5f`의 `trex_core/application/ledger_outbox.py`에서 `run_once()`는 `claim_pending()` 결과에 따라 `claim:empty` 또는 `claim:claimed` counter를 증가시키지만, empty/non-empty 구분 없이 INFO claim 로그를 남긴다.
- `E-003` 같은 파일의 `LedgerWriter.counters`는 process-local dict이며 이를 반환하는 status/RPC/exporter가 없다. 독립 read-only 조사도 현재 counter만으로는 운영 조회 가능성을 충족하지 못한다고 판정했다.
- `E-004` `trex_core/runtime/config.py`의 `ledger_interval_sec` 기본값은 1.0초이고 `trex_core/runtime/daemon.py::_ledger_loop()`는 매 interval마다 `run_once()`를 호출한다. writer 예외는 현재 로그 없이 삼켜진다.
- `E-005` `trex_core/application/ledger_outbox.py`의 batch claim INFO는 현재 `instance_key`, `claim_result`, `count`, `limit`만 남겨 correlation ID를 제공하지 않는다. targeted claim은 correlation ID를 남기며, claim lost 및 foreign/unowned item은 WARNING, retry/conflict/permanent/dead-letter outcome은 기존 counter와 로그 경로로 구분한다.
- `E-006` active `AC-0029`는 instance ownership, lease fencing, claim/outcome 관측과 secret 비노출을 소유하지만 idle poll 무로그와 운영 status 조회는 규정하지 않는다. 이번 capability는 `AC-0029`를 회귀 검증(`satisfy`)하면서 별도 delta AC를 생성(`create`)할 독립 운영 경계다.
- `E-007` focused baseline `uv run pytest -q tests/application/test_ledger_outbox.py tests/application/test_ledger_outbox_backoff.py tests/adapters/persistence/test_postgresql_ledger_outbox.py` 결과는 `20 passed, 16 skipped`였다. skip은 `TREX_POSTGRESQL_TEST_DSN`이 없는 PostgreSQL integration이다.
- `E-008` 저장소에는 범용 metrics exporter나 daemon status RPC가 없다. 기존 instance-scoped `trex.slots.req`는 `spy=true` 운영 조회와 `spy=false` 전략 조회를 구분하므로, operator 응답에만 additive process-lifetime ledger outbox metrics를 싣는 것이 새 transport/DB schema 없이 가능한 최소 surface다.
## Scope
- `S-001` `create`: 새 delta AC는 pending row가 없는 poll에서 `claim:empty`를 정확히 누적하되 기본 INFO 로그를 생성하지 않는 동작을 소유한다. 기본 `ledger_interval_sec=1.0`에서 24시간에 해당하는 `N=86,400`회의 `run_once()`를 실제 sleep 없이 두 writer(`instance-a`, `instance-b`)에 각각 호출한다. 각 writer의 `empty_polls_total`은 정확히 86,400이고 rendered INFO의 `claim_result=empty`는 0건이어야 한다.
- `S-002` `create`: instance-scoped `trex.slots.req`의 `spy=true` operator 응답에 top-level `ledger_outbox_metrics` snapshot을 additive로 노출한다. 정상 snapshot은 `available=true`, `scope=process_lifetime`, configured `instance_key`를 포함한다. `claims` object는 `empty_polls_total`, `claimed_batches_total`, `unavailable_total`, `lost_total`, `foreign_instance_total`을, `outcomes` object는 `completed_total`, `duplicate_completed_total`, `retryable_failure_total`, `conflict_blocked_total`, `permanent_failure_total`, `dead_lettered_total`을 누락 없이 0 이상의 정수로 반환한다. 값은 해당 daemon process의 configured instance writer만 집계하고 새 dict snapshot으로 반환한다. reader가 없거나 조회가 실패하면 `available=false`, `scope=process_lifetime`, configured `instance_key`, `reason=ledger_outbox_metrics_unavailable`을 반환하며 slots 조회 자체는 실패시키지 않는다. `spy=false` 전략 응답에는 `ledger_outbox_metrics` key 자체를 포함하지 않는다.
- `S-003` `create`: non-empty batch claim에서만 INFO를 남기고 non-zero `count`, `limit`, `instance_key`와 해당 batch의 non-empty correlation ID 목록을 보존한다. targeted claim은 기존 단일 correlation ID를 유지한다. empty poll은 correlation ID를 생성하거나 로그하지 않는다.
- `S-004` `satisfy AC-0029`: claim lost, foreign/unowned rejection, retryable, conflict, permanent failure, dead-letter와 성공/duplicate outcome의 severity, counter, instance ownership 및 lease fencing을 약화하지 않는다. 로그·metric·status에는 credential, account identifier, token, password, DSN, provider payload 또는 raw exception text를 포함하지 않는다.
- `S-005` `TrexDaemon._ledger_loop()`의 `ledger_writer.run_once()`가 예상 밖 예외를 던지면 ERROR 한 건에 `reason=ledger_writer_run_once_failed`, configured `instance_key`, exception class name인 `exc_type`만 남긴 뒤 다음 configured poll을 계속한다. 이 정책은 `_ledger_loop`에만 적용하며 raw exception message와 traceback/`exc_info`는 기록하지 않는다. sentinel 예외로 reason·type·loop survival과 secret 비노출을 함께 검증한다.
- `S-006` fixed `ledger_interval_sec` polling과 기존 retry `next_retry_at`, claim lease, ownership/filtering semantics는 변경하지 않는다. 신규 item은 daemon이 살아 있는 동안 enqueue 시점부터 다음 configured poll 기회에 처리 대상이 되어야 한다.
- `S-007` `docs/runtime-flows.md`와 관련 interface 문서를 idle logging, operator metrics, fixed cadence 결정에 맞게 동기화한다.
## Out of Scope
- `OS-001` adaptive idle backoff, PostgreSQL LISTEN/NOTIFY, event-driven wakeup, 별도 metrics exporter 및 durable metric persistence는 이번 Line에서 구현하지 않는다. 이들은 cross-process signaling과 별도 latency/SLO 설계가 필요한 후속 capability다.
- `OS-002` `ledger_outbox` schema, correlation ID 형식, lease TTL, retry/backoff 공식, dead-letter/conflict 정책과 portfolio-hub API 계약은 변경하지 않는다.
- `OS-003` AC-0029 원문, 승인된 기존 REQ/Discovery bytes와 기존 lifecycle status는 변경하지 않는다.
- `OS-004` production daemon 재기동, production PostgreSQL row 조작, 실제 원장 delivery와 운영 로그 삭제는 수행하지 않는다.
## Risks and Unknowns
### Risks
- `R-001` empty와 non-empty 분기를 잘못 배치하면 실제 claim INFO까지 사라질 수 있다. empty/no-log와 non-empty/identity-log를 별도 회귀로 검증한다.
- `R-002` process-lifetime metric은 daemon 재시작 시 0으로 초기화된다. response에 scope를 명시하고 durable total로 오인하지 않도록 문서화한다.
- `R-003` operator metric을 strategy 응답에도 싣거나 mutable counters dict를 그대로 반환하면 불필요한 내부 정보 노출 또는 동시 변경 위험이 생긴다. `spy=true`에서만 정수 snapshot을 새 dict로 반환한다.
- `R-004` daemon exception 로그에 raw exception text나 traceback을 넣으면 DSN·credential이 노출될 수 있다. sentinel 예외로 sanitized logging과 loop survival을 함께 검증한다.
- `R-005` PostgreSQL fixture가 없으면 real-backend multi-instance/lease 회귀는 skip된다. 해당 gap을 명시하고 기존 gated integration selector를 유지한다.
### Assumptions
- `A-001` Issue #97의 “metric 또는 status에서 조회”는 daemon process lifetime 동안 instance별 누적값을 operator의 `spy=true` slots RPC/CLI JSON 응답에서 조회하는 것으로 충족한다. durable historical aggregation은 요구하지 않는다.
- `A-002` 허용 가능한 신규 item 지연은 기존 configured `ledger_interval_sec`의 다음 poll 기회까지다. cadence를 유지하므로 이번 변경이 추가 지연을 만들지 않는다.
@@ -0,0 +1,35 @@
# Activity Log — line-0012
## Specification approval
- Issue: `fourblocks2020/trex-core#97`
- Base revision: `62e4f57c2b80ce28db87421c5e7b72a4316b8c5f`
- Branch: `feat/issue-97-outbox-idle-observability`
- Discovery `dcy-0012` was confirmed by the user after independent review PASS.
- Confirmed Discovery digest after lifecycle transition: `8a18a9d1a22d92f13f59c2b6d0ce942a3dc24d0959cf8943d1ea843a8e7e536f`.
- Specification review PASS covered `REQ-0012`, create `AC-0040`, and unchanged satisfy target `AC-0029`.
- The user explicitly approved `REQ-0012` and `AC-0040/AC-0029` from approval packet SHA-256 `f1460df034b635174f8a64d59ceec1c9f5d2c3c4f818ab4b939550e455121666`.
- Lifecycle result: `REQ-0012=approved`, `AC-0040=active`; `AC-0029` remains active and unchanged.
- No Normative Design Documents are part of the approval baseline.
## Implementation plan
1. Suppress empty-claim INFO while retaining process-lifetime counters and exact non-empty batch correlation IDs.
2. Add immutable operator metrics snapshots to spy-only slots RPC responses.
3. Add sanitized `_ledger_loop` exception observability and prove loop survival.
4. Synchronize runtime/interface documentation and run focused, full, static, ProofLine, and independent verification.
5. Deliver a pull request without production deployment or live remediation.
## Implementation and verification
- idle empty claim INFO를 제거하고 instance-local process-lifetime metric snapshot을 추가했다.
- `trex.slots.req`의 `spy=true` 응답에 metrics를 연결하고 `spy=false` 응답은 기존 shape를 유지했다.
- `_ledger_loop` unexpected writer exception을 sanitized ERROR로 기록하고 loop를 유지하도록 했다.
- `docs/runtime-flows.md`, `docs/interface-contract.md`를 구현 계약과 동기화했다.
- Focused tests: `42 passed, 16 skipped`; PostgreSQL fixture 부재로 16건은 환경 미검증이다.
- Full tests: `1267 passed, 98 skipped`; skip에는 PostgreSQL fixture 의존 테스트가 포함된다.
- Ruff, `compileall`, `proofline validate`, `git diff --check`: 통과.
- Base revision sabotage probes: old idle logging, missing snapshot, missing RPC wiring, silent daemon exception에 대해 `4 failed`로 변경 검출을 확인했다.
- 독립 구현 검토: PASS; production composition wiring을 포함해 material blocker 없음.
- 미실행: production daemon lifecycle, broker 운영, production PostgreSQL 변경 또는 재기동.
- 다음 행동: commit, remote branch push, PR 생성 및 exact head CI 확인.
+4
View File
@@ -0,0 +1,4 @@
---
id: "line-0012"
status: discovery
---
+43
View File
@@ -0,0 +1,43 @@
---
id: "req-0012"
status: approved
discovery: "dcy-0012"
criteria:
create:
- "ac-0040"
update: []
retire: []
satisfy:
- "ac-0029"
---
# Ledger outbox idle poll 로그 억제와 관측 보존
## Objective
정상 idle 상태의 ledger outbox poll이 초 단위 INFO 로그를 만들지 않게 하면서, process-lifetime claim·outcome 통계를 operator가 instance별로 조회할 수 있게 한다. 실제 work와 failure는 correlation 가능한 sanitized observability를 유지하고 기존 lease·ownership·retry 동작은 변경하지 않는다.
## Scope
- pending row가 없는 `LedgerWriter.run_once()`는 `claim:empty`를 누적하지만 INFO claim log를 남기지 않는다.
- non-empty batch claim INFO는 configured `instance_key`, non-zero `count`, `limit`, 해당 batch의 non-empty correlation ID를 repository claim 순서와 같은 `correlation_ids` JSON string array로 제공한다.
- `trex.slots.req`의 `spy=true` 응답에 top-level `ledger_outbox_metrics` process-lifetime snapshot을 additive로 제공한다. `spy=false` 응답에는 해당 key를 포함하지 않는다.
- metrics reader가 없거나 조회에 실패해도 slots 조회는 성공하며 stable unavailable snapshot을 반환한다.
- `_ledger_loop()`의 예상 밖 writer exception은 stable reason, configured `instance_key`, exception class name만 ERROR로 남기고 다음 poll을 계속한다.
- `AC-0029`의 ownership, active lease exclusion, stale outcome fencing, foreign/unowned isolation, retry 및 terminal outcome 계약을 회귀 검증한다.
- `docs/runtime-flows.md`와 `docs/interface-contract.md`를 idle suppression, operator-only metrics, process-lifetime scope, fixed cadence와 sanitized writer exception contract에 맞게 동기화한다.
## Constraints
- metrics snapshot은 해당 daemon process의 configured instance writer만 집계하는 process-lifetime 값이며 process restart 시 0부터 시작한다.
- counter는 누락 없는 0 이상의 정수이고 호출자가 내부 mutable state를 변경할 수 없는 새 snapshot이어야 한다.
- log, metric, status에는 credential, account identifier, token, password, DSN, provider payload, raw exception message 또는 traceback을 포함하지 않는다.
- fixed `ledger_interval_sec`, retry `next_retry_at`, claim lease, ownership/filtering, persistence schema와 ledger API contract는 변경하지 않는다.
- PostgreSQL multi-instance verification은 disposable fixture가 있을 때 실행하며 fixture 부재로 skip되면 환경 미검증으로 명시한다.
## Non-Goals
- adaptive idle backoff 또는 event-driven wakeup을 추가하지 않는다.
- 범용 metrics exporter나 신규 transport/status RPC를 만들지 않는다.
- portfolio/business-state, broker order/fill, correlation-ID format, dead-letter policy를 변경하지 않는다.
- production deployment, live database migration, backfill 또는 remediation을 수행하지 않는다.
+36 -3
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft
- Owner: project maintainers
- Last Updated (KST): 2026-07-15 (BAT-057 — operator broker truth hotfix baseline alignment)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002; operator broker truth = dcy-037 / BAT-057
- Last Updated (KST): 2026-09-20 (trex-core Issue #97 ledger outbox idle observability)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002; operator broker truth = dcy-037 / BAT-057; ledger venue transport = portfolio-hub ProofLine req-0009
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
## Project Summary
@@ -63,11 +63,16 @@
| 패턴 | 표면 | 요청 속성 | 응답 |
|---|---|---|---|
| 슬롯 RPC | `trex.slots.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 `{slots[], degraded, reason, message}` (REQ-073/074, BAT-035) |
| 슬롯 RPC | `trex.slots.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 `{slots[], degraded, reason, message, ledger_outbox_metrics?}` (`ledger_outbox_metrics`는 `spy=true` 전용, REQ-073/074, AC-0040) |
| intent RPC | `trex.intent.req` | `correlation_id` = intent 멱등키, `reply_to=amq.rabbitmq.reply-to`, `delivery_mode=2` | reply-to 큐로 `{intent_id, status:admitted|rejected, slot_id?, reason?}`. retryability·broker attempt·KIS failure 정보는 strategy 응답에 포함하지 않는다. 체결/종료는 응답 아닌 이벤트로 |
| update_thresholds | `trex.intent.req` | `action="update_thresholds"`, `reply_to` | reply-to 큐로 `{status:updated|ignored, slot_id, reason?}` (REQ-040) |
| 이벤트 pub/sub | `trex.events` (topic) | routing key `{event_type}.{strategy_id}.{symbol}` | 전략 큐가 자기 `strategy_id` 패턴으로 바인딩 |
- `slot_filled_sell`의 `strategy_id`는 SELL admission에서 확정해 `sell_strategy_id`에 보존한다. 우선순위는 요청의 non-empty `sell_strategy_id` → 기존 보존 `sell_strategy_id` → legacy/safety 경로의 non-empty `strategy_id`다.
- SELL terminal transition이 owner 필드를 clear하기 전에 보존 owner를 routing hint로 캡처한다. full fill, partial-fill cancel, 외부 cancel 후 재매도, 재기동 후 reprocessing에도 같은 owner를 사용한다.
- 위 source가 모두 없으면 `slot_filled_sell._.{symbol}`을 발행하지 않는다. 금융 상태 전이와 ledger/outbox는 유지하고, 해당 tick의 `sell_event_owner_missing` counter·ERROR log·best-effort alarm을 남긴다. ownerless event를 일반 unroutable DLQ 메시지로 합성하지 않는다.
- event payload의 기존 `slot_id`, `event_type`, `sequence_at` identity는 producer가 보존한다. delivery는 at-least-once이며 consumer의 멱등 처리나 exactly-once는 이 인터페이스의 보장이 아니다.
### Message contracts
#### `trex.slots.req` 요청 payload (REQ-073 AC-1, BAT-035; REQ-116 BAT-055)
@@ -157,6 +162,17 @@
- `spy=false`(기본): holding 슬롯 응답 후 `WatchdogStore.update(slot_id)` 호출 — 전략 폴링 = 생존 신호.
- `spy=true`: watchdog 갱신 건너뜀 — 운영자 CLI(`universe`/`slots`) 가 항상 사용.
#### `spy=true` ledger outbox metrics (AC-0040)
- `ledger_outbox_metrics`는 `spy=true` 운영자 응답의 top-level additive field다. `spy=false` 전략 응답에는 key 자체를 포함하지 않는다.
- 정상 snapshot은 `available=true`, `scope="process_lifetime"`, configured `instance_key`와 다음 누적 정수를 제공한다.
- `claims`: `empty_polls_total`, `claimed_batches_total`, `unavailable_total`, `lost_total`, `foreign_instance_total`
- `outcomes`: `completed_total`, `duplicate_completed_total`, `retryable_failure_total`, `conflict_blocked_total`, `permanent_failure_total`, `dead_lettered_total`
- 각 값은 해당 daemon process의 configured instance writer에서만 누적하며 0 이상의 정수다. snapshot은 내부 mutable counter와 분리된 새 값이고 daemon restart 때 0으로 초기화된다.
- metrics reader가 없거나 조회에 실패하면 slots 조회는 계속 성공하고 `available=false`, `scope="process_lifetime"`, configured `instance_key`, `reason="ledger_outbox_metrics_unavailable"`만 반환한다. raw exception text는 응답에 포함하지 않는다.
- ledger writer는 fixed `ledger_interval_sec` cadence를 유지한다. empty claim은 `empty_polls_total`만 증가시키고 INFO를 남기지 않는다. non-empty batch INFO만 `instance_key`, non-zero `count`, `limit`, repository claim 순서의 `correlation_ids` JSON string array를 포함한다.
- `_ledger_loop`의 예상 밖 writer exception은 `reason=ledger_writer_run_once_failed`, configured instance, exception class name만 ERROR로 남긴다. raw exception message, traceback과 `exc_info`는 금지하며 loop는 다음 configured poll을 계속한다.
### 운영자 CLI ↔ RPC 매핑 (dcy-016 / REQ-032)
운영자 CLI 명령은 데몬 RabbitMQ RPC 로 통일된다(단일 writer admission 보존, DB 직접 쓰기 없음). 인스턴스 라우팅: `--config <toml>`(canonical, REQ-048) 또는 `--instance <key>`(단축·override), 미지정 시 `default`.
@@ -259,6 +275,23 @@ resume_kill_switch 요청·응답 (REQ-046):
| GET | `/v1/auth/whoami` | `{daemon_id: str, ...}` |
| GET | `/v1/portfolios?managed_by_daemon_id=<id>` | `{items: [PortfolioItem, ...]}` 또는 list root |
## Interface: `LedgerPort` → portfolio-hub trade write (BAT-006; portfolio-hub REQ-0009)
- Type: external-service (HTTP, portfolio-hub)
- Consumers / Producers: `LedgerWriter`(소비) → `PortfolioHubLedger`(transport adapter) → portfolio-hub
- Purpose: durable `LedgerTrade`를 `POST /v1/portfolios/{portfolio_id}/ledger/trades` 요청으로 직렬화한다.
### Venue Provenance Mapping
| Core field | HTTP request field | Contract |
|---|---|---|
| `LedgerTrade.venue` | `execution_venue` | 실제 체결 venue. `KRX`, `NXT`, `null`; `SOR` 금지 |
| `LedgerTrade.order_venue` | `order_venue` | 원래 주문 routing venue. `KRX`, `NXT`, `SOR` |
- `PortfolioHubLedger._payload()`는 두 값을 그대로 전달하며 `order_venue`에서 `execution_venue`를 추론하지 않는다.
- Core producer, `LedgerTrade` domain model, durable outbox schema와 broker-routing 동작은 변경하지 않는다. 기존 outbox가 보존한 두 필드가 HTTP 경계에서 누락되지 않도록 하는 transport mapping이다.
- Rollout 순서: nullable fields를 수용하는 portfolio-hub API/migration을 먼저 또는 동시에 배포한 뒤 이 adapter를 배포한다. 구형 Core가 두 필드를 보내지 않는 mixed-version 요청은 Hub에서 `null`로 수용한다.
## Interface: `SnapshotStore` + `PauseStore` + `SnapshotScheduler` (Protocol, BAT-008)
- Type: internal-service
+37 -14
View File
@@ -47,12 +47,26 @@ Release tag의 clean checkout에서 `./scripts/build_release_image.sh trex-core:
non-root user 및 container `--version`을 검증한다. 전체 명령과 registry 사용법은
[`docs/ops/release-image-build.md`](ops/release-image-build.md)를 따른다.
### 배포/복구/수동 재기동 provenance gate (issue #87)
### 배포/복구/수동 재기동 provenance 검증 (issue #87)
배포, 복구, 수동 stop/start/recreate 전에 아래 gate를 실행해 **검증 가능한 release record**를 남긴다.
이 저장소는 provenance **검증 contract**만 제공한다. 실행 중 target의 inspect, observed evidence 생성, 대상 선택, 승인, operation lock, stop/start/recreate/deploy, checkout 복구와 non-zero exit enforcement는 운영 계층이 소유한다. Core gate는 runtime을 직접 inspect하거나 lifecycle command를 실행하지 않으며, caller evidence를 `operator_observed`로 명시한다.
운영 계층은 각 PRE/POST 시점에 exact schema의 JSON을 생성한다. Unknown/duplicate key, 형식 오류, timezone 없는 시각과 secret-like material은 record를 만들기 전에 거부된다.
```json
{
"schema_version": 1,
"target_id": "<canonical-runtime-target>",
"target_kind": "<operations-owned-kind>",
"image_id": "sha256:<64-lowercase-hex>",
"image_digest": "sha256:<64-lowercase-hex>",
"oci_revision": "<40-lowercase-hex>",
"observed_at": "2026-09-20T02:00:00Z"
}
```
```bash
# PRE (대상 인스턴스 1개만 지정; POST도 같은 operation-id로 별도 실행)
# PRE: 대상 인스턴스 1개만 지정한다. POST도 같은 operation-id로 별도 실행한다.
python scripts/release_provenance_gate.py \
--actor "<operator>" \
--reason "<ticket-or-incident>" \
@@ -60,8 +74,7 @@ python scripts/release_provenance_gate.py \
--operation-id "<deployment-or-incident-id>" \
--phase "pre" \
--command-class "manual-stop" \
--expected-oci-revision "<40-character-running-image-oci-revision>" \
--image-digest "sha256:<64-hex-running-image-digest>" \
--observed-evidence-file "/absolute/path/pre-observed-evidence.json" \
--runtime-file "trex_core/runtime/daemon.py" \
--runtime-file "trex_core/runtime/cli.py" \
--capture-command "pending_slots=.venv/bin/trex-core slots --status pending --instance <instance_key> --json" \
@@ -69,16 +82,26 @@ python scripts/release_provenance_gate.py \
--capture-command "queue_consumers=rabbitmqctl list_consumers"
```
- gate는 audit checkout이 detached HEAD인지 확인하고 NUL 구분 `git status --porcelain=v1`로 clean 여부를 검사한다. branch checkout 또는 dirty checkout이면 운영 상태 조회 명령도 실행하지 않고 종료 코드 `2`로 중단하며, dirty 변경 파일은 audit checkout 밖의 `<checkout>-release-provenance-isolation/`에 작업별 디렉터리로 복사한다. 우회 옵션은 없다.
- `checkout.head_revision`과 필수 `--expected-oci-revision` 일치 여부를 검사해 audit checkout HEAD가 실행 이미지 OCI revision과 같은지 검증한다.
- 필수 `--image-digest` 형식과 하나 이상의 `--runtime-file` 검증 결과(sha256 + revision 일치 여부)가 JSON record에 저장된다.
- actor/timestamp/instance/operation-id/phase/command-class/reason이 JSON record에 함께 저장되어 PRE/POST 및 stop/start/recreate 감사 추적이 가능하다. actor/reason에 credential 형태의 key/value가 포함되면 기록 전에 거부한다.
- PRE/POST 모두 실행해 재기동 전후의 `pending_slots`, `reconciliation_slots`, `queue_consumers`, broker-side-effect 로그(`order_submit/fill/reject/cancel`)를 자동 캡처한다. 필수 조회 또는 로그 캡처가 누락·실패하면 gate는 실패한다.
- capture command는 위의 인스턴스 지정 `trex-core slots` 두 형식과 `rabbitmqctl list_consumers`만 허용한다. 주문 제출·취소, DB 복구, 자동 sync/verify live 실행은 거부한다.
- broker-side-effect 로그 입력은 기본 `/tmp/trex-core.log`를 사용하며, 로그 파일 위치가 다르면 `--broker-log-path <path>`로 명시한다. record에는 원문 대신 파일 sha256·스캔 행 수와 event 종류·행 번호·행 sha256만 남긴다. 파일을 정상 스캔했지만 일치 이벤트가 없으면 `captured_empty`, 파일 누락·읽기 실패는 각각 `unavailable`·`error`로 기록하며 후자는 gate를 차단한다.
- record와 격리 디렉터리는 audit checkout 밖에 둔다. 기본 record 위치는 `<checkout>-release-provenance/`이며, 충돌 없는 파일명을 exclusive-create하고 read-only mode로 전환해 기존 record를 덮어쓰지 않는다.
- Gate stdout은 `record_path`, raw record bytes의 `record_sha256`, `gate_passed`를 포함한 v1 JSON receipt다. 운영 계층은 PRE/POST receipt를 신뢰 경계 밖에 보존하고 non-zero이면 lifecycle을 실행하지 않는다.
- Gate는 audit checkout이 detached HEAD인지 확인하고 NUL 구분 `git status --porcelain=v1`로 clean 여부를 검사한다. branch checkout 또는 dirty checkout이면 운영 상태 조회 명령도 실행하지 않고 종료 코드 `2`로 중단한다.
- Observed `oci_revision`, audit checkout HEAD와 revision별 runtime-file bytes가 모두 일치해야 한다. Target/image 값은 caller 관측값이며 core가 직접 관측했다고 주장하지 않는다.
- PRE/POST에서 `pending_slots`, `reconciliation_slots`, `queue_consumers`, broker-side-effect 로그를 read-only로 캡처한다. 필수 조회 또는 로그 캡처가 누락·실패하면 gate는 실패한다.
- Capture command는 위의 인스턴스 지정 `trex-core slots` 두 형식과 `rabbitmqctl list_consumers`만 허용한다. 주문 제출·취소, DB 복구 또는 lifecycle 명령은 실행하지 않는다.
- Dirty checkout의 regular file과 symlink는 checkout 밖 `<checkout>-release-provenance-isolation/`에 원본을 변경하지 않고 복사한다. v1 isolation manifest는 path, type, size, source/copy SHA-256과 일치 verdict를 기록하며 unsupported type·escape·copy mismatch는 실패한다.
- Record와 manifest는 audit checkout 밖에 exclusive-create하고 read-only mode로 전환한다. 기존 파일 충돌은 덮어쓰지 않고 실패한다.
재기동 후에는 같은 `--operation-id`, `--phase "post"`, 해당 command class(예: `manual-start`)로 POST record를 추가 저장한다.
재기동 후 운영 계층은 새 observed evidence로 POST gate를 실행한다. 두 receipt의 exact path와 trusted digest를 명시해 pair manifest를 생성한다. Validator는 디렉터리를 scan하거나 latest file을 추정하지 않는다. PRE/POST의 image identity와 command class는 서로 달라도 되지만 operation ID, instance, target kind/ID와 각 record의 success evidence는 일치해야 한다.
```bash
python scripts/verify_release_provenance_pair.py \
--pre-record "<pre-receipt.record_path>" \
--pre-sha256 "<pre-receipt.record_sha256>" \
--post-record "<post-receipt.record_path>" \
--post-sha256 "<post-receipt.record_sha256>" \
--output "/absolute/path/<operation-id>-pair-manifest.json"
```
Pair manifest는 input raw bytes를 trusted digest와 다시 비교한 뒤 exclusive-create/read-only로 저장한다. Digest mismatch, malformed/legacy/failure record, identity mismatch와 output collision은 manifest 없이 non-zero로 종료한다. 이 성공은 provenance record pair의 일관성만 증명하며 실제 lifecycle 실행 여부나 운영 정책 승인을 증명하지 않는다.
---
+4 -4
View File
@@ -3,8 +3,8 @@
운영팀은 release tag의 clean checkout에서 아래 명령으로 이미지를 빌드한다.
```bash
git checkout v0.7.0
./scripts/build_release_image.sh trex-core:0.7.0
git checkout v0.9.0
./scripts/build_release_image.sh trex-core:0.9.0
```
스크립트는 `pyproject.toml`의 canonical version과 image tag가 일치하는지 확인하고 현재
@@ -16,8 +16,8 @@ tag, label 불일치, non-root user 불일치 또는 container `--version` smoke
Registry 경로를 사용할 때도 version tag를 유지한다.
```bash
./scripts/build_release_image.sh registry.example/trex-core:0.7.0
docker push registry.example/trex-core:0.7.0
./scripts/build_release_image.sh registry.example/trex-core:0.9.0
docker push registry.example/trex-core:0.9.0
```
배포 전 `docker image inspect`에서 나온 revision과 image digest를
+7 -3
View File
@@ -50,7 +50,7 @@
1. 전략 모듈 또는 운영자가 RPC 요청을 `trex.slots.req` 큐로 publish — payload 에 `filter`(status 필터) + `spy`(워치독 억제 여부), properties 에 `correlation_id` + `reply_to`.
2. `trex-slots-rpc` 스레드가 메시지 수신 → `make_slots_rpc_handler(slot_repo, ...)` 로 슬롯 목록 산출.
3. `spy=false`(기본) 면 holding 슬롯 `WatchdogStore.update(slot_id)` 호출(전략 폴링 = 생존 신호, REQ-034).
4. 응답 `{slots[], degraded, reason, message}` 를 `reply_to` 큐로 publish → manual ack.
4. 응답 `{slots[], degraded, reason, message}` 를 `reply_to` 큐로 publish → manual ack. `spy=true` 운영자 응답에는 process-lifetime `ledger_outbox_metrics` snapshot을 top-level에 추가하고, `spy=false` 전략 응답에는 해당 key를 노출하지 않는다.
5. read-only 이므로 dedup 불요; 재요청 시 동일 응답을 다시 생성한다.
## Flow: `매수/매도 intent admission RPC` (RabbitMQ `trex.intent.req`, BAT-007)
@@ -65,9 +65,11 @@
## Flow: `슬롯 이벤트 발행/구독` (RabbitMQ `trex.events`, BAT-007, BAT-047)
1. 체결: execution 루프가 `pending_buy→holding`(또는 `pending_sell→closed`)을 commit + ledger outbox enqueue (REQ-018/019).
1. 체결: execution 루프가 `pending_buy→holding`(또는 `pending_sell→inactive`)을 commit + ledger outbox enqueue (REQ-018/019).
2. 같은 스레드에서 `event_publisher.publish_event(rk, payload)` 호출 (REQ-091 재시도 정책 포함, BAT-047).
- routing key: `{event_type}.{strategy_id}.{symbol}` (예: `slot_filled_buy.A.005930`).
- SELL owner는 admission 시 요청의 non-empty `sell_strategy_id` → 기존 보존 `sell_strategy_id` → legacy/safety `strategy_id` 순으로 확정해 pending slot에 저장한다. terminal transition 전에 이를 캡처하므로 full fill, partial-fill cancel, zero-fill cancel 후 ownerless 재매도, 외부 cancel, 재기동/reprocessing에서 mutable post-transition field를 추측하지 않는다.
- SELL owner가 끝내 없으면 정상 `trex.events` publish와 `_` fallback, unroutable DLQ 합성을 모두 skip한다. 대신 `ExecutionResult.sell_event_owner_missing` tick counter + `reason=sell_event_owner_missing` ERROR log + best-effort critical alarm을 남긴다. 이미 commit한 broker fill, slot transition, ledger/outbox는 rollback하지 않는다.
- payload: 현재 상태 + `sequence_at`(슬롯 `updated_at` iso) 운반 → 수신자는 last-wins 적용 가능 (순서 보증 불요).
- publisher confirms 활성. `is_open=True`를 맹신하지 않음 — 발행 실패 시(half-open 포함) 강제 재연결(`close`+`_connect(timeout=2s)`) 후 1회 재시도. 재시도도 실패하면 `TransportUnavailableError` 전파.
- unroutable(구독자 없음): `UnroutableError` → `_publish_dlq`로 `trex.events.dlq`(durable)에 보존. DLQ 유입 시 ERROR 로그 발행(REQ-093).
@@ -258,7 +260,7 @@
- **슬리피지 보호 지정가 + KRX 호가단위 스냅(dcy-022/REQ-070, dcy-030/REQ-083)**: 슬롯에 `slippage_pct` 가 있고 `limit_price` 도 있으면, raw 실효가 = `limit_price×(1±pct/100)` 를 KRX 호가단위(REQ-083 `domain/pricing.py`)로 **스냅**해 제출한다. 방향 = **상한 준수**: 매수 = `floor_to_tick(raw)`(slippage 상한 미초과), 매도 = `ceil_to_tick(raw)`(매도 하한 미초과). band<1tick 엣지에서 미체결을 막기 위해 매수 = `max(floor_to_tick(raw), ceil_to_tick(limit_price))`, 매도 = `min(ceil_to_tick(raw), floor_to_tick(limit_price))` 로 entry 유효 호가를 보장한다. `limit_price` 없으면(시장가) 스냅 무시. 미지정 슬롯은 기존 동작(회귀 없음). 주식 전용(ETF/ETN·2,000원 미만 비주식 미지원, dcy-030 OQ-1). 이전 구현은 `int(ceil/floor)` 무스냅이라 호가단위 위반으로 KIS 거부(issue #28) — CHG-20260616-01 에서 스냅·방향(체결 보장→상한 준수) 정정.
2. poll: `list_dispatched_pending(instance_key)`(broker_order_id NOT NULL) → `BrokerPort.poll_fill` → 체결 시 `pending_buy→holding`(avg_price/qty) / `pending_sell→inactive`(점유 초기화·자산 정체성 보존), 거부 시 롤백. 전이 후 `clear_dispatch`.
- **체결가 백필 가드(dcy-032/REQ-095/096, BAT-048)**: `_resolve_fill_price(fs, slot)` 헬퍼가 체결가를 결정. (1) `fs.avg_price`가 양수이면 그대로 사용(happy path — WARN 없음). (2) `fs.avg_price=None` 또는 `≤0`이고 `slot.limit_price>0`이면 지정가로 백필 + `reason=avg_price_backfill` **WARN 1건** 발행(슬롯 평단·원장 price에 백필값 사용, 0 저장 방지). (3) 지정가마저 None/≤0이면 체결 처리를 **skip**(슬롯 전이·원장 기록 미수행) + `reason=fill_price_unresolvable` 강 WARN 발행 — 슬롯이 PENDING 상태로 남아 stale_pending 경보가 운영자 개입을 유도. 이 가드는 `_on_filled`·`_on_partial_cancel`·`_record_ledger` 세 경로에 공통 적용된다(`reconcile()` 경로도 동일 헬퍼를 탄다).
- 외부 취소 감지(dcy-013/REQ-012/013, issue #9): 계좌에서 직접 취소된 주문을 KIS `poll_fill` 이 `cncl_yn="Y"` 로 파싱한다. **체결 0 취소** → `FillStatus(state="cancelled")` → 기존 롤백 경로(`pending_sell→holding`, `clear_dispatch`)로 자동 복구(영구 갇힘 제거). **부분 체결 후 취소**(`is_partial_cancel`) → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held, cost basis 유지 / 매수: 체결분만) + `holding` 복귀(OQ-1 A안, 과매도·이중기록 방지).
- 외부 취소 감지(dcy-013/REQ-012/013, issue #9): 계좌에서 직접 취소된 주문을 KIS `poll_fill` 이 `cncl_yn="Y"` 로 파싱한다. **체결 0 취소** → `FillStatus(state="cancelled")` → 기존 롤백 경로(`pending_sell→holding`, `clear_dispatch`)로 자동 복구하며 보존 `sell_strategy_id`는 유지된다. owner 없는 재매도는 이 owner를 재사용한다. **부분 체결 후 취소**(`is_partial_cancel`) → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held, cost basis 유지 / 매수: 체결분만) + `holding` 복귀(OQ-1 A안, 과매도·이중기록 방지); SELL event routing도 보존 owner를 사용한다.
- pending stale 워치독(dcy-013/REQ-014 FR-3, Alert-only): `accepted`/`partially_filled`(취소 아님)로 다음 tick 대기 중인 슬롯이 `TREX_PENDING_STALE_THRESHOLD`(초, 0=비활성)를 초과해 머물면 WARN 로그 + `ExecutionResult.stale_pending` 카운터로 가시화한다. **상태는 변경하지 않는다**(auto-remediation 후속) — 취소 감지 누락(R-1) 등 미지 원인의 무한 갇힘이 silent 로 남지 않게 하는 안전망.
- **timeout 자동 취소(dcy-022/REQ-069, dcy-041)**: `accepted`/`partially_filled` 상태 슬롯에 `timeout_sec`가 있고 경과 시간이 초과하면 외부 호출 전에 `cancel_state=unknown`을 예약 저장한 뒤 취소를 한 번 보낸다. 즉시 결과는 `requested|rejected|unknown`으로 교체하지만 **슬롯 상태는 변경하지 않는다**. 같은 주문은 자동 재취소하지 않으며 다음 poll의 `cancelled|filled`만 최종 상태를 확정한다.
3. reconcile(재기동): 서빙 전 `list_dispatched_pending` 재폴링으로 크래시 후 복구(중복 주문 0). 미디스패치(broker_order_id NULL) 슬롯은 다음 dispatch 가 1회만 송신.
@@ -312,6 +314,8 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
4. 터미널 실패 가시화(dcy-015/REQ-018): `delivery_failed`(permanent/dead-letter)는 `WriterStats.delivery_failed` 카운터 + WARN 로그 + 구분 status 로 노출되어 정상 `pending` 과 구별된다(무성 영속 실패 금지). dead-letter 는 폐기가 아니라 운영자 재처리 가능 상태이며 `find_ledger_gap_slots`(REQ-055)·skip 가시화와 연계해 누락을 추적한다.
5. 백오프 파라미터는 env 설정: `TREX_LEDGER_RETRY_BASE_SEC`(기본 1.0)/`TREX_LEDGER_RETRY_CAP_SEC`(기본 300)/`TREX_LEDGER_MAX_ATTEMPTS`(기본 8).
6. portfolio-hub 장기 장애에도 outbox 가 적재되어 무유실. 재시작 시 PostgreSQL `ledger_outbox.status='pending'` 행을 그대로 이어 처리한다(터미널 `delivery_failed`/`conflict_blocked` 는 제외).
7. 데몬은 configured `ledger_interval_sec`(기본 1초)의 fixed cadence를 유지한다. pending row가 없는 정상 idle poll은 process-local `claim:empty` counter만 증가시키고 INFO 로그를 남기지 않는다. non-empty batch만 INFO를 남기며 configured `instance_key`, non-zero `count`, `limit`, repository claim 순서의 `correlation_ids` JSON string array를 포함한다.
8. process-lifetime counter는 daemon restart 때 0으로 초기화되며 DB나 별도 exporter에 영속화하지 않는다. `trex.slots.req`의 `spy=true` 응답에서 claim/outcome totals를 조회할 수 있다. writer 호출이 예상 밖 예외를 던지면 `_ledger_loop`는 `reason=ledger_writer_run_once_failed`, configured instance와 exception class name만 ERROR로 남기고 다음 poll을 계속한다. raw exception message, traceback과 `exc_info`는 기록하지 않는다.
## Flow: `구조화 로깅 / 관측성` (BAT-045·046 / dcy-029, REQ-084~088)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "trex-core"
version = "0.7.0"
version = "0.9.0"
description = "Shared core for the trex product family; first domain: slot lifecycle (storage-agnostic pure domain)"
requires-python = ">=3.12"
dependencies = [
+20 -8
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shlex
@@ -18,6 +19,7 @@ if __package__ in {None, ""}:
from trex_core.adapters.persistence.ops_checks import (
collect_release_provenance,
isolate_dirty_checkout_files,
load_observed_evidence,
)
_COMMAND_CLASSES = ("deploy", "recovery", "manual-stop", "manual-start", "recreate")
@@ -92,8 +94,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--phase", required=True, choices=("pre", "post"))
parser.add_argument("--command-class", required=True, choices=_COMMAND_CLASSES)
parser.add_argument("--checkout-dir", default=".")
parser.add_argument("--expected-oci-revision", required=True)
parser.add_argument("--image-digest", required=True)
parser.add_argument("--observed-evidence-file", required=True)
parser.add_argument("--runtime-file", action="append", required=True)
parser.add_argument("--broker-log-path", default="/tmp/trex-core.log")
parser.add_argument("--capture-command", action="append", required=True)
@@ -102,6 +103,7 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
try:
observed_evidence = load_observed_evidence(args.observed_evidence_file)
capture_commands: dict[str, list[str]] = {}
for raw in args.capture_command:
key, command = _parse_capture_command(raw)
@@ -136,13 +138,12 @@ def main(argv: list[str] | None = None) -> int:
operation_id=args.operation_id,
phase=args.phase,
checkout_dir=checkout,
expected_oci_revision=args.expected_oci_revision,
image_digest=args.image_digest,
observed_evidence=observed_evidence,
runtime_files=args.runtime_file,
capture_commands=capture_commands,
broker_log_path=args.broker_log_path,
)
except (RuntimeError, ValueError) as exc:
except (RuntimeError, TypeError, ValueError) as exc:
parser.error(str(exc))
output_dir.mkdir(parents=True, exist_ok=True)
@@ -162,10 +163,21 @@ def main(argv: list[str] | None = None) -> int:
)
# Exclusive create prevents a provenance record from being overwritten.
with output_path.open("x", encoding="utf-8") as fp:
fp.write(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
record_bytes = (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
with output_path.open("xb") as fp:
fp.write(record_bytes)
output_path.chmod(0o444)
print(str(output_path))
print(
json.dumps(
{
"schema_version": 1,
"record_path": str(output_path.resolve()),
"record_sha256": hashlib.sha256(record_bytes).hexdigest(),
"gate_passed": bool(report["gate"]["passed"]),
},
separators=(",", ":"),
)
)
if dirty_checkout:
return 2
return 0 if report["gate"]["passed"] else 1
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Verify one explicit release-provenance PRE/POST pair."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from trex_core.adapters.persistence.ops_checks import verify_release_provenance_pair
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pre-record", required=True)
parser.add_argument("--pre-sha256", required=True)
parser.add_argument("--post-record", required=True)
parser.add_argument("--post-sha256", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args(argv)
try:
manifest = verify_release_provenance_pair(
pre_path=args.pre_record,
pre_sha256=args.pre_sha256,
post_path=args.post_record,
post_sha256=args.post_sha256,
output_path=args.output,
)
except (OSError, TypeError, ValueError) as exc:
parser.error(str(exc))
print(
json.dumps(
{
"schema_version": 1,
"manifest_path": str(Path(args.output).resolve()),
"operation_id": manifest["operation_id"],
},
separators=(",", ":"),
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -17,11 +17,13 @@ from trex_core.application.ledger_port import (
)
def _trade(asset="PF-1", execution_id="paper:PAPER-000001:10") -> LedgerTrade:
def _trade(asset="PF-1", execution_id="paper:PAPER-000001:10", *,
venue=None, order_venue="KRX") -> LedgerTrade:
return LedgerTrade(
asset_id=asset, order_id="PAPER-000001", execution_id=execution_id,
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
executed_at=datetime(2026, 6, 4, 9, 0, tzinfo=timezone.utc),
venue=venue, order_venue=order_venue,
)
@@ -29,6 +31,20 @@ def _client(handler):
return httpx.Client(transport=httpx.MockTransport(handler))
def test_payload_maps_execution_and_order_venues():
cases = [
(None, "SOR"),
("KRX", "NXT"),
("NXT", "KRX"),
]
for execution_venue, order_venue in cases:
body = PortfolioHubLedger._payload(
_trade(venue=execution_venue, order_venue=order_venue)
)
assert body["execution_venue"] == execution_venue
assert body["order_venue"] == order_venue
def test_post_contract_includes_execution_id_and_path_uses_portfolio_id():
captured = {}
@@ -101,6 +101,31 @@ class TestPublishRetryOnHalfOpen:
assert reconnect_called["yes"], "_force_reconnect must be called on first failure"
def test_sell_retry_reuses_identical_payload_and_mandatory_flag(self):
t = _make_transport()
_inject_connected(t)
payload = {
"slot_id": "slot-95",
"event_type": "slot_filled_sell",
"sequence_at": 11,
}
routing_key = "slot_filled_sell.SELL-B.005930"
with (
patch.object(
t,
"_do_basic_publish",
side_effect=[OSError("first attempt"), None],
) as publish,
patch.object(t, "_force_reconnect"),
):
t.publish_event(routing_key, payload)
assert publish.call_count == 2
assert publish.call_args_list[0] == publish.call_args_list[1]
assert publish.call_args_list[0].args[0] == routing_key
assert publish.call_args_list[0].kwargs["mandatory"] is True
# ---------------------------------------------------------------------------
# REQ-091 AC-2: retry also fails → TransportUnavailableError raised
@@ -5,6 +5,7 @@ queue/routing keys to avoid colliding with other tests or runs.
"""
from __future__ import annotations
import json
import os
import threading
import time
@@ -149,6 +150,63 @@ def test_event_publish_and_per_strategy_consume(mq_url):
_drain_queue(mq_url, queue_b)
def test_sell_event_retry_preserves_payload_and_strategy_route(mq_url, monkeypatch):
"""AC-0039: live retry keeps identity and uses mandatory confirmed publish."""
suffix = uuid.uuid4().hex[:8]
owner = f"SELL-{suffix}"
other_owner = f"OTHER-{suffix}"
queue = topology.strategy_event_queue_name(owner)
other_queue = topology.strategy_event_queue_name(other_owner)
routing_key = topology.event_routing_key("slot_filled_sell", owner, "005930")
payload = {
"slot_id": f"slot-{suffix}",
"event_type": "slot_filled_sell",
"sequence_at": 7,
}
publisher = RabbitMQTransport(mq_url, timeout=5.0)
attempts: list[tuple[str, bytes, bool]] = []
try:
publisher.declare_topology()
publisher.declare_strategy_queue(
queue, topology.strategy_event_binding_pattern(owner),
)
publisher.declare_strategy_queue(
other_queue, topology.strategy_event_binding_pattern(other_owner),
)
original_publish = publisher._do_basic_publish
def fail_first_then_publish(rk: str, body: bytes, *, mandatory: bool) -> None:
attempts.append((rk, body, mandatory))
if len(attempts) == 1:
raise OSError("forced first-attempt failure")
original_publish(rk, body, mandatory=mandatory)
monkeypatch.setattr(publisher, "_do_basic_publish", fail_first_then_publish)
publisher.publish_event(routing_key, payload)
assert len(attempts) == 2
assert attempts[0] == attempts[1]
assert attempts[0][0] == routing_key
assert attempts[0][2] is True
assert json.loads(attempts[0][1]) == payload
publisher._ensure_connected()
channel = publisher._channel
assert channel is not None
method, _props, body = channel.basic_get(queue, auto_ack=True)
assert method is not None
assert body is not None
assert json.loads(body) == payload
other_method, _other_props, _other_body = channel.basic_get(
other_queue, auto_ack=True,
)
assert other_method is None
finally:
publisher.close()
_drain_queue(mq_url, queue)
_drain_queue(mq_url, other_queue)
def test_call_rpc_timeout(mq_url):
queue = f"trex.test.rpc.timeout.{uuid.uuid4().hex[:8]}"
client = RabbitMQTransport(mq_url, timeout=5.0)
@@ -0,0 +1,275 @@
"""PostgreSQL auto-reconcile transaction sequencing tests."""
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import uuid4
import psycopg
import pytest
from trex_core.adapters.persistence.auto_reconcile import PostgreSQLAutoReconcileStore
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
from trex_core.adapters.portfolio.reconcile_warning_store import (
PostgreSQLReconcileWarningStore,
)
from trex_core.application.auto_reconcile import AutoReconcileCommand
from trex_core.domain.slot import SlotService, SlotState
class FakeCursor:
def __init__(self, rowcounts=(1, 1), fail_on_insert=False):
self.rowcounts = iter(rowcounts)
self.rowcount = -1
self.fail_on_insert = fail_on_insert
self.calls = []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, sql, params=None):
normalized = " ".join(sql.split())
self.calls.append((normalized, params))
if normalized.startswith("UPDATE daily_slot"):
self.rowcount = next(self.rowcounts)
elif normalized.startswith("DELETE FROM slot_reconcile_warning"):
self.rowcount = next(self.rowcounts)
elif normalized.startswith("INSERT INTO slot_transition_log") and self.fail_on_insert:
raise RuntimeError("audit insert failed")
class FakeConnection:
def __init__(self, cursor):
self._cursor = cursor
self.commits = 0
self.rollbacks = 0
def cursor(self):
return self._cursor
def commit(self):
self.commits += 1
def rollback(self):
self.rollbacks += 1
def close(self):
pass
class FakeConnectionContext:
def __init__(self, conn):
self.conn = conn
def __enter__(self):
return self.conn
def __exit__(self, exc_type, exc, tb):
return False
def _command():
return AutoReconcileCommand(
slot_id=uuid4(),
instance_key="inst-a",
portfolio_id="pf-a",
symbol="019685",
expected_qty=1452.0,
expected_avg_price=2100.0,
target_qty=726.0,
target_avg_price=2172.46829187,
hub_quantity=726.0,
broker_quantity=726,
evidence_observed_at=datetime.now(tz=UTC),
)
def _install(monkeypatch, conn):
module = SimpleNamespace(connect=lambda dsn, autocommit=False: FakeConnectionContext(conn))
monkeypatch.setattr(
"trex_core.adapters.persistence.auto_reconcile._require_psycopg",
lambda _module=None: module,
)
def test_postgresql_auto_reconcile_commits_update_b_clear_and_audit(monkeypatch):
cursor = FakeCursor(rowcounts=(1, 1))
conn = FakeConnection(cursor)
_install(monkeypatch, conn)
assert PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
assert conn.commits == 1
assert conn.rollbacks == 0
statements = [sql for sql, _params in cursor.calls]
assert statements[0].startswith("SELECT pg_advisory_xact_lock")
assert "status='holding'" in statements[1]
assert "avg_price IS NOT DISTINCT FROM" in statements[1]
assert "broker_order_id IS NULL" in statements[1]
assert statements[2].startswith("DELETE FROM slot_reconcile_warning")
assert cursor.calls[2][1][1] == "B"
assert statements[3].startswith("INSERT INTO slot_transition_log")
assert cursor.calls[3][1][2:4] == (
"hub_broker_matched_core_mirror_stale",
"auto_reconcile",
)
def test_postgresql_auto_reconcile_cas_failure_rolls_back_without_warning_or_audit(monkeypatch):
cursor = FakeCursor(rowcounts=(0,))
conn = FakeConnection(cursor)
_install(monkeypatch, conn)
assert not PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
assert conn.commits == 0
assert conn.rollbacks == 1
assert len(cursor.calls) == 2
def test_postgresql_auto_reconcile_audit_failure_rolls_back_slot_and_warning(monkeypatch):
cursor = FakeCursor(rowcounts=(1, 1), fail_on_insert=True)
conn = FakeConnection(cursor)
_install(monkeypatch, conn)
with pytest.raises(RuntimeError, match="audit insert failed"):
PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
assert conn.commits == 0
assert conn.rollbacks == 1
assert len(cursor.calls) == 4
def _seed_real_transaction_state(pg_clean):
repo = PostgreSQLSlotRepository(pg_clean)
service = SlotService(repo, instance_key="inst-a")
warning = PostgreSQLReconcileWarningStore(pg_clean)
repo.reconcile_universe("inst-a", {"pf-a": "019685"})
slot = repo.find_slot_by_portfolio("inst-a", "pf-a")
assert slot is not None
pending = service.transit_slot(
slot.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="buy",
actor="test",
symbol="019685",
)
holding = service.transit_slot(
pending.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="filled",
actor="test",
qty=1452.0,
avg_price=2100.0,
)
warning.set_warned(
str(holding.slot_id),
portfolio_id="pf-a",
case_code="B",
actor="reconcile",
)
warning.set_warned(
str(holding.slot_id),
portfolio_id="pf-a",
case_code="C",
actor="reconcile",
)
command = AutoReconcileCommand(
slot_id=holding.slot_id,
instance_key="inst-a",
portfolio_id="pf-a",
symbol="019685",
expected_qty=1452.0,
expected_avg_price=2100.0,
target_qty=726.0,
target_avg_price=2172.46829187,
hub_quantity=726.0,
broker_quantity=726,
evidence_observed_at=datetime.now(tz=UTC),
)
return repo, warning, holding, command
def test_postgresql_auto_reconcile_real_transaction_commit(pg_clean):
repo, warning, holding, command = _seed_real_transaction_state(pg_clean)
assert PostgreSQLAutoReconcileStore(pg_clean).apply_holding_mirror(command)
current = repo.fetch_slot(holding.slot_id)
assert current is not None
assert current.status is SlotState.HOLDING
assert current.qty == 726.0
assert current.avg_price == pytest.approx(2172.46829187)
info = warning.get_warning_info(str(holding.slot_id))
assert info is not None
assert info["case_codes"] == ["C"]
with psycopg.connect(pg_clean) as conn:
row = conn.execute(
"SELECT actor, reason FROM slot_transition_log "
"WHERE slot_id = %s ORDER BY ts DESC LIMIT 1",
(holding.slot_id,),
).fetchone()
assert row == ("auto_reconcile", "hub_broker_matched_core_mirror_stale")
def test_postgresql_auto_reconcile_real_transaction_audit_failure_rolls_back(pg_clean):
repo, warning, holding, command = _seed_real_transaction_state(pg_clean)
with psycopg.connect(pg_clean) as conn:
before_audit_row = conn.execute(
"SELECT count(*) FROM slot_transition_log WHERE slot_id = %s",
(holding.slot_id,),
).fetchone()
assert before_audit_row is not None
before_audit_count = before_audit_row[0]
with psycopg.connect(pg_clean, autocommit=True) as conn:
conn.execute(
"""
CREATE FUNCTION trex_test_fail_auto_reconcile_audit()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'forced auto-reconcile audit failure';
END
$$
"""
)
conn.execute(
"""
CREATE TRIGGER trex_test_fail_auto_reconcile_audit
BEFORE INSERT ON slot_transition_log
FOR EACH ROW WHEN (NEW.actor = 'auto_reconcile')
EXECUTE FUNCTION trex_test_fail_auto_reconcile_audit()
"""
)
try:
with pytest.raises(psycopg.errors.RaiseException, match="forced auto-reconcile"):
PostgreSQLAutoReconcileStore(pg_clean).apply_holding_mirror(command)
current = repo.fetch_slot(holding.slot_id)
assert current is not None
assert current.qty == 1452.0
assert current.avg_price == 2100.0
info = warning.get_warning_info(str(holding.slot_id))
assert info is not None
assert info["case_codes"] == ["B", "C"]
with psycopg.connect(pg_clean) as conn:
after_audit_row = conn.execute(
"SELECT count(*) FROM slot_transition_log WHERE slot_id = %s",
(holding.slot_id,),
).fetchone()
assert after_audit_row is not None
after_audit_count = after_audit_row[0]
assert after_audit_count == before_audit_count
finally:
with psycopg.connect(pg_clean, autocommit=True) as conn:
conn.execute(
"DROP TRIGGER IF EXISTS trex_test_fail_auto_reconcile_audit ON slot_transition_log"
)
conn.execute("DROP FUNCTION IF EXISTS trex_test_fail_auto_reconcile_audit()")
+69 -11
View File
@@ -130,8 +130,15 @@ def _provenance_kwargs(repo: Path, head: str) -> dict:
"operation_id": "op-1",
"phase": "pre",
"checkout_dir": repo,
"expected_oci_revision": head,
"image_digest": "sha256:" + ("a" * 64),
"observed_evidence": {
"schema_version": 1,
"target_id": "service-a",
"target_kind": "compose-service",
"image_id": "sha256:" + ("b" * 64),
"image_digest": "sha256:" + ("a" * 64),
"oci_revision": head,
"observed_at": "2026-09-20T02:00:00Z",
},
"runtime_files": ["tracked.txt"],
"capture_commands": {
"pending_slots": [
@@ -151,7 +158,7 @@ def test_collect_release_provenance_passes_on_clean_checkout(tmp_path):
report = collect_release_provenance(**_provenance_kwargs(repo, head))
assert report["checkout"]["git_clean"] is True
assert report["checkout"]["detached_head"] is True
assert report["artifact_identity"]["oci_revision_matches_head"] is True
assert report["observed_evidence"]["source"] == "operator_observed"
assert report["runtime_file_verification"][0]["matches_expected_revision"] is True
assert report["gate"]["passed"] is True
assert report["operation_id"] == "op-1"
@@ -181,7 +188,7 @@ def test_collect_release_provenance_blocks_on_revision_or_runtime_mismatch(tmp_p
kwargs = _provenance_kwargs(repo, old_head)
report = collect_release_provenance(**kwargs)
assert report["checkout"]["git_clean"] is True
assert report["artifact_identity"]["oci_revision_matches_head"] is False
assert report["checkout"]["head_revision"] != report["observed_evidence"]["oci_revision"]
assert report["runtime_file_verification"][0]["matches_expected_revision"] is False
assert report["gate"]["passed"] is False
@@ -192,13 +199,58 @@ def test_isolate_dirty_checkout_files_copies_dirty_and_untracked(tmp_path):
(repo / "new.txt").write_text("new\n", encoding="utf-8")
isolated = tmp_path / "isolated"
manifest = isolate_dirty_checkout_files(checkout_dir=repo, isolate_dir=isolated)
assert set(manifest["dirty_paths"]) == {"tracked.txt", "new.txt"}
assert set(manifest["copied_paths"]) == {"tracked.txt", "new.txt"}
entries = {entry["path"]: entry for entry in manifest["entries"]}
assert set(entries) == {"tracked.txt", "new.txt"}
assert all(entry["source_type"] == "regular" for entry in entries.values())
assert all(entry["matches"] is True for entry in entries.values())
snapshot = Path(manifest["isolate_dir"])
assert snapshot.parent == isolated
assert (snapshot / "tracked.txt").read_text(encoding="utf-8") == "dirty\n"
assert (snapshot / "new.txt").read_text(encoding="utf-8") == "new\n"
assert (snapshot / "manifest.json").is_file()
assert (snapshot / "manifest.json").stat().st_mode & 0o222 == 0
@pytest.mark.parametrize("target_kind", ["absolute", "relative_escape"])
def test_isolate_dirty_checkout_files_rejects_symlink_escape_without_mutating_source(
tmp_path, target_kind
):
repo, _ = _init_git_repo(tmp_path)
outside = tmp_path / "outside.txt"
outside.write_text("outside\n", encoding="utf-8")
link = repo / "escape-link"
target = str(outside) if target_kind == "absolute" else "../outside.txt"
link.symlink_to(target)
isolated = tmp_path / "isolated"
with pytest.raises(ValueError, match="symlink target escapes checkout"):
isolate_dirty_checkout_files(checkout_dir=repo, isolate_dir=isolated)
assert link.is_symlink()
assert link.readlink() == Path(target)
assert outside.read_text(encoding="utf-8") == "outside\n"
if isolated.exists():
assert not list(isolated.glob("*/manifest.json"))
def test_isolate_dirty_checkout_files_preserves_internal_relative_symlink(tmp_path):
repo, _ = _init_git_repo(tmp_path)
nested = repo / "nested"
nested.mkdir()
(nested / "target.txt").write_text("target\n", encoding="utf-8")
link = repo / "internal-link"
link.symlink_to("nested/target.txt")
manifest = isolate_dirty_checkout_files(
checkout_dir=repo, isolate_dir=tmp_path / "isolated"
)
entry = next(item for item in manifest["entries"] if item["path"] == "internal-link")
copied = Path(manifest["isolate_dir"]) / "internal-link"
assert entry["source_type"] == "symlink"
assert entry["copied_type"] == "symlink"
assert entry["matches"] is True
assert copied.readlink() == Path("nested/target.txt")
assert link.readlink() == Path("nested/target.txt")
def test_collect_release_provenance_rejects_runtime_path_escape(tmp_path):
@@ -226,8 +278,15 @@ def test_collect_release_provenance_reports_git_failure_context(tmp_path):
operation_id="op-1",
phase="pre",
checkout_dir=not_repo,
expected_oci_revision="0" * 40,
image_digest="sha256:" + ("a" * 64),
observed_evidence={
"schema_version": 1,
"target_id": "service-a",
"target_kind": "compose-service",
"image_id": "sha256:" + ("b" * 64),
"image_digest": "sha256:" + ("a" * 64),
"oci_revision": "0" * 40,
"observed_at": "2026-09-20T02:00:00Z",
},
runtime_files=["tracked.txt"],
capture_commands={
"pending_slots": [
@@ -291,8 +350,7 @@ def test_empty_broker_log_is_explicitly_captured_as_no_matching_events(tmp_path)
@pytest.mark.parametrize(
("field", "value"),
[
("expected_oci_revision", None),
("image_digest", "not-a-digest"),
("observed_evidence", {}),
("runtime_files", []),
("capture_commands", {}),
("instance", "../escape"),
@@ -63,6 +63,38 @@ def test_buy_intent_persists_transition(pg_clean):
assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930"
def test_sell_admission_owner_survives_postgresql_repository_restart(pg_clean):
repo, svc, intents = _wire(pg_clean)
repo.reconcile_universe(INST, _SYMS)
decision = intents.submit_buy(BuyIntent(
intent_id="pg-owner-buy",
qty=10,
strategy_id="BUY-A",
portfolio_id="PF-1",
))
assert decision.slot_id is not None
svc.transit_slot(
decision.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="filled",
actor="test",
qty=10.0,
avg_price=1000.0,
)
sell = intents.submit_sell(SellIntent(
intent_id="pg-owner-sell",
slot_id=decision.slot_id,
sell_strategy_id="SELL-B",
))
assert sell.admitted
reloaded = PostgreSQLSlotRepository(pg_clean).fetch_slot(decision.slot_id)
assert reloaded is not None
assert reloaded.status is SlotState.PENDING_SELL
assert reloaded.sell_strategy_id == "SELL-B"
def test_buy_intent_idempotent_on_pg(pg_clean):
repo, svc, intents = _wire(pg_clean)
repo.reconcile_universe(INST, _SYMS)
@@ -0,0 +1,188 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
from trex_core.adapters.persistence.ops_checks import (
load_observed_evidence,
verify_release_provenance_pair,
)
DIGEST_A = "sha256:" + "a" * 64
DIGEST_B = "sha256:" + "b" * 64
REVISION = "c" * 40
def _evidence() -> dict:
return {
"schema_version": 1,
"target_id": "service-a",
"target_kind": "compose-service",
"image_id": DIGEST_A,
"image_digest": DIGEST_B,
"oci_revision": REVISION,
"observed_at": "2026-09-20T02:00:00Z",
}
def test_load_observed_evidence_accepts_exact_v1(tmp_path: Path):
path = tmp_path / "evidence.json"
path.write_text(json.dumps(_evidence()), encoding="utf-8")
assert load_observed_evidence(path) == _evidence()
@pytest.mark.parametrize(
"raw",
[
'{"schema_version":1,"schema_version":1}',
json.dumps({**_evidence(), "unknown": True}),
json.dumps({**_evidence(), "target_id": ""}),
json.dumps({**_evidence(), "schema_version": True}),
json.dumps({**_evidence(), "image_id": "SHA256:" + "A" * 64}),
json.dumps({**_evidence(), "observed_at": "2026-09-20T02:00:00+09:00"}),
json.dumps({**_evidence(), "target_id": "token=must-not-leak"}),
],
)
def test_load_observed_evidence_rejects_invalid_or_sensitive_input(tmp_path: Path, raw: str):
path = tmp_path / "evidence.json"
path.write_text(raw, encoding="utf-8")
with pytest.raises(ValueError):
load_observed_evidence(path)
def _record(*, phase: str, operation_id: str = "op-1", gate_passed: bool = True) -> dict:
return {
"schema_version": 1,
"timestamp_utc": "2026-09-20T02:00:00Z",
"actor": "operator-a",
"reason": "release",
"instance": "instance-a",
"command_class": "deploy" if phase == "pre" else "recreate",
"operation_id": operation_id,
"phase": phase,
"observed_evidence": {**_evidence(), "source": "operator_observed"},
"checkout": {
"path": "/audit/checkout",
"head_revision": REVISION,
"detached_head": True,
"git_status_porcelain": [],
"git_clean": True,
"dirty_paths": [],
},
"runtime_file_verification": [
{
"path": "runtime.py",
"path_within_checkout": True,
"exists": True,
"current_sha256": "d" * 64,
"expected_revision": REVISION,
"expected_sha256": "d" * 64,
"matches_expected_revision": True,
}
],
"state_capture": {
"command_outputs": {
key: {
"command": ["read-only"],
"returncode": 0,
"stdout": {},
"stdout_format": "json",
"stderr": "",
}
for key in ("pending_slots", "reconciliation_slots", "queue_consumers")
},
"broker_side_effect_logs": {
"path": "/tmp/broker.log",
"exists": True,
"capture_status": "captured_empty",
"file_sha256": "e" * 64,
"scanned_lines": 0,
"matches": [],
},
},
"gate": {
"passed": gate_passed,
"deployment_blocked": not gate_passed,
"runtime_files_verified": True,
"state_captures_succeeded": True,
"broker_log_captured": True,
},
}
def _write_record(path: Path, record: dict) -> str:
path.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8")
return hashlib.sha256(path.read_bytes()).hexdigest()
def test_pair_validator_binds_explicit_paths_and_expected_digests(tmp_path: Path):
pre = tmp_path / "pre.json"
post = tmp_path / "post.json"
pre_sha = _write_record(pre, _record(phase="pre"))
post_sha = _write_record(post, _record(phase="post"))
output = tmp_path / "manifests" / "pair.json"
manifest = verify_release_provenance_pair(
pre_path=pre,
pre_sha256=pre_sha,
post_path=post,
post_sha256=post_sha,
output_path=output,
)
assert manifest["pre"]["record_path"] == str(pre.resolve())
assert manifest["post"]["record_sha256"] == post_sha
assert output.stat().st_mode & 0o222 == 0
@pytest.mark.parametrize("mutation", ["tamper", "failed", "mismatch"])
def test_pair_validator_fails_without_manifest(tmp_path: Path, mutation: str):
pre = tmp_path / "pre.json"
post = tmp_path / "post.json"
pre_sha = _write_record(pre, _record(phase="pre"))
post_record = _record(phase="post")
if mutation == "failed":
post_record["gate"]["passed"] = False
post_record["gate"]["deployment_blocked"] = True
if mutation == "mismatch":
post_record["operation_id"] = "op-2"
post_sha = _write_record(post, post_record)
if mutation == "tamper":
post.write_text(post.read_text() + " ", encoding="utf-8")
output = tmp_path / "pair.json"
with pytest.raises(ValueError):
verify_release_provenance_pair(
pre_path=pre,
pre_sha256=pre_sha,
post_path=post,
post_sha256=post_sha,
output_path=output,
)
assert not output.exists()
def test_pair_validator_does_not_overwrite_existing_output(tmp_path: Path):
pre = tmp_path / "pre.json"
post = tmp_path / "post.json"
pre_sha = _write_record(pre, _record(phase="pre"))
post_sha = _write_record(post, _record(phase="post"))
output = tmp_path / "pair.json"
output.write_text("keep", encoding="utf-8")
with pytest.raises(FileExistsError):
verify_release_provenance_pair(
pre_path=pre,
pre_sha256=pre_sha,
post_path=post,
post_sha256=post_sha,
output_path=output,
)
assert output.read_text(encoding="utf-8") == "keep"
+52
View File
@@ -117,6 +117,58 @@ def test_get_portfolios_network_error_raises_unavailable():
# REQ-123: cost-basis parsing (canonical + diagnostics)
# ---------------------------------------------------------------------------
def test_get_cost_basis_evidence_parses_identity_quantity_and_complete_basis():
def handler(req: httpx.Request) -> httpx.Response:
assert req.url.path == "/v1/portfolios/pf-1/holdings/cost-basis"
return httpx.Response(200, json={
"portfolio_id": "pf-1",
"symbol": "019685",
"current_quantity": "726",
"cost_basis_per_unit": "2172.46829187",
"total_cost_basis": str(726 * 2172.46829187),
"data_completeness_warning": None,
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
evidence = adapter.get_cost_basis_evidence("pf-1")
assert evidence.portfolio_id == "pf-1"
assert evidence.symbol == "019685"
assert evidence.current_quantity == 726.0
assert evidence.cost_basis_per_unit == pytest.approx(2172.46829187)
assert evidence.data_completeness_warning is None
assert evidence.observed_at.tzinfo is not None
@pytest.mark.parametrize("payload", [
{"cost_basis_per_unit": 1, "total_cost_basis": 1},
{
"portfolio_id": "wrong", "symbol": "019685", "current_quantity": 1,
"cost_basis_per_unit": 1, "total_cost_basis": 1,
},
{
"portfolio_id": "pf-1", "symbol": "019685", "current_quantity": 1,
"cost_basis_per_unit": "NaN", "total_cost_basis": 1,
},
])
def test_get_cost_basis_evidence_rejects_missing_mismatched_or_nonfinite(payload):
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
with pytest.raises(PortfolioHubUnavailableError):
adapter.get_cost_basis_evidence("pf-1")
def test_get_cost_basis_evidence_never_uses_legacy_fields():
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"avg_cost": 1234, "wac": 1234})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
with pytest.raises(PortfolioHubUnavailableError):
adapter.get_cost_basis_evidence("pf-1")
def test_get_cost_basis_with_diagnostics_prefers_canonical_when_valid():
"""REQ-123 AC-1: cost_basis_per_unit가 있으면 우선 사용."""
+326
View File
@@ -0,0 +1,326 @@
"""Issue #94 holding mirror auto-reconciliation tests."""
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime, timedelta
import pytest
from trex_core.adapters.persistence.auto_reconcile import InMemoryAutoReconcileStore
from trex_core.application.auto_reconcile import (
AutoReconcileCommand,
HoldingsCostBasisEvidence,
)
from trex_core.application.broker_port import BalanceItem
from trex_core.application.portfolio import (
InMemoryReconcileNotificationStore,
InMemoryReconcileWarningStore,
PortfolioItem,
)
from trex_core.application.reconciliation import reconcile_core_holdings
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
class CountingBroker:
def __init__(self, quantity: int = 726) -> None:
self.quantity = quantity
self.balance_reads = 0
self.order_calls = 0
def name(self):
return "counting"
def get_balance(self):
self.balance_reads += 1
return [BalanceItem(symbol="019685", hldg_qty=self.quantity)]
def get_current_price(self, symbol):
return 1.0
def submit_order(self, request):
self.order_calls += 1
raise AssertionError("auto reconcile must not submit orders")
def poll_fill(self, *args, **kwargs):
raise AssertionError("auto reconcile must not poll orders")
def cancel_order(self, *args, **kwargs):
raise AssertionError("auto reconcile must not cancel orders")
class EvidencePort:
def __init__(self, evidence: HoldingsCostBasisEvidence) -> None:
self.evidence = evidence
self.calls = 0
def get_cost_basis_evidence(self, portfolio_id: str) -> HoldingsCostBasisEvidence:
self.calls += 1
assert portfolio_id == "pf-019685"
return self.evidence
class RecordingAlarm:
def __init__(self) -> None:
self.calls: list[tuple[str, dict]] = []
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
self.calls.append((code, fields or {}))
return True
def _holding(repo: InMemorySlotRepository, svc: SlotService):
repo.reconcile_universe("default", {"pf-019685": "019685"})
slot = repo.find_slot_by_portfolio("default", "pf-019685")
assert slot is not None
pending = svc.transit_slot(
slot.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="buy",
actor="test",
symbol="019685",
)
return svc.transit_slot(
pending.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="filled",
actor="test",
avg_price=2100.0,
qty=1452.0,
)
def _evidence(**overrides) -> HoldingsCostBasisEvidence:
values = {
"portfolio_id": "pf-019685",
"symbol": "019685",
"current_quantity": 726.0,
"cost_basis_per_unit": 2172.46829187,
"total_cost_basis": 726.0 * 2172.46829187,
"data_completeness_warning": None,
"observed_at": datetime.now(tz=UTC),
}
values.update(overrides)
return HoldingsCostBasisEvidence(**values)
def _run(
*,
evidence=None,
mutate_slot=None,
auto_store_wrapper=None,
notification=False,
portfolio_items=None,
):
if portfolio_items is None:
portfolio_items = [PortfolioItem("pf-019685", "019685", 0, 726.0)]
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn = InMemoryReconcileWarningStore()
slot = _holding(repo, svc)
if mutate_slot is not None:
repo.update_slot(mutate_slot(slot))
slot = repo.fetch_slot(slot.slot_id)
assert slot is not None
warn.set_warned(
str(slot.slot_id),
portfolio_id="pf-019685",
case_code="C",
actor="test",
reason="preserve-me",
)
base_store = InMemoryAutoReconcileStore(repo, warn)
auto_store = auto_store_wrapper(base_store, repo) if auto_store_wrapper else base_store
broker = CountingBroker()
alarm = RecordingAlarm() if notification else None
notifications = InMemoryReconcileNotificationStore() if notification else None
port = EvidencePort(evidence or _evidence())
decisions = reconcile_core_holdings(
slot_repo=repo,
slot_service=svc,
broker=broker, # type: ignore[arg-type]
instance_key="default",
warn_store=warn,
alarm_emitter=alarm, # type: ignore[arg-type]
notification_store=notifications,
portfolio_items=portfolio_items,
cost_basis_port=port,
auto_reconcile_store=auto_store,
)
return repo, warn, slot, broker, port, decisions, alarm
def test_auto_reconcile_updates_qty_avg_audit_and_only_clears_b():
repo, warn, slot, broker, port, decisions, _alarm = _run()
current = repo.fetch_slot(slot.slot_id)
assert current is not None
assert decisions[str(slot.slot_id)] == "auto_synced"
assert current.qty == 726.0
assert current.avg_price == pytest.approx(2172.46829187)
assert current.status is SlotState.HOLDING
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["C"]
audit = repo.transition_log_for(slot.slot_id)[-1]
assert audit.actor == "auto_reconcile"
assert audit.reason == "hub_broker_matched_core_mirror_stale"
assert audit.extra["before_qty"] == 1452.0
assert audit.extra["after_qty"] == 726.0
assert broker.balance_reads == 1
assert broker.order_calls == 0
assert port.calls == 1
def test_auto_reconcile_repeat_is_idempotent():
repo, warn, slot, broker, port, decisions, _alarm = _run()
audit_count = len(repo.transition_log_for(slot.slot_id))
second = reconcile_core_holdings(
slot_repo=repo,
slot_service=SlotService(repo),
broker=broker, # type: ignore[arg-type]
instance_key="default",
warn_store=warn,
portfolio_items=[PortfolioItem("pf-019685", "019685", 0, 726.0)],
cost_basis_port=port,
auto_reconcile_store=InMemoryAutoReconcileStore(repo, warn),
)
assert second[str(slot.slot_id)] == "ok"
assert len(repo.transition_log_for(slot.slot_id)) == audit_count
assert decisions[str(slot.slot_id)] == "auto_synced"
@pytest.mark.parametrize(
"evidence",
[
_evidence(data_completeness_warning="history incomplete"),
_evidence(symbol="WRONG"),
_evidence(current_quantity=725.0, total_cost_basis=725.0 * 2172.46829187),
_evidence(observed_at=datetime.now(tz=UTC) - timedelta(minutes=2)),
_evidence(cost_basis_per_unit=float("nan"), total_cost_basis=float("nan")),
],
)
def test_auto_reconcile_invalid_evidence_fails_closed(evidence):
repo, warn, slot, broker, port, decisions, _alarm = _run(evidence=evidence)
current = repo.fetch_slot(slot.slot_id)
assert current is not None
assert decisions[str(slot.slot_id)] == "qty_warning"
assert current.qty == 1452.0
assert current.avg_price == 2100.0
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["B", "C"]
assert repo.transition_log_for(slot.slot_id)[-1].actor == "test"
assert broker.balance_reads == 1
assert broker.order_calls == 0
assert port.calls == 1
def test_auto_reconcile_pending_dispatch_metadata_fails_closed_without_evidence_read():
repo, warn, slot, _broker, port, decisions, _alarm = _run(
mutate_slot=lambda value: replace(value, broker_order_id="pending-order")
)
assert decisions[str(slot.slot_id)] == "qty_warning"
assert repo.fetch_slot(slot.slot_id).qty == 1452.0
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["B", "C"]
assert port.calls == 0
def test_auto_reconcile_duplicate_hub_symbol_fails_closed():
repo, warn, slot, _broker, port, decisions, _alarm = _run(
portfolio_items=[
PortfolioItem("pf-019685", "019685", 0, 726.0),
PortfolioItem("pf-other", "019685", 0, 0.0),
]
)
current = repo.fetch_slot(slot.slot_id)
assert current is not None
assert decisions[str(slot.slot_id)] == "qty_warning"
assert current.qty == 1452.0
warning_info = warn.get_warning_info(str(slot.slot_id))
assert warning_info is not None
assert warning_info["case_codes"] == ["B", "C"]
assert port.calls == 0
def test_auto_reconcile_cas_race_preserves_intervening_state_and_records():
class RacingStore:
def __init__(self, delegate, repo):
self.delegate = delegate
self.repo = repo
def apply_holding_mirror(self, command):
current = self.repo.fetch_slot(command.slot_id)
self.repo.update_slot(replace(current, avg_price=9999.0))
return self.delegate.apply_holding_mirror(command)
repo, warn, slot, _broker, _port, decisions, _alarm = _run(
auto_store_wrapper=lambda delegate, repo: RacingStore(delegate, repo)
)
current = repo.fetch_slot(slot.slot_id)
assert current is not None
assert decisions[str(slot.slot_id)] == "qty_warning"
assert current.qty == 1452.0
assert current.avg_price == 9999.0
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["B", "C"]
assert repo.transition_log_for(slot.slot_id)[-1].actor == "test"
def test_in_memory_auto_reconcile_audit_failure_rolls_back_all_records(monkeypatch):
repo = InMemorySlotRepository()
service = SlotService(repo)
warning = InMemoryReconcileWarningStore()
slot = _holding(repo, service)
assert slot.portfolio_id is not None
assert slot.symbol is not None
assert slot.qty is not None
warning.set_warned(
str(slot.slot_id), portfolio_id=slot.portfolio_id, case_code="B", actor="reconcile"
)
warning.set_warned(
str(slot.slot_id), portfolio_id=slot.portfolio_id, case_code="C", actor="reconcile"
)
before_slot = repo.fetch_slot(slot.slot_id)
before_warning = warning.get_warning_info(str(slot.slot_id))
before_log = repo.transition_log_for(slot.slot_id)
def fail_audit(_record):
raise RuntimeError("audit insert failed")
monkeypatch.setattr(repo, "append_transition_log", fail_audit)
with pytest.raises(RuntimeError, match="audit insert failed"):
InMemoryAutoReconcileStore(repo, warning).apply_holding_mirror(
AutoReconcileCommand(
slot_id=slot.slot_id,
instance_key=slot.instance_key,
portfolio_id=slot.portfolio_id,
symbol=slot.symbol,
expected_qty=slot.qty,
expected_avg_price=slot.avg_price,
target_qty=726.0,
target_avg_price=2172.46829187,
hub_quantity=726.0,
broker_quantity=726,
evidence_observed_at=datetime.now(tz=UTC),
)
)
assert repo.fetch_slot(slot.slot_id) == before_slot
assert warning.get_warning_info(str(slot.slot_id)) == before_warning
assert repo.transition_log_for(slot.slot_id) == before_log
def test_auto_reconcile_emits_detected_then_one_authoritative_resolved_event():
_repo, _warn, slot, _broker, _port, decisions, alarm = _run(notification=True)
assert decisions[str(slot.slot_id)] == "auto_synced"
assert [code for code, _fields in alarm.calls] == [
"reconcile_mismatch_detected",
"reconcile_mismatch_resolved",
]
assert all("account" not in str(fields).lower() for _code, fields in alarm.calls)
+60 -2
View File
@@ -65,7 +65,7 @@ class ProgrammableBroker:
return None
def _wire(broker, *, stale_sec=None):
def _wire(broker, *, stale_sec=None, event_emitter=None):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore(),
@@ -74,7 +74,7 @@ def _wire(broker, *, stale_sec=None):
ledger = InMemoryLedger()
execution = ExecutionService(
svc, repo, broker, ledger_outbox=outbox,
stale_pending_threshold_sec=stale_sec,
stale_pending_threshold_sec=stale_sec, event_emitter=event_emitter,
)
writer = LedgerWriter(outbox, ledger)
repo.reconcile_universe("default", {"PF-1": "SYM"})
@@ -104,6 +104,64 @@ def test_full_external_cancel_returns_slot_to_holding():
assert r.rolled_back == 1
def test_zero_fill_cancel_preserves_owner_for_ownerless_resell():
broker = ProgrammableBroker()
repo, _svc, intents, execution, _ledger, _writer = _wire(broker)
sid = _buy_to_holding(intents, execution)
assert sid is not None
intents.submit_sell(SellIntent(
intent_id="s-owner-1", slot_id=sid, sell_strategy_id="SELL-B",
))
broker.responder = lambda side: FillStatus(state="cancelled")
execution.run_once()
holding = repo.fetch_slot(sid)
assert holding is not None
assert holding.status is SlotState.HOLDING
assert holding.sell_strategy_id == "SELL-B"
intents.submit_sell(SellIntent(intent_id="s-owner-2", slot_id=sid))
pending = repo.fetch_slot(sid)
assert pending is not None
assert pending.status is SlotState.PENDING_SELL
assert pending.sell_strategy_id == "SELL-B"
def test_partial_sell_cancel_routes_with_sell_owner_and_preserves_it():
emitted: list[tuple[str, str | None]] = []
def emitter(event_type, _slot, owner=None):
emitted.append((event_type, owner))
broker = ProgrammableBroker()
repo, _svc, intents, execution, _ledger, _writer = _wire(
broker, event_emitter=emitter,
)
sid = _buy_to_holding(intents, execution)
assert sid is not None
intents.submit_sell(SellIntent(
intent_id="s-partial-owner", slot_id=sid, sell_strategy_id="SELL-B",
))
broker.balance["005930"] = 6
broker.responder = lambda side: FillStatus(
state="partially_filled",
filled_qty=4,
avg_price=1100.0,
execution_id="exec-partial-owner",
cancelled=True,
)
execution.run_once()
holding = repo.fetch_slot(sid)
assert holding is not None
assert holding.status is SlotState.HOLDING
assert holding.sell_strategy_id == "SELL-B"
assert ("slot_filled_sell", "SELL-B") in emitted
def test_unresolved_poll_observability_is_sanitized_and_deduplicated(caplog):
broker = ProgrammableBroker()
repo, _svc, intents, execution, _ledger, _writer = _wire(broker)
+75
View File
@@ -1,6 +1,7 @@
"""LedgerOutbox + LedgerWriter direct unit tests (REQ-019)."""
from __future__ import annotations
import logging
from dataclasses import replace
from datetime import UTC, datetime, timedelta
@@ -132,6 +133,80 @@ def test_writer_exposes_claim_and_outcome_counters():
assert writer.counters["outcome:completed"] == 1
def test_idle_day_is_silent_and_counted_per_instance(caplog):
writers = [
LedgerWriter(
InMemoryLedgerOutbox(instance_key=instance_key),
InMemoryLedger(),
instance_key=instance_key,
)
for instance_key in ("instance-a", "instance-b")
]
with caplog.at_level(logging.INFO, logger="trex_core.application.ledger_outbox"):
for writer in writers:
for _ in range(86_400):
writer.run_once()
assert [
writer.metrics_snapshot()["claims"]["empty_polls_total"]
for writer in writers
] == [86_400, 86_400]
assert "claim_result=empty" not in caplog.text
assert "correlation_ids" not in caplog.text
def test_nonempty_claim_log_has_ordered_json_correlation_ids(caplog):
outbox = InMemoryLedgerOutbox(instance_key="inst-a")
writer = LedgerWriter(outbox, InMemoryLedger(), instance_key="inst-a")
first = outbox.enqueue(_trade(execution_id="e-1"))
second = outbox.enqueue(_trade(execution_id="e-2"))
with caplog.at_level(logging.INFO, logger="trex_core.application.ledger_outbox"):
writer.run_once(limit=2)
claim_logs = [
record.getMessage()
for record in caplog.records
if "ledger outbox claim:" in record.getMessage()
]
assert claim_logs == [
"ledger outbox claim: instance_key=inst-a claim_result=claimed "
f"count=2 limit=2 correlation_ids=[\"{first.correlation_id}\","
f"\"{second.correlation_id}\"]"
]
def test_metrics_snapshot_is_complete_and_detached():
outbox = InMemoryLedgerOutbox(instance_key="inst-a")
writer = LedgerWriter(outbox, InMemoryLedger(), instance_key="inst-a")
snapshot = writer.metrics_snapshot()
assert snapshot == {
"available": True,
"scope": "process_lifetime",
"instance_key": "inst-a",
"claims": {
"empty_polls_total": 0,
"claimed_batches_total": 0,
"unavailable_total": 0,
"lost_total": 0,
"foreign_instance_total": 0,
},
"outcomes": {
"completed_total": 0,
"duplicate_completed_total": 0,
"retryable_failure_total": 0,
"conflict_blocked_total": 0,
"permanent_failure_total": 0,
"dead_lettered_total": 0,
},
}
snapshot["claims"]["empty_polls_total"] = 999
assert writer.metrics_snapshot()["claims"]["empty_polls_total"] == 0
@pytest.mark.parametrize("instance_key", ["", " "])
def test_writer_rejects_explicit_empty_instance_key(instance_key):
outbox = InMemoryLedgerOutbox(instance_key="inst-a")
@@ -1,6 +1,7 @@
"""Slot transition event emission tests (REQ-021 + REQ-023 + REQ-018 hook)."""
from __future__ import annotations
import logging
import threading
import time
from trex_core.adapters.broker.paper import PaperBroker
@@ -218,3 +219,213 @@ def test_sell_fill_dlq_sentinel_absent_when_strategy_id_set():
assert bad_keys == [], (
f"DLQ-sentinel routing keys found (strategy_id=None leak): {bad_keys}"
)
def test_sell_fill_prefers_admission_sell_strategy_owner():
emitted: list[tuple[str, str | None]] = []
def emitter(event_type: str, _slot, owner: str | None = None) -> None:
emitted.append((event_type, owner))
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(
svc,
repo,
InMemoryIntentStore(),
resolve_symbol=lambda _pid: "005930",
)
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
execution = ExecutionService(
svc,
repo,
PaperBroker(fill_after_polls=0),
ledger_outbox=outbox,
event_emitter=emitter,
)
repo.reconcile_universe("default", {"PF-1": "SYM"})
decision = intents.submit_buy(BuyIntent(
intent_id="b-owner", qty=10, strategy_id="BUY-A", portfolio_id="PF-1",
))
assert decision.slot_id is not None
execution.run_once()
intents.submit_sell(SellIntent(
intent_id="s-owner", slot_id=decision.slot_id, sell_strategy_id="SELL-B",
))
pending = repo.fetch_slot(decision.slot_id)
assert pending is not None
assert pending.sell_strategy_id == "SELL-B"
execution.run_once()
assert ("slot_filled_sell", "SELL-B") in emitted
assert ("slot_filled_sell", "BUY-A") not in emitted
execution.run_once()
assert emitted.count(("slot_filled_sell", "SELL-B")) == 1
def test_ownerless_sell_admission_persists_legacy_buy_owner():
repo, _svc, intents, execution, _writer = _wire(InMemoryTransport())
decision = intents.submit_buy(BuyIntent(
intent_id="b-legacy-owner", qty=10, strategy_id="BUY-A", portfolio_id="PF-1",
))
assert decision.slot_id is not None
execution.run_once()
intents.submit_sell(SellIntent(
intent_id="s-legacy-owner",
slot_id=decision.slot_id,
sell_strategy_id=" ",
))
pending = repo.fetch_slot(decision.slot_id)
assert pending is not None
assert pending.sell_strategy_id == "BUY-A"
def test_sell_owner_survives_execution_service_restart():
emitted: list[tuple[str, str | None]] = []
def emitter(event_type: str, _slot, owner: str | None = None) -> None:
emitted.append((event_type, owner))
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(
svc, repo, InMemoryIntentStore(), resolve_symbol=lambda _pid: "005930",
)
outbox = InMemoryLedgerOutbox(instance_key="default")
repo.reconcile_universe("default", {"PF-1": "SYM"})
broker = PaperBroker(fill_after_polls=0)
first_execution = ExecutionService(
svc, repo, broker, ledger_outbox=outbox,
)
decision = intents.submit_buy(BuyIntent(
intent_id="b-restart-owner", qty=10, strategy_id="BUY-A", portfolio_id="PF-1",
))
assert decision.slot_id is not None
first_execution.run_once()
intents.submit_sell(SellIntent(
intent_id="s-restart-owner",
slot_id=decision.slot_id,
sell_strategy_id="SELL-B",
))
restarted_execution = ExecutionService(
svc,
repo,
broker,
ledger_outbox=outbox,
event_emitter=emitter,
)
restarted_execution.run_once()
assert ("slot_filled_sell", "SELL-B") in emitted
class _AlarmRecorder:
def __init__(self) -> None:
self.calls: list[tuple[str, dict]] = []
def publish_alarm(self, code, _title, *, fields=None, severity="critical"):
self.calls.append((code, {**(fields or {}), "severity": severity}))
return True
def test_ownerless_sell_fill_is_skipped_without_financial_rollback(caplog):
emitted: list[tuple[str, str | None]] = []
alarm = _AlarmRecorder()
def emitter(event_type: str, _slot, owner: str | None = None) -> None:
emitted.append((event_type, owner))
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(
svc,
repo,
InMemoryIntentStore(),
resolve_symbol=lambda _pid: "005930",
)
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
execution = ExecutionService(
svc,
repo,
PaperBroker(fill_after_polls=0),
ledger_outbox=outbox,
event_emitter=emitter,
alarm_emitter=alarm, # type: ignore[arg-type]
instance_key="default",
)
repo.reconcile_universe("default", {"PF-1": "SYM"})
decision = intents.submit_buy(BuyIntent(
intent_id="b-ownerless", qty=10, portfolio_id="PF-1",
))
assert decision.slot_id is not None
execution.run_once()
intents.submit_sell(SellIntent(intent_id="s-ownerless", slot_id=decision.slot_id))
with caplog.at_level(logging.ERROR, logger="trex_core.application.execution"):
result = execution.run_once()
sell_calls = [call for call in emitted if call[0] == "slot_filled_sell"]
assert sell_calls == []
assert result.sell_event_owner_missing == 1
assert result.filled == 1
assert result.ledger_enqueued == 1
final_slot = repo.fetch_slot(decision.slot_id)
assert final_slot is not None
assert final_slot.status is SlotState.INACTIVE
assert any("reason=sell_event_owner_missing" in rec.message for rec in caplog.records)
assert alarm.calls == [(
"sell_event_owner_missing",
{
"instance_key": "default",
"slot_id": str(decision.slot_id),
"symbol": "005930",
"event_type": "slot_filled_sell",
"reason": "sell_event_owner_missing",
"severity": "critical",
},
)]
def test_ownerless_sell_alarm_failure_does_not_rollback_fill():
class RaisingAlarm:
def publish_alarm(self, *_args, **_kwargs):
raise RuntimeError("alarm unavailable")
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(
svc, repo, InMemoryIntentStore(), resolve_symbol=lambda _pid: "005930",
)
outbox = InMemoryLedgerOutbox(instance_key="default")
execution = ExecutionService(
svc,
repo,
PaperBroker(fill_after_polls=0),
ledger_outbox=outbox,
alarm_emitter=RaisingAlarm(), # type: ignore[arg-type]
instance_key="default",
)
repo.reconcile_universe("default", {"PF-1": "SYM"})
decision = intents.submit_buy(BuyIntent(
intent_id="b-ownerless-alarm", qty=10, portfolio_id="PF-1",
))
assert decision.slot_id is not None
execution.run_once()
intents.submit_sell(SellIntent(
intent_id="s-ownerless-alarm", slot_id=decision.slot_id,
))
result = execution.run_once()
final_slot = repo.fetch_slot(decision.slot_id)
assert final_slot is not None
assert final_slot.status is SlotState.INACTIVE
assert result.filled == 1
assert result.ledger_enqueued == 1
assert result.sell_event_owner_missing == 1
+60
View File
@@ -207,6 +207,66 @@ def test_spy_true_no_watchdog_update():
assert watchdog.last_seen(sid) is None # still not updated (spy=True)
def test_spy_response_exposes_ledger_outbox_metrics():
class _MetricsReader:
def metrics_snapshot(self):
return {
"available": True,
"scope": "process_lifetime",
"instance_key": "default",
"claims": {
"empty_polls_total": 7,
"claimed_batches_total": 1,
"unavailable_total": 0,
"lost_total": 0,
"foreign_instance_total": 0,
},
"outcomes": {
"completed_total": 1,
"duplicate_completed_total": 0,
"retryable_failure_total": 0,
"conflict_blocked_total": 0,
"permanent_failure_total": 0,
"dead_lettered_total": 0,
},
}
repo, *_ = _wire()
handler = make_slots_rpc_handler(
repo,
ledger_outbox_metrics_reader=_MetricsReader(),
)
spy_payload = handler(_req(spy=True)).payload
strategy_payload = handler(_req(spy=False)).payload
assert spy_payload["ledger_outbox_metrics"]["claims"]["empty_polls_total"] == 7
assert "ledger_outbox_metrics" not in strategy_payload
def test_spy_response_reports_metrics_unavailable_without_failing_slots():
class _FailingReader:
def metrics_snapshot(self):
raise RuntimeError("SECRET_METRICS_READER_FAILURE")
repo, *_ = _wire()
for reader in (None, _FailingReader()):
handler = make_slots_rpc_handler(
repo,
instance_key="default",
ledger_outbox_metrics_reader=reader,
)
payload = handler(_req(spy=True)).payload
assert len(payload["slots"]) == 2
assert payload["ledger_outbox_metrics"] == {
"available": False,
"scope": "process_lifetime",
"instance_key": "default",
"reason": "ledger_outbox_metrics_unavailable",
}
# REQ-073 AC-4: spy=False (default) → holding watchdog updated
def test_spy_false_updates_watchdog():
repo, svc, _, _, watchdog, handler = _wire()
@@ -60,11 +60,15 @@ def test_run_once_uses_authoritative_core_holdings_broker(monkeypatch):
projected_broker = object()
authoritative_broker = object()
unmanaged_store = object()
auto_store = SimpleNamespace(apply_holding_mirror=lambda _command: False)
captured = {}
def _capture(**kwargs):
captured["broker"] = kwargs["broker"]
captured["unmanaged_position_store"] = kwargs.get("unmanaged_position_store")
captured["portfolio_items"] = kwargs.get("portfolio_items")
captured["cost_basis_port"] = kwargs.get("cost_basis_port")
captured["auto_reconcile_store"] = kwargs.get("auto_reconcile_store")
return {}
monkeypatch.setattr(
@@ -79,12 +83,17 @@ def test_run_once_uses_authoritative_core_holdings_broker(monkeypatch):
slot_repo=object(),
slot_service=SimpleNamespace(instance_key="inst-1"),
unmanaged_position_store=unmanaged_store,
cost_basis_port=read,
auto_reconcile_store=auto_store, # type: ignore[arg-type]
)
sched._reconcile_slots = lambda _items: None
assert sched.run_once() is True
assert captured["broker"] is authoritative_broker
assert captured["unmanaged_position_store"] is unmanaged_store
assert captured["portfolio_items"] == read.items
assert captured["cost_basis_port"] is read
assert captured["auto_reconcile_store"] is auto_store
def test_swap_preserves_warning_for_carried_over_portfolio():
+1
View File
@@ -24,6 +24,7 @@ _PG_TABLES = (
"ledger_outbox",
"intent_log",
"slot_transition_log",
"slot_reconcile_warning",
"daily_symbol_entries",
"daily_slot",
"reconcile_notification_state",
+2 -1
View File
@@ -34,7 +34,7 @@ def _holding_slot(service, repo):
slot = _inactive_slot(repo)
pending = service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930",
reason="buy", actor="test", symbol="005930", strategy_id="BUY-A",
)
return service.transit_slot(
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
@@ -49,6 +49,7 @@ class TestSell:
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
updated = repo.fetch_slot(holding.slot_id)
assert updated.status is SlotState.PENDING_SELL
assert updated.sell_strategy_id == "BUY-A"
def test_sell_records_transition_log(self, service, repo):
"""sell() appends a transition log entry."""
@@ -0,0 +1,47 @@
"""Ledger writer loop failures remain visible without leaking exception text."""
from __future__ import annotations
import logging
import threading
from typing import Any
from trex_core.runtime.daemon import Daemon
_SENTINEL = "SECRET-LEDGER-DSN-PAYLOAD-DO-NOT-LOG"
def test_ledger_loop_logs_sanitized_error_and_continues(caplog):
call_count = 0
stop_event = threading.Event()
class _Writer:
def run_once(self):
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError(f"dsn=postgresql://user:{_SENTINEL}@host/db")
stop_event.set()
class _Config:
ledger_interval_sec = 0.001
instance_key = "instance-a"
daemon: Any = object.__new__(Daemon)
daemon.stop = stop_event
daemon.config = _Config()
daemon.ledger_writer = _Writer()
with caplog.at_level(logging.ERROR, logger="trex_core.runtime.daemon"):
thread = threading.Thread(target=daemon._ledger_loop, daemon=True)
thread.start()
thread.join(timeout=2.0)
assert not thread.is_alive()
assert call_count >= 2
assert len(caplog.records) == 1
record = caplog.records[0]
assert "reason=ledger_writer_run_once_failed" in record.getMessage()
assert "instance_key=instance-a" in record.getMessage()
assert "exc_type=RuntimeError" in record.getMessage()
assert _SENTINEL not in caplog.text
assert record.exc_info is None
+9 -9
View File
@@ -83,7 +83,12 @@ def test_daemon_wires_reconciliation_broker_into_snapshot_scheduler():
/ "daemon.py"
).read_text(encoding="utf-8")
tree = ast.parse(src)
found = False
expected = {
"core_holdings_broker": "reconciliation_broker",
"cost_basis_port": "portfolio_read",
"auto_reconcile_store": "auto_reconcile_store",
}
found: dict[str, str] = {}
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
@@ -100,17 +105,12 @@ def test_daemon_wires_reconciliation_broker_into_snapshot_scheduler():
):
continue
for kw in call.keywords:
if kw.arg != "core_holdings_broker":
if kw.arg not in expected:
continue
if (
isinstance(kw.value, ast.Attribute)
and kw.value.attr == "reconciliation_broker"
and isinstance(kw.value.value, ast.Name)
and kw.value.value.id == "self"
):
found = True
break
assert found, (
"Daemon must pass self.reconciliation_broker into "
"SnapshotScheduler(core_holdings_broker=...)"
)
found[kw.arg] = kw.value.attr
assert found == expected
@@ -0,0 +1,60 @@
"""Runtime SELL event owner routing defenses (REQ-0011 / AC-0039)."""
from __future__ import annotations
from typing import Any, cast
from uuid import uuid4
from trex_core.domain.slot import Slot, SlotState
from trex_core.runtime.daemon import Daemon
class _Publisher:
def __init__(self) -> None:
self.calls: list[tuple[str, dict]] = []
self.failure: Exception | None = None
def publish_event(self, routing_key: str, payload: dict) -> None:
if self.failure is not None:
raise self.failure
self.calls.append((routing_key, payload))
def _daemon_with_publisher() -> tuple[Daemon, _Publisher]:
daemon = object.__new__(Daemon)
publisher = _Publisher()
daemon.event_publisher = cast(Any, publisher)
return daemon, publisher
def test_runtime_emitter_blocks_ownerless_sell_without_synthetic_owner(caplog):
daemon, publisher = _daemon_with_publisher()
slot = Slot(slot_id=uuid4(), status=SlotState.INACTIVE, symbol="005930")
daemon._make_event_emitter()("slot_filled_sell", slot, None)
assert publisher.calls == []
assert "reason=sell_event_owner_missing" in caplog.text
def test_runtime_emitter_routes_sell_with_explicit_owner():
daemon, publisher = _daemon_with_publisher()
slot = Slot(slot_id=uuid4(), status=SlotState.INACTIVE, symbol="005930")
daemon._make_event_emitter()("slot_filled_sell", slot, "SELL-B")
assert len(publisher.calls) == 1
assert publisher.calls[0][0] == "slot_filled_sell.SELL-B.005930"
def test_runtime_publish_failure_log_excludes_raw_exception(caplog):
daemon, publisher = _daemon_with_publisher()
publisher.failure = RuntimeError("SENTINEL_SECRET_CONNECTION_TEXT")
slot = Slot(slot_id=uuid4(), status=SlotState.INACTIVE, symbol="005930")
daemon._make_event_emitter()("slot_filled_sell", slot, "SELL-B")
assert publisher.calls == []
assert "reason=event_publish_failed" in caplog.text
assert "exc_type=RuntimeError" in caplog.text
assert "SENTINEL_SECRET_CONNECTION_TEXT" not in caplog.text
assert not any(record.exc_info for record in caplog.records)
+43 -6
View File
@@ -26,7 +26,7 @@ def test_documented_python_invocation_loads_project_without_pythonpath():
)
assert completed.returncode == 0, completed.stderr
assert "--expected-oci-revision" in completed.stdout
assert "--observed-evidence-file" in completed.stdout
assert script.stat().st_mode & 0o111 != 0
@@ -62,6 +62,21 @@ def _argv(tmp_path: Path, repo: Path, head: str) -> list[str]:
trex, rabbit = _fake_read_only_commands(tmp_path)
broker_log = tmp_path / "broker.log"
broker_log.write_text("event=order_submit account=must-not-leak\n", encoding="utf-8")
evidence = tmp_path / "observed-evidence.json"
evidence.write_text(
json.dumps(
{
"schema_version": 1,
"target_id": "service-a",
"target_kind": "compose-service",
"image_id": "sha256:" + ("b" * 64),
"image_digest": "sha256:" + ("a" * 64),
"oci_revision": head,
"observed_at": "2026-09-20T02:00:00Z",
}
),
encoding="utf-8",
)
return [
"--actor", "operator-a",
"--reason", "deploy release",
@@ -70,8 +85,7 @@ def _argv(tmp_path: Path, repo: Path, head: str) -> list[str]:
"--phase", "pre",
"--command-class", "deploy",
"--checkout-dir", str(repo),
"--expected-oci-revision", head,
"--image-digest", "sha256:" + ("a" * 64),
"--observed-evidence-file", str(evidence),
"--runtime-file", "runtime.py",
"--broker-log-path", str(broker_log),
"--capture-command", f"pending_slots={trex} slots --status pending --instance inst-a --json",
@@ -85,15 +99,20 @@ def test_cli_writes_unique_record_outside_checkout_and_keeps_checkout_clean(tmp_
argv = _argv(tmp_path, repo, head)
assert release_provenance_gate.main(argv) == 0
first = Path(capsys.readouterr().out.strip())
first_receipt = json.loads(capsys.readouterr().out)
first = Path(first_receipt["record_path"])
assert release_provenance_gate.main(argv) == 0
second = Path(capsys.readouterr().out.strip())
second_receipt = json.loads(capsys.readouterr().out)
second = Path(second_receipt["record_path"])
assert first != second
assert first.parent == tmp_path / "repo-release-provenance"
assert first.stat().st_mode & 0o222 == 0
assert second.stat().st_mode & 0o222 == 0
payload = json.loads(first.read_text(encoding="utf-8"))
assert payload["schema_version"] == 1
assert payload["observed_evidence"]["source"] == "operator_observed"
assert first_receipt["record_sha256"] == __import__("hashlib").sha256(first.read_bytes()).hexdigest()
assert payload["gate"]["passed"] is True
assert "must-not-leak" not in first.read_text(encoding="utf-8")
status = subprocess.run(
@@ -108,7 +127,8 @@ def test_cli_dirty_checkout_is_fail_closed_and_isolated(tmp_path, capsys):
(repo / "runtime.py").write_text("dirty\n", encoding="utf-8")
assert release_provenance_gate.main(argv) == 2
output = Path(capsys.readouterr().out.strip())
receipt = json.loads(capsys.readouterr().out)
output = Path(receipt["record_path"])
payload = json.loads(output.read_text(encoding="utf-8"))
assert output.stat().st_mode & 0o222 == 0
assert payload["gate"]["passed"] is False
@@ -131,3 +151,20 @@ def test_cli_rejects_unsafe_command_and_checkout_local_output(tmp_path):
argv.extend(["--output-dir", str(repo / "artifacts")])
with pytest.raises(SystemExit):
release_provenance_gate.main(argv)
def test_invalid_observed_evidence_creates_no_record_or_receipt(tmp_path, capsys):
repo, head = _init_repo(tmp_path)
argv = _argv(tmp_path, repo, head)
evidence_index = argv.index("--observed-evidence-file") + 1
evidence = Path(argv[evidence_index])
evidence.write_text(
'{"schema_version":1,"schema_version":1,"target_id":"service-a"}',
encoding="utf-8",
)
with pytest.raises(SystemExit):
release_provenance_gate.main(argv)
assert capsys.readouterr().out == ""
assert not (tmp_path / "repo-release-provenance").exists()
@@ -78,6 +78,8 @@ class PortfolioHubLedger:
"executed_at": trade.executed_at.isoformat() if trade.executed_at else None,
"execution_id": trade.execution_id,
"order_id": trade.order_id,
"execution_venue": trade.venue,
"order_venue": trade.order_venue,
}
def _post(self, url: str, json: dict) -> httpx.Response:
@@ -0,0 +1,175 @@
"""Atomic persistence adapters for holding mirror auto-reconciliation."""
from __future__ import annotations
import copy
import json
from dataclasses import replace
from datetime import UTC, datetime
from typing import Any
from trex_core.adapters.persistence.postgresql import _require_psycopg
from trex_core.application.auto_reconcile import AutoReconcileCommand
from trex_core.application.portfolio import InMemoryReconcileWarningStore
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
from trex_core.domain.slot.models import Slot, SlotTransitionRecord
from trex_core.domain.slot.state import SlotState, slot_advisory_lock_id
_REASON = "hub_broker_matched_core_mirror_stale"
_ACTOR = "auto_reconcile"
def _eligible(current: Slot | None, command: AutoReconcileCommand) -> bool:
return bool(
current is not None
and current.slot_id == command.slot_id
and current.instance_key == command.instance_key
and current.portfolio_id == command.portfolio_id
and current.symbol == command.symbol
and current.status is SlotState.HOLDING
and current.retired_at is None
and current.qty == command.expected_qty
and current.avg_price == command.expected_avg_price
and current.limit_price is None
and current.broker_order_id is None
and current.cancel_state is None
and current.cancel_requested_at is None
and current.order_submitted_at is None
and current.order_date is None
and current.cancel_attempts == 0
)
def _audit_extra(command: AutoReconcileCommand) -> dict[str, object]:
return {
"portfolio_id": command.portfolio_id,
"symbol": command.symbol,
"before_qty": command.expected_qty,
"after_qty": command.target_qty,
"before_avg_price": command.expected_avg_price,
"after_avg_price": command.target_avg_price,
"hub_quantity": command.hub_quantity,
"broker_quantity": command.broker_quantity,
}
class InMemoryAutoReconcileStore:
"""In-process atomic-equivalent adapter used by tests and local runtimes."""
def __init__(
self,
slot_repo: InMemorySlotRepository,
warning_store: InMemoryReconcileWarningStore,
) -> None:
self._slot_repo = slot_repo
self._warning_store = warning_store
def apply_holding_mirror(self, command: AutoReconcileCommand) -> bool:
lock_id = slot_advisory_lock_id(command.instance_key)
with self._slot_repo.transactional_advisory_lock(lock_id):
with self._warning_store._lock: # same critical section as warning CRUD
current = self._slot_repo.fetch_slot(command.slot_id)
warnings = self._warning_store._warned.get(str(command.slot_id), {})
if current is None or not _eligible(current, command) or "B" not in warnings:
return False
previous_slot = current
previous_transitions = list(self._slot_repo._transitions)
previous_warnings = copy.deepcopy(self._warning_store._warned)
now = datetime.now(tz=UTC)
try:
self._slot_repo.update_slot(
replace(
current,
qty=command.target_qty,
avg_price=command.target_avg_price,
updated_at=now,
)
)
self._slot_repo.append_transition_log(
SlotTransitionRecord(
slot_id=command.slot_id,
from_state=SlotState.HOLDING,
to_state=SlotState.HOLDING,
ts=now,
reason=_REASON,
actor=_ACTOR,
extra=_audit_extra(command),
)
)
warnings.pop("B", None)
if not warnings:
self._warning_store._warned.pop(str(command.slot_id), None)
return True
except Exception:
self._slot_repo._slots[command.slot_id] = previous_slot
self._slot_repo._transitions[:] = previous_transitions
self._warning_store._warned = previous_warnings
raise
class PostgreSQLAutoReconcileStore:
"""One-transaction CAS update, B-only clear, and append-only audit."""
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
self._psycopg = _require_psycopg(psycopg_module)
self._dsn = dsn
def apply_holding_mirror(self, command: AutoReconcileCommand) -> bool:
now = datetime.now(tz=UTC)
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
try:
with conn.cursor() as cur:
cur.execute(
"SELECT pg_advisory_xact_lock(%s)",
(slot_advisory_lock_id(command.instance_key),),
)
cur.execute(
"UPDATE daily_slot SET qty=%s, avg_price=%s, updated_at=%s "
"WHERE slot_id=%s AND instance_key=%s AND portfolio_id=%s "
"AND symbol=%s AND status='holding' AND retired_at IS NULL "
"AND qty IS NOT DISTINCT FROM %s "
"AND avg_price IS NOT DISTINCT FROM %s "
"AND limit_price IS NULL AND broker_order_id IS NULL "
"AND cancel_state IS NULL AND cancel_requested_at IS NULL "
"AND order_submitted_at IS NULL AND order_date IS NULL "
"AND cancel_attempts=0",
(
command.target_qty,
command.target_avg_price,
now,
command.slot_id,
command.instance_key,
command.portfolio_id,
command.symbol,
command.expected_qty,
command.expected_avg_price,
),
)
if cur.rowcount != 1:
conn.rollback()
return False
cur.execute(
"DELETE FROM slot_reconcile_warning WHERE slot_id=%s AND case_code=%s",
(str(command.slot_id), "B"),
)
if cur.rowcount != 1:
conn.rollback()
return False
cur.execute(
"INSERT INTO slot_transition_log "
"(slot_id, from_state, to_state, ts, reason, actor, extra) "
"VALUES (%s, 'holding', 'holding', %s, %s, %s, %s)",
(
command.slot_id,
now,
_REASON,
_ACTOR,
json.dumps(_audit_extra(command)),
),
)
conn.commit()
return True
except Exception:
conn.rollback()
raise
+382 -26
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
@@ -68,10 +69,39 @@ _SENSITIVE_VALUE = re.compile(
)
_FULL_GIT_REVISION = re.compile(r"[0-9a-f]{40}")
_IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
_SHA256 = re.compile(r"[0-9a-f]{64}")
_SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
_REQUIRED_CAPTURE_KEYS = frozenset(
{"pending_slots", "reconciliation_slots", "queue_consumers"}
)
_OBSERVED_EVIDENCE_KEYS = frozenset(
{
"schema_version",
"target_id",
"target_kind",
"image_id",
"image_digest",
"oci_revision",
"observed_at",
}
)
_RECORD_KEYS = frozenset(
{
"schema_version",
"timestamp_utc",
"actor",
"reason",
"instance",
"command_class",
"operation_id",
"phase",
"observed_evidence",
"checkout",
"runtime_file_verification",
"state_capture",
"gate",
}
)
def _run_git(checkout_dir: Path, *args: str) -> str:
@@ -153,6 +183,80 @@ def _sanitize_json(value: object) -> object:
return value
def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate JSON key: {key}")
result[key] = value
return result
def _load_strict_json(path: str | Path) -> dict[str, object]:
try:
value = json.loads(
Path(path).read_text(encoding="utf-8"),
object_pairs_hook=_reject_duplicate_keys,
)
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ValueError("invalid JSON input") from exc
if not isinstance(value, dict):
raise TypeError("JSON input must be an object")
return value
def _is_utc_timestamp(value: object) -> bool:
if not isinstance(value, str):
return False
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return False
return parsed.tzinfo is not None and parsed.utcoffset() == datetime.now(tz=UTC).utcoffset()
def _contains_sensitive_material(value: object) -> bool:
if isinstance(value, dict):
return any(
_SENSITIVE_KEY.search(str(key)) or _contains_sensitive_material(item)
for key, item in value.items()
)
if isinstance(value, list):
return any(_contains_sensitive_material(item) for item in value)
return isinstance(value, str) and _sanitize_text(value) != value
def _validate_observed_evidence(value: object) -> dict[str, object]:
if not isinstance(value, dict) or set(value) != _OBSERVED_EVIDENCE_KEYS:
raise ValueError("observed evidence keys must match schema version 1")
if type(value["schema_version"]) is not int or value["schema_version"] != 1:
raise ValueError("observed evidence schema_version must be integer 1")
if not all(
isinstance(value[key], str) and bool(value[key])
for key in ("target_id", "target_kind")
):
raise ValueError("observed target fields must be non-empty strings")
if not all(
isinstance(value[key], str) and _IMAGE_DIGEST.fullmatch(value[key])
for key in ("image_id", "image_digest")
):
raise ValueError("observed image identity must use lowercase sha256 digests")
if not isinstance(value["oci_revision"], str) or not _FULL_GIT_REVISION.fullmatch(
value["oci_revision"]
):
raise ValueError("observed oci_revision must be a full lowercase git SHA")
if not _is_utc_timestamp(value["observed_at"]):
raise ValueError("observed_at must be a UTC RFC 3339 timestamp")
if _contains_sensitive_material(value):
raise ValueError("observed evidence must not contain sensitive material")
return dict(value)
def load_observed_evidence(path: str | Path) -> dict[str, object]:
"""Load strict caller-observed provenance without claiming runtime observation."""
return _validate_observed_evidence(_load_strict_json(path))
def _validate_capture_commands(
capture_commands: dict[str, list[str]], *, instance: str
) -> None:
@@ -297,8 +401,7 @@ def collect_release_provenance(
command_class: str,
operation_id: str,
phase: str,
expected_oci_revision: str,
image_digest: str,
observed_evidence: dict[str, object],
runtime_files: list[str],
capture_commands: dict[str, list[str]],
checkout_dir: str | Path = ".",
@@ -313,10 +416,8 @@ def collect_release_provenance(
raise ValueError("instance and operation_id must be safe identifiers")
if phase not in {"pre", "post"}:
raise ValueError("phase must be pre or post")
if not _FULL_GIT_REVISION.fullmatch(expected_oci_revision):
raise ValueError("expected_oci_revision must be a full lowercase git SHA")
if not _IMAGE_DIGEST.fullmatch(image_digest):
raise ValueError("image_digest must be sha256:<64 lowercase hex>")
observed = _validate_observed_evidence(observed_evidence)
expected_oci_revision = str(observed["oci_revision"])
if not runtime_files:
raise ValueError("at least one runtime file is required")
_validate_capture_commands(capture_commands, instance=instance)
@@ -378,6 +479,7 @@ def collect_release_provenance(
and broker_logs_ok
)
return {
"schema_version": 1,
"timestamp_utc": datetime.now(tz=UTC).isoformat(),
"actor": actor,
"reason": reason,
@@ -393,11 +495,7 @@ def collect_release_provenance(
"git_clean": git_clean,
"dirty_paths": dirty_paths,
},
"artifact_identity": {
"expected_oci_revision": expected_oci_revision,
"oci_revision_matches_head": oci_revision_matches_head,
"image_digest": image_digest,
},
"observed_evidence": {**observed, "source": "operator_observed"},
"runtime_file_verification": runtime_reports,
"state_capture": {
"command_outputs": command_outputs,
@@ -413,38 +511,296 @@ def collect_release_provenance(
}
def _exact_object(value: object, keys: set[str] | frozenset[str], label: str) -> dict:
if not isinstance(value, dict) or set(value) != set(keys):
raise ValueError(f"{label} keys do not match schema version 1")
return value
def _validate_success_record(record: dict[str, object], *, phase: str) -> dict[str, object]:
_exact_object(record, _RECORD_KEYS, "gate record")
if type(record["schema_version"]) is not int or record["schema_version"] != 1:
raise ValueError("unsupported gate record schema")
if record["phase"] != phase:
raise ValueError(f"record phase must be {phase}")
if not _is_utc_timestamp(record["timestamp_utc"]):
raise ValueError("record timestamp must be UTC RFC 3339")
for key in ("actor", "reason", "instance", "command_class", "operation_id"):
if not isinstance(record[key], str) or not record[key]:
raise ValueError(f"record {key} must be a non-empty string")
observed = _exact_object(
record["observed_evidence"], _OBSERVED_EVIDENCE_KEYS | {"source"}, "observed evidence"
)
if observed["source"] != "operator_observed":
raise ValueError("observed evidence source must be operator_observed")
_validate_observed_evidence({key: observed[key] for key in _OBSERVED_EVIDENCE_KEYS})
checkout = _exact_object(
record["checkout"],
{
"path",
"head_revision",
"detached_head",
"git_status_porcelain",
"git_clean",
"dirty_paths",
},
"checkout",
)
if not (
checkout["detached_head"] is True
and checkout["git_clean"] is True
and checkout["dirty_paths"] == []
and checkout["head_revision"] == observed["oci_revision"]
):
raise ValueError("checkout evidence does not satisfy pair success invariants")
runtime_reports = record["runtime_file_verification"]
runtime_keys = {
"path",
"path_within_checkout",
"exists",
"current_sha256",
"expected_revision",
"expected_sha256",
"matches_expected_revision",
}
if not isinstance(runtime_reports, list) or not runtime_reports:
raise ValueError("runtime verification must be a non-empty list")
for item in runtime_reports:
report = _exact_object(item, runtime_keys, "runtime verification")
if not (
isinstance(report["path"], str)
and report["path"]
and report["path_within_checkout"] is True
and report["exists"] is True
and report["matches_expected_revision"] is True
and report["expected_revision"] == observed["oci_revision"]
and isinstance(report["current_sha256"], str)
and _SHA256.fullmatch(report["current_sha256"])
and report["current_sha256"] == report["expected_sha256"]
):
raise ValueError("runtime verification does not satisfy pair success invariants")
state_capture = _exact_object(
record["state_capture"], {"command_outputs", "broker_side_effect_logs"}, "state capture"
)
command_outputs = _exact_object(
state_capture["command_outputs"], _REQUIRED_CAPTURE_KEYS, "command outputs"
)
output_keys = {"command", "returncode", "stdout", "stdout_format", "stderr"}
for output in command_outputs.values():
captured = _exact_object(output, output_keys, "command output")
if captured["returncode"] != 0 or type(captured["returncode"]) is not int:
raise ValueError("state capture command did not succeed")
broker = state_capture["broker_side_effect_logs"]
broker_success_keys = {
"path",
"exists",
"capture_status",
"file_sha256",
"scanned_lines",
"matches",
}
broker = _exact_object(broker, broker_success_keys, "broker capture")
if broker["capture_status"] not in {"captured", "captured_empty"}:
raise ValueError("broker capture did not succeed")
gate = _exact_object(
record["gate"],
{
"passed",
"deployment_blocked",
"runtime_files_verified",
"state_captures_succeeded",
"broker_log_captured",
},
"gate",
)
if [
gate[key]
for key in (
"passed",
"deployment_blocked",
"runtime_files_verified",
"state_captures_succeeded",
"broker_log_captured",
)
] != [True, False, True, True, True]:
raise ValueError("gate summary does not satisfy pair success invariants")
return record
def _record_digest(path: Path, expected: str) -> str:
if not _SHA256.fullmatch(expected):
raise ValueError("expected record digest must be lowercase SHA-256")
try:
payload = path.read_bytes()
except OSError as exc:
raise ValueError("record path is not readable") from exc
actual = _sha256_bytes(payload)
if actual != expected:
raise ValueError("record bytes do not match expected SHA-256")
return actual
def verify_release_provenance_pair(
*,
pre_path: str | Path,
pre_sha256: str,
post_path: str | Path,
post_sha256: str,
output_path: str | Path,
) -> dict[str, object]:
"""Verify one explicit PRE/POST pair and write an immutable manifest."""
pre = Path(pre_path)
post = Path(post_path)
if not pre.is_absolute() or not post.is_absolute():
raise ValueError("record paths must be canonical absolute paths")
pre = pre.resolve()
post = post.resolve()
if pre == post:
raise ValueError("PRE and POST record paths must differ")
pre_digest = _record_digest(pre, pre_sha256)
post_digest = _record_digest(post, post_sha256)
pre_record = _validate_success_record(_load_strict_json(pre), phase="pre")
post_record = _validate_success_record(_load_strict_json(post), phase="post")
for key in ("schema_version", "operation_id", "instance"):
if pre_record[key] != post_record[key]:
raise ValueError(f"PRE/POST {key} mismatch")
pre_observed = pre_record["observed_evidence"]
post_observed = post_record["observed_evidence"]
for key in ("target_kind", "target_id"):
if pre_observed[key] != post_observed[key]:
raise ValueError(f"PRE/POST {key} mismatch")
destination = Path(output_path).resolve()
for checkout_path in (pre_record["checkout"]["path"], post_record["checkout"]["path"]):
checkout = Path(checkout_path).resolve()
try:
destination.relative_to(checkout)
except ValueError:
continue
raise ValueError("pair manifest must be outside the audit checkout")
def manifest_record(
path: Path, digest: str, record: dict[str, object], observed: dict[str, object]
) -> dict[str, object]:
return {
"record_path": str(path),
"record_sha256": digest,
"phase": record["phase"],
"command_class": record["command_class"],
"image_id": observed["image_id"],
"image_digest": observed["image_digest"],
"oci_revision": observed["oci_revision"],
"observed_at": observed["observed_at"],
}
manifest = {
"schema_version": 1,
"verified_at": datetime.now(tz=UTC).isoformat(),
"operation_id": pre_record["operation_id"],
"instance": pre_record["instance"],
"target_kind": pre_observed["target_kind"],
"target_id": pre_observed["target_id"],
"pre": manifest_record(pre, pre_digest, pre_record, pre_observed),
"post": manifest_record(post, post_digest, post_record, post_observed),
}
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("x", encoding="utf-8") as fp:
fp.write(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
destination.chmod(0o444)
return manifest
def isolate_dirty_checkout_files(
*,
checkout_dir: str | Path,
isolate_dir: str | Path,
) -> dict:
"""Copy dirty/untracked checkout files to an isolation directory."""
"""Copy dirty files without mutating the checkout and verify copy integrity."""
checkout = Path(checkout_dir).resolve()
dst_root = Path(isolate_dir).resolve()
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S.%fZ")
dst = dst_root / f"{stamp}--{uuid.uuid4().hex[:12]}"
_, dirty_paths = _git_status(checkout)
copied: list[str] = []
entries: list[dict[str, object]] = []
for rel in dirty_paths:
src = _resolve_under(checkout, rel)
if src is None or not src.exists() or src.is_dir():
continue
target = (dst / rel).resolve()
rel_path = Path(rel)
if rel_path.is_absolute() or ".." in rel_path.parts:
raise ValueError(f"unsafe dirty path: {rel}")
src = checkout / rel_path
try:
target.relative_to(dst)
except ValueError:
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target)
copied.append(rel)
src.parent.resolve().relative_to(checkout)
except ValueError as exc:
raise ValueError(f"dirty path parent escapes checkout: {rel}") from exc
if src.is_symlink():
source_type = "symlink"
source_target = os.readlink(src)
source_target_path = Path(source_target)
if source_target_path.is_absolute():
raise ValueError(f"symlink target escapes checkout: {rel}")
resolved_source_target = (src.parent / source_target_path).resolve(strict=False)
try:
resolved_source_target.relative_to(checkout)
except ValueError as exc:
raise ValueError(f"symlink target escapes checkout: {rel}") from exc
source_bytes = source_target.encode("utf-8")
target = dst / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
os.symlink(source_target, target)
copied_type = "symlink" if target.is_symlink() else "unsupported"
copied_bytes = os.readlink(target).encode("utf-8") if target.is_symlink() else b""
elif src.is_file():
source_type = "regular"
source_bytes = src.read_bytes()
target = dst / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target, follow_symlinks=False)
copied_type = "regular" if target.is_file() and not target.is_symlink() else "unsupported"
copied_bytes = target.read_bytes() if copied_type == "regular" else b""
else:
raise ValueError(f"unsupported dirty path type: {rel}")
source_sha = _sha256_bytes(source_bytes)
copied_sha = _sha256_bytes(copied_bytes)
matches = (
copied_type == source_type
and len(copied_bytes) == len(source_bytes)
and copied_sha == source_sha
)
if not matches:
raise ValueError(f"isolated copy integrity mismatch: {rel}")
entries.append(
{
"path": rel,
"source_type": source_type,
"source_size": len(source_bytes),
"source_sha256": source_sha,
"copied_path": str(target.resolve(strict=False)) if source_type == "regular" else str(target),
"copied_type": copied_type,
"copied_size": len(copied_bytes),
"copied_sha256": copied_sha,
"matches": True,
}
)
manifest = {
"schema_version": 1,
"timestamp_utc": datetime.now(tz=UTC).isoformat(),
"checkout_dir": str(checkout),
"isolate_dir": str(dst),
"dirty_paths": dirty_paths,
"copied_paths": copied,
"entries": entries,
}
dst.mkdir(parents=True, exist_ok=True)
with (dst / "manifest.json").open("x", encoding="utf-8") as fp:
manifest_path = dst / "manifest.json"
with manifest_path.open("x", encoding="utf-8") as fp:
fp.write(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
manifest_path.chmod(0o444)
return manifest
+52
View File
@@ -16,10 +16,12 @@ signal (REQ-028 AC-2) without callers seeing raw httpx exceptions.
from __future__ import annotations
import math
from datetime import UTC, datetime
from typing import Any, Optional
import httpx
from trex_core.application.auto_reconcile import HoldingsCostBasisEvidence
from trex_core.application.portfolio import (
BrokerCredentialPayload,
PortfolioHubUnavailableError,
@@ -268,6 +270,56 @@ class PortfolioHubReadAdapter:
return None, "invalid"
return value, "ok"
def get_cost_basis_evidence(self, portfolio_id: str) -> HoldingsCostBasisEvidence:
"""Return only canonical, complete evidence used by auto-reconcile."""
url = f"{self._base_url}/v1/portfolios/{portfolio_id}/holdings/cost-basis"
try:
resp = self._get(url)
except httpx.HTTPError as exc:
raise PortfolioHubUnavailableError(
f"cost_basis network error: {exc.__class__.__name__}"
) from exc
if not (200 <= resp.status_code < 300):
raise PortfolioHubUnavailableError(
f"cost_basis http_status_{resp.status_code}"
)
try:
body = resp.json()
except ValueError as exc:
raise PortfolioHubUnavailableError("cost_basis invalid_json") from exc
if not isinstance(body, dict):
raise PortfolioHubUnavailableError("cost_basis invalid_payload")
response_pid = body.get("portfolio_id")
symbol = body.get("symbol")
if str(response_pid or "") != portfolio_id or not isinstance(symbol, str) or not symbol:
raise PortfolioHubUnavailableError("cost_basis identity_mismatch")
def _finite_number(name: str, *, positive: bool) -> float:
raw = body.get(name)
if isinstance(raw, bool):
raise PortfolioHubUnavailableError(f"cost_basis invalid_{name}")
try:
value = float(str(raw))
except (TypeError, ValueError) as exc:
raise PortfolioHubUnavailableError(f"cost_basis invalid_{name}") from exc
if not math.isfinite(value) or (positive and value <= 0) or (not positive and value < 0):
raise PortfolioHubUnavailableError(f"cost_basis invalid_{name}")
return value
warning = body.get("data_completeness_warning")
if warning is not None and not isinstance(warning, str):
raise PortfolioHubUnavailableError("cost_basis invalid_completeness_warning")
return HoldingsCostBasisEvidence(
portfolio_id=portfolio_id,
symbol=symbol,
current_quantity=_finite_number("current_quantity", positive=True),
cost_basis_per_unit=_finite_number("cost_basis_per_unit", positive=True),
total_cost_basis=_finite_number("total_cost_basis", positive=True),
observed_at=datetime.now(tz=UTC),
data_completeness_warning=warning or None,
)
def get_cost_basis_with_diagnostics(
self,
portfolio_id: str,
+46
View File
@@ -0,0 +1,46 @@
"""Fail-closed holding mirror auto-reconciliation contracts (issue #94)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from uuid import UUID
@dataclass(frozen=True)
class HoldingsCostBasisEvidence:
"""Canonical portfolio-hub evidence captured by one successful HTTP read."""
portfolio_id: str
symbol: str
current_quantity: float
cost_basis_per_unit: float
total_cost_basis: float
observed_at: datetime
data_completeness_warning: str | None = None
@dataclass(frozen=True)
class AutoReconcileCommand:
"""CAS-bound command for one atomic mirror repair."""
slot_id: UUID
instance_key: str
portfolio_id: str
symbol: str
expected_qty: float
expected_avg_price: float | None
target_qty: float
target_avg_price: float
hub_quantity: float
broker_quantity: int
evidence_observed_at: datetime
class AutoReconcileStore(Protocol):
"""Atomic qty/avg/audit/B-warning persistence boundary."""
def apply_holding_mirror(self, command: AutoReconcileCommand) -> bool:
"""Apply exactly once when every CAS/eligibility condition still holds."""
...
+73 -13
View File
@@ -95,6 +95,7 @@ class ExecutionResult:
stale_pending: int = 0 # dcy-013 / REQ-014 FR-3: pending orders over threshold
sell_skipped_no_balance: int = 0 # dcy-020 / REQ-062: broker flat -> sell skipped
timeout_cancelled: int = 0 # dcy-022 / REQ-069: cancel_order sent on timeout
sell_event_owner_missing: int = 0 # req-0011/ac-0039: pre-publish skip count
class ExecutionService:
@@ -555,13 +556,13 @@ class ExecutionService:
def _on_filled(self, slot, fs, side: str, r: ExecutionResult) -> None:
broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch
# Capture strategy_id BEFORE the transition clears it.
# PENDING_SELL -> INACTIVE clears strategy_id on the slot row
# (_apply_transition_fields REQ-057), so we must capture it here to
# supply as a routing hint for the slot_filled_sell event — otherwise the
# routing key uses "_" and the message is NO_ROUTEd to the DLQ instead
# of reaching trex.events.strategy.{strategy_id} (M2 REL-009 feedback).
routing_strategy_id = slot.strategy_id
# req-0011/ac-0038: SELL admission persists the canonical owner in
# sell_strategy_id. Capture before INACTIVE clears identity fields.
routing_strategy_id = (
self._sell_event_owner(slot)
if slot.status is SlotState.PENDING_SELL
else self._non_empty_strategy_id(slot.strategy_id)
)
# REQ-095: resolve fill price with limit_price backfill guard.
fill_price, _was_backfilled = self._resolve_fill_price(fs, slot)
if fill_price is None:
@@ -633,7 +634,7 @@ class ExecutionService:
# record subject). Best-effort: a publish failure must not roll back.
# Pass routing_strategy_id so sell fills route correctly even after
# strategy_id is cleared from the slot row (slot_filled_sell NO_ROUTE fix).
self._emit_event(event_type, updated, routing_strategy_id)
self._emit_event(event_type, updated, routing_strategy_id, r)
self._repo.clear_dispatch(slot.slot_id) # done with this order
def _on_partial_cancel(self, slot, fs, side: str, r: ExecutionResult) -> None:
@@ -646,9 +647,11 @@ class ExecutionService:
portion that actually filled.
"""
broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch
# Capture strategy_id BEFORE the transition (same reason as _on_filled:
# PENDING_SELL -> INACTIVE clears strategy_id, causing NO_ROUTE).
routing_strategy_id = slot.strategy_id
routing_strategy_id = (
self._sell_event_owner(slot)
if slot.status is SlotState.PENDING_SELL
else self._non_empty_strategy_id(slot.strategy_id)
)
# REQ-095: resolve fill price with limit_price backfill guard.
fill_price, _was_backfilled = self._resolve_fill_price(fs, slot)
if fill_price is None:
@@ -700,7 +703,7 @@ class ExecutionService:
except SlotStateMismatchError:
return # slot moved concurrently; next tick reconciles
self._record_ledger(slot, fs, side, broker_order_id, r, resolved_price=fill_price)
self._emit_event(event_type, updated, routing_strategy_id)
self._emit_event(event_type, updated, routing_strategy_id, r)
self._repo.clear_dispatch(slot.slot_id)
def _check_stale_pending(self, slot, now: datetime, r: ExecutionResult) -> None:
@@ -860,7 +863,64 @@ class ExecutionService:
},
)
def _emit_event(self, event_type: str, slot, routing_strategy_id: str | None = None) -> None:
@staticmethod
def _non_empty_strategy_id(value: object) -> str | None:
return value if isinstance(value, str) and value.strip() else None
@classmethod
def _sell_event_owner(cls, slot) -> str | None:
return (
cls._non_empty_strategy_id(getattr(slot, "sell_strategy_id", None))
or cls._non_empty_strategy_id(getattr(slot, "strategy_id", None))
)
def _emit_event(
self,
event_type: str,
slot,
routing_strategy_id: str | None = None,
result: ExecutionResult | None = None,
) -> None:
if (
event_type == "slot_filled_sell"
and self._non_empty_strategy_id(routing_strategy_id) is None
):
if result is not None:
result.sell_event_owner_missing += 1
reason = "sell_event_owner_missing"
slot_id = getattr(slot, "slot_id", None) or getattr(slot, "id", None)
symbol = getattr(slot, "symbol", None)
_log.error(
"sell event skipped reason=%s instance_key=%s slot_id=%s "
"symbol=%s event_type=%s",
reason,
self._instance_key,
slot_id,
symbol,
event_type,
)
if self._alarm_emitter is not None:
try:
self._alarm_emitter.publish_alarm(
reason,
"SELL event owner missing; strategy publish skipped",
fields={
"instance_key": self._instance_key,
"slot_id": str(slot_id) if slot_id is not None else None,
"symbol": symbol,
"event_type": event_type,
"reason": reason,
},
)
except Exception: # noqa: BLE001 - alarm is best-effort
_log.error(
"sell owner alarm failed reason=%s instance_key=%s "
"slot_id=%s",
reason,
self._instance_key,
slot_id,
)
return
if self._event_emitter is None:
return
try:
+13 -1
View File
@@ -469,6 +469,18 @@ class IntentService:
return IntentDecision(
intent.intent_id, "rejected", None, f"slot_not_holding:{slot.status.value}"
)
# req-0011 / ac-0038: decide the SELL event owner at admission and
# persist it in the existing canonical slot field. An explicit SELL
# owner wins, then a preserved owner from a prior zero-fill cancel,
# then the legacy holding owner used by autonomous safety sells.
def non_empty(value: Optional[str]) -> Optional[str]:
return value if isinstance(value, str) and value.strip() else None
sell_event_owner = (
non_empty(intent.sell_strategy_id)
or non_empty(slot.sell_strategy_id)
or non_empty(slot.strategy_id)
)
# dcy-022 / REQ-068: resolve effective timeout.
effective_timeout = (
intent.timeout_sec
@@ -482,7 +494,7 @@ class IntentService:
SlotState.PENDING_SELL,
reason=intent.reason,
actor="intent_api",
sell_strategy_id=intent.sell_strategy_id,
sell_strategy_id=sell_event_owner,
# dcy-012 / REQ-007: optional sell limit (None => market sell).
limit_price=intent.limit_price,
# dcy-021 / REQ-062: partial-sell order qty (None => sell all);
+51 -8
View File
@@ -23,13 +23,14 @@ critical path while making ledger writes eventually-consistent and idempotent.
"""
from __future__ import annotations
import json
import logging
import random
from collections.abc import Callable
from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Optional, Protocol
from typing import Any, Optional, Protocol
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.ledger_port import (
@@ -223,6 +224,41 @@ class LedgerWriter:
def _count(self, key: str) -> None:
self.counters[key] = self.counters.get(key, 0) + 1
def metrics_snapshot(self) -> dict[str, Any]:
"""Return a detached process-lifetime observability snapshot."""
return {
"available": True,
"scope": "process_lifetime",
"instance_key": self._instance_key,
"claims": {
"empty_polls_total": self.counters.get("claim:empty", 0),
"claimed_batches_total": self.counters.get("claim:claimed", 0),
"unavailable_total": self.counters.get("claim:unavailable", 0),
"lost_total": self.counters.get("claim:lost", 0),
"foreign_instance_total": self.counters.get(
"claim:foreign_instance", 0
),
},
"outcomes": {
"completed_total": self.counters.get("outcome:completed", 0),
"duplicate_completed_total": self.counters.get(
"outcome:duplicate_completed", 0
),
"retryable_failure_total": self.counters.get(
"outcome:retryable_failure", 0
),
"conflict_blocked_total": self.counters.get(
"outcome:conflict_blocked", 0
),
"permanent_failure_total": self.counters.get(
"outcome:permanent_failure", 0
),
"dead_lettered_total": self.counters.get(
"outcome:dead_lettered", 0
),
},
}
def enqueue_fill(self, trade: LedgerTrade) -> LedgerWorkItem:
return self._outbox.enqueue(trade)
@@ -231,13 +267,20 @@ class LedgerWriter:
items = self._outbox.claim_pending(limit)
claim_result = "claimed" if items else "empty"
self._count(f"claim:{claim_result}")
_log.info(
"ledger outbox claim: instance_key=%s claim_result=%s count=%d limit=%d",
self._instance_key,
claim_result,
len(items),
limit,
)
if items:
correlation_ids = json.dumps(
[item.correlation_id for item in items],
ensure_ascii=True,
separators=(",", ":"),
)
_log.info(
"ledger outbox claim: instance_key=%s claim_result=claimed "
"count=%d limit=%d correlation_ids=%s",
self._instance_key,
len(items),
limit,
correlation_ids,
)
for item in items:
if not self._validate_instance(item):
continue
@@ -259,6 +259,7 @@ def make_slots_rpc_handler(
watchdog_store: Optional[WatchdogStore] = None,
watchdog_timeout_sec: int = 120,
ledger_outbox=None,
ledger_outbox_metrics_reader=None,
max_active_slots: Optional[int] = None,
strategy_state_store=None,
broker_quantity_reader: Optional[SlotsBrokerQuantityReader] = None,
@@ -512,6 +513,23 @@ def make_slots_rpc_handler(
)
if health_payload:
base_payload["broker_balance_health"] = health_payload
if spy:
unavailable_metrics = {
"available": False,
"scope": "process_lifetime",
"instance_key": instance_key,
"reason": "ledger_outbox_metrics_unavailable",
}
if ledger_outbox_metrics_reader is None:
base_payload["ledger_outbox_metrics"] = unavailable_metrics
else:
try:
metrics = ledger_outbox_metrics_reader.metrics_snapshot()
if not isinstance(metrics, dict):
raise TypeError("metrics snapshot must be a dict")
base_payload["ledger_outbox_metrics"] = metrics
except Exception: # noqa: BLE001 - status response must stay available
base_payload["ledger_outbox_metrics"] = unavailable_metrics
if warn_store is not None:
base_payload["excluded_slots"] = {
"reconcile_quantity_mismatch": excluded_count,
+9
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
from typing import Optional, Protocol
from uuid import UUID
from trex_core.application.auto_reconcile import HoldingsCostBasisEvidence
class IntentStore(Protocol):
"""Durable idempotency + audit store for accepted intents (dcy-002 OQ-1).
@@ -64,3 +66,10 @@ class PortfolioHubCostBasisPort(Protocol):
or any error occurs. The caller must handle None as a graceful-degrade signal.
"""
...
def get_cost_basis_evidence(self, portfolio_id: str) -> HoldingsCostBasisEvidence:
"""Return strict canonical evidence for automatic holding reconciliation.
Implementations must not fall back to legacy cost-basis fields.
"""
...
+151 -1
View File
@@ -29,12 +29,18 @@ import hashlib
import logging
import math
import re
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import cast
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.auto_reconcile import (
AutoReconcileCommand,
AutoReconcileStore,
HoldingsCostBasisEvidence,
)
from trex_core.application.broker_port import BrokerPort
from trex_core.application.portfolio import (
PortfolioItem,
ReconcileWarningStore,
SnapshotStore,
UnmanagedPositionStore,
@@ -377,6 +383,113 @@ def _broker_balances_are_authoritative_for_unmanaged_resolution(broker: BrokerPo
return False
_AUTO_RECONCILE_EVIDENCE_MAX_AGE = timedelta(seconds=60)
_AUTO_RECONCILE_FUTURE_TOLERANCE = timedelta(seconds=5)
def _try_auto_reconcile_holding(
*,
slot,
broker_quantity: int,
portfolio_items: list[PortfolioItem] | None,
cost_basis_port,
auto_reconcile_store: AutoReconcileStore | None,
observed_at: datetime,
) -> bool:
"""Return True only after the atomic mirror repair commits."""
if portfolio_items is None or cost_basis_port is None or auto_reconcile_store is None:
return False
if (
slot.status is not SlotState.HOLDING
or slot.retired_at is not None
or not slot.portfolio_id
or not slot.symbol
or slot.qty is None
or broker_quantity <= 0
or slot.limit_price is not None
or slot.broker_order_id is not None
or slot.cancel_state is not None
or slot.cancel_requested_at is not None
or slot.order_submitted_at is not None
or slot.order_date is not None
or slot.cancel_attempts != 0
):
return False
matches = [item for item in portfolio_items if item.portfolio_id == slot.portfolio_id]
symbol_matches = [item for item in portfolio_items if item.symbol == slot.symbol]
if len(matches) != 1 or len(symbol_matches) != 1:
return False
item = matches[0]
hub_quantity = item.asset_quantity
if (
item.symbol != slot.symbol
or not math.isfinite(hub_quantity)
or hub_quantity <= 0
or not float(hub_quantity).is_integer()
or int(hub_quantity) != broker_quantity
):
return False
getter = getattr(cost_basis_port, "get_cost_basis_evidence", None)
if not callable(getter):
return False
try:
evidence = cast(HoldingsCostBasisEvidence, getter(slot.portfolio_id))
except Exception: # noqa: BLE001 - fail closed without provider details
_log.warning(
"auto_reconcile skipped: slot_id=%s reason=cost_basis_evidence_unavailable",
slot.slot_id,
)
return False
age = observed_at - evidence.observed_at
expected_total = evidence.current_quantity * evidence.cost_basis_per_unit
if (
evidence.observed_at.tzinfo is None
or age > _AUTO_RECONCILE_EVIDENCE_MAX_AGE
or age < -_AUTO_RECONCILE_FUTURE_TOLERANCE
or evidence.data_completeness_warning is not None
or evidence.portfolio_id != slot.portfolio_id
or evidence.symbol != slot.symbol
or not math.isfinite(evidence.current_quantity)
or not math.isfinite(evidence.cost_basis_per_unit)
or not math.isfinite(evidence.total_cost_basis)
or evidence.current_quantity != hub_quantity
or evidence.cost_basis_per_unit <= 0
or evidence.total_cost_basis <= 0
or not math.isclose(
evidence.total_cost_basis,
expected_total,
rel_tol=1e-9,
abs_tol=1e-6,
)
):
return False
command = AutoReconcileCommand(
slot_id=slot.slot_id,
instance_key=slot.instance_key,
portfolio_id=slot.portfolio_id,
symbol=slot.symbol,
expected_qty=float(slot.qty),
expected_avg_price=(float(slot.avg_price) if slot.avg_price is not None else None),
target_qty=float(broker_quantity),
target_avg_price=float(evidence.cost_basis_per_unit),
hub_quantity=float(hub_quantity),
broker_quantity=broker_quantity,
evidence_observed_at=evidence.observed_at,
)
try:
return bool(auto_reconcile_store.apply_holding_mirror(command))
except Exception: # noqa: BLE001 - persistence failure keeps warning active
_log.warning(
"auto_reconcile failed: slot_id=%s reason=atomic_persistence_failure",
slot.slot_id,
)
return False
def reconcile_core_holdings(
*,
slot_repo,
@@ -387,6 +500,9 @@ def reconcile_core_holdings(
warn_store: ReconcileWarningStore | None = None,
notification_store=None,
unmanaged_position_store: UnmanagedPositionStore | None = None,
portfolio_items: list[PortfolioItem] | None = None,
cost_basis_port=None,
auto_reconcile_store: AutoReconcileStore | None = None,
) -> dict[str, str]:
"""dcy-020 / REQ-062 (BAT-029) + REQ-103 (BAT-050): surface core-slot holding
vs broker divergence and set durable reconcile-warning for B case.
@@ -451,6 +567,7 @@ def reconcile_core_holdings(
slot_id_str, slot.portfolio_id, sym, bq, slot.qty,
)
# REQ-103 / BAT-050: durable reconcile-warning (case_code='B').
warning_persisted = False
if warn_store is not None:
try:
warn_store.set_warned(
@@ -460,6 +577,7 @@ def reconcile_core_holdings(
actor="reconcile",
reason=f"slot_qty={slot.qty} broker_qty={bq} symbol={sym}",
)
warning_persisted = True
except Exception: # noqa: BLE001 - never let reconcile crash the loop
_log.exception(
"failed to set reconcile_warning for slot %s", slot.slot_id
@@ -482,6 +600,38 @@ def reconcile_core_holdings(
_log.exception(
"reconcile_core_holdings: notify_mismatch failed for slot=%s", slot_id_str,
)
if warning_persisted and _try_auto_reconcile_holding(
slot=slot,
broker_quantity=bq,
portfolio_items=portfolio_items,
cost_basis_port=cost_basis_port,
auto_reconcile_store=auto_reconcile_store,
observed_at=snapshot_observed_at,
):
decisions[slot_id_str] = "auto_synced"
active_b_identity_keys.discard(slot_id_str)
_log.info(
"auto_reconcile committed: slot_id=%s portfolio_id=%s symbol=%s",
slot_id_str,
slot.portfolio_id,
sym,
)
if alarm_emitter is not None and notification_store is not None:
try:
_notify_resolved(
alarm_emitter,
notification_store,
identity_key=slot_id_str,
case_code="B",
instance_key=instance_key,
symbol=sym,
slot_id=slot_id_str,
)
except Exception: # noqa: BLE001 - business commit is authoritative
_log.exception(
"reconcile_core_holdings: auto resolved notify failed for slot=%s",
slot_id_str,
)
else:
decisions[str(slot.slot_id)] = "ok"
for sym, bq in broker_qty.items(): # unmatched broker position
+14 -2
View File
@@ -16,6 +16,7 @@ from collections.abc import Callable
from datetime import UTC, date, datetime, time, timedelta, timezone
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.auto_reconcile import AutoReconcileStore
from trex_core.application.balance_health import BalanceHealthTracker
from trex_core.application.broker_port import BrokerBalanceError, BrokerPort
from trex_core.application.portfolio import (
@@ -55,6 +56,8 @@ class SnapshotScheduler:
warn_store: ReconcileWarningStore | None = None,
notification_store: ReconcileNotificationStore | None = None,
unmanaged_position_store: UnmanagedPositionStore | None = None,
cost_basis_port=None,
auto_reconcile_store: AutoReconcileStore | None = None,
credential_fetch_enabled: bool = False,
kis_reinit_callback: Callable[[BrokerCredentialPayload], None] | None = None,
credential_refresh: Callable[[], object] | None = None,
@@ -81,6 +84,8 @@ class SnapshotScheduler:
# req-0007 / ac-0024: durable notification deduplication store (Optional).
self._notification_store = notification_store
self._unmanaged_position_store = unmanaged_position_store
self._cost_basis_port = cost_basis_port
self._auto_reconcile_store = auto_reconcile_store
# dcy-036 / REQ-113/114/115: credential fetch opt-in + break-glass detection.
# credential_fetch_enabled=False (default) → env-only, no portfolio-hub fetch.
self._credential_fetch_enabled: bool = credential_fetch_enabled
@@ -332,7 +337,7 @@ class SnapshotScheduler:
if self._slot_service is not None:
try:
# BAT-050 / REQ-103: pass warn_store for B-case durable warning.
self._run_core_holding_reconciliation_worker()
self._run_core_holding_reconciliation_worker(items)
except Exception: # noqa: BLE001
_log.exception("core-holding divergence reconcile failed")
self._last_fired_date = self._clock()
@@ -353,7 +358,7 @@ class SnapshotScheduler:
),
)
def _run_core_holding_reconciliation_worker(self) -> None:
def _run_core_holding_reconciliation_worker(self, items=None) -> None:
from trex_core.application.reconciliation import reconcile_core_holdings
self._run_balance_worker(
@@ -368,6 +373,13 @@ class SnapshotScheduler:
warn_store=self._warn_store, # BAT-050 / REQ-103
notification_store=self._notification_store, # req-0007 / ac-0024
unmanaged_position_store=self._unmanaged_position_store,
portfolio_items=(
list(items)
if items is not None
else [asset.item for asset in self._store.list_assets()]
),
cost_basis_port=self._cost_basis_port,
auto_reconcile_store=self._auto_reconcile_store,
),
)
+11
View File
@@ -327,6 +327,16 @@ class SlotService:
raise SlotTransitionError(
f"sell() requires holding state, got: {slot.status.value}"
)
# req-0011 / ac-0038: autonomous sells have no SellIntent, so persist
# the already-owned SELL identity or inherit the holding owner.
sell_event_owner = next(
(
value
for value in (slot.sell_strategy_id, slot.strategy_id)
if isinstance(value, str) and value.strip()
),
None,
)
self.transit_slot(
slot_id,
SlotState.HOLDING,
@@ -334,6 +344,7 @@ class SlotService:
reason=reason,
actor=actor,
limit_price=limit_price,
sell_strategy_id=sell_event_owner,
)
# ------------------------------------------------------------
+42 -13
View File
@@ -34,6 +34,7 @@ from trex_core.adapters.messaging import (
from trex_core.adapters.persistence.account_snapshot import (
PostgreSQLAccountSnapshotStore,
)
from trex_core.adapters.persistence.auto_reconcile import PostgreSQLAutoReconcileStore
from trex_core.adapters.persistence.ledger_skip_store import PostgreSQLLedgerSkipStore
from trex_core.adapters.persistence.migrate import apply_migrations
from trex_core.adapters.persistence.postgresql import (
@@ -202,6 +203,7 @@ class Daemon:
self.pause_store = PostgreSQLPauseStore(config.dsn)
# BAT-050 / REQ-102: durable reconcile-warning store ((slot_id, case_code) PK).
self.warn_store = PostgreSQLReconcileWarningStore(config.dsn)
self.auto_reconcile_store = PostgreSQLAutoReconcileStore(config.dsn)
# req-0007 / ac-0024: durable reconcile notification deduplication store.
self.notification_store = PostgreSQLReconcileNotificationStore(config.dsn)
self.unmanaged_position_store = PostgreSQLUnmanagedPositionStore(config.dsn)
@@ -286,6 +288,8 @@ class Daemon:
warn_store=self.warn_store, # BAT-050 / REQ-103: durable reconcile-warning
notification_store=self.notification_store, # req-0007 / ac-0024
unmanaged_position_store=self.unmanaged_position_store,
cost_basis_port=self.portfolio_read,
auto_reconcile_store=self.auto_reconcile_store,
credential_fetch_enabled=config.credential_fetch_enabled, # dcy-036 / REQ-113
credential_refresh=self.broker_provider.refresh_day_start_credential,
# dcy-036 / REQ-113 AC-2 / REQ-115 AC-1: KISBroker reinit callback.
@@ -382,28 +386,47 @@ class Daemon:
def _make_event_emitter(self):
def emit(event_type: str, slot, routing_strategy_id: str | None = None) -> None:
payload = slot_transition_event_payload(slot, event_type=event_type)
# routing_strategy_id is supplied when the transition clears
# slot.strategy_id (e.g. PENDING_SELL -> INACTIVE for slot_filled_sell).
# Without this hint the routing key would use "_" and the message would
# be NO_ROUTEd to the DLQ instead of reaching the strategy queue
# (M2 REL-009 feedback / slot_filled NO_ROUTE fix).
# routing_strategy_id is captured before transitions that clear owner
# fields (for SELL, the admission-owned sell_strategy_id). Missing
# SELL ownership is blocked below instead of synthesizing "_".
effective_strategy_id = (
routing_strategy_id or getattr(slot, "strategy_id", None) or "_"
routing_strategy_id or getattr(slot, "strategy_id", None)
)
# req-0011/ac-0039 defense in depth: application execution normally
# skips ownerless SELL events before this hook. Never synthesize the
# legacy "_" owner if a direct caller violates that boundary.
if (
event_type == "slot_filled_sell"
and not (
isinstance(effective_strategy_id, str)
and effective_strategy_id.strip()
)
):
_log.error(
"sell event publish blocked reason=sell_event_owner_missing "
"slot_id=%s symbol=%s event_type=%s",
getattr(slot, "slot_id", None) or getattr(slot, "id", None),
getattr(slot, "symbol", None),
event_type,
)
return
effective_strategy_id = effective_strategy_id or "_"
rk = topology.event_routing_key(
event_type, effective_strategy_id, slot.symbol or "_",
)
try:
self.event_publisher.publish_event(rk, payload)
except Exception: # noqa: BLE001 - publish failure must not roll back
# REQ-093: escalate to ERROR so operators can observe via log
# alert. Include routing key + slot identity for correlation.
except Exception as exc: # noqa: BLE001 - publish failure must not roll back
# req-0011/ac-0039: transport failures are observable without
# rendering raw exception text, traceback, broker payload, or
# connection details into logs.
_log.error(
"event publish failed (rk=%s slot_id=%s strategy=%s)",
"event publish failed reason=event_publish_failed "
"rk=%s slot_id=%s strategy=%s exc_type=%s",
rk,
getattr(slot, "slot_id", None) or getattr(slot, "id", None),
getattr(slot, "strategy_id", None),
exc_info=True,
type(exc).__name__,
)
return emit
@@ -429,8 +452,13 @@ class Daemon:
while not self.stop.wait(self.config.ledger_interval_sec):
try:
self.ledger_writer.run_once()
except Exception: # noqa: BLE001,S112
continue
except Exception as exc: # noqa: BLE001 - loop must survive transient errors
_log.error(
"ledger writer loop error: reason=ledger_writer_run_once_failed "
"instance_key=%s exc_type=%s",
self.config.instance_key,
type(exc).__name__,
)
def _intent_rpc_loop(self) -> None:
transport = _build_transport(self.config)
@@ -463,6 +491,7 @@ class Daemon:
watchdog_store=self.watchdog,
watchdog_timeout_sec=self.config.safety_sell.watchdog_timeout_sec,
ledger_outbox=self.outbox,
ledger_outbox_metrics_reader=self.ledger_writer,
max_active_slots=self.config.max_active_slots,
strategy_state_store=self.strategy_state_store, # dcy-028 C-1
broker_quantity_reader=self.account_snapshot_service,
Generated
+1 -1
View File
@@ -289,7 +289,7 @@ wheels = [
[[package]]
name = "trex-core"
version = "0.7.0"
version = "0.9.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },