Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f87350d415 | ||
|
|
903267189c | ||
|
|
a2be76b199 | ||
|
|
62e4f57c2b | ||
|
|
c66acf6e47 | ||
|
|
3f6a967aa1 | ||
|
|
db2bdd7d4a | ||
|
|
cd2342f2c6 |
@@ -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가 거부되는지 검증한다.
|
||||
@@ -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 불변성을 검증한다.
|
||||
@@ -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를 비교한다.
|
||||
@@ -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을 확인한다.
|
||||
@@ -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으로 명시하고 단위 검증을 통과로 대체 주장하지 않는다.
|
||||
@@ -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되면 통과로 치환하지 않고 환경 미검증으로 보고한다.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"next_line_number": 10,
|
||||
"next_ac_number": 35
|
||||
"next_line_number": 13,
|
||||
"next_ac_number": 41
|
||||
}
|
||||
|
||||
@@ -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 없음.
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
id: "line-0010"
|
||||
status: discovery
|
||||
---
|
||||
@@ -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 변경
|
||||
@@ -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`로 원래 결함을 재현했고 수집 오류 없이 종료했다.
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
id: "line-0011"
|
||||
status: discovery
|
||||
---
|
||||
@@ -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 추가
|
||||
@@ -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 확인.
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
id: "line-0012"
|
||||
status: discovery
|
||||
---
|
||||
@@ -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을 수행하지 않는다.
|
||||
@@ -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
@@ -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 실행 여부나 운영 정책 승인을 증명하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
운영팀은 release tag의 clean checkout에서 아래 명령으로 이미지를 빌드한다.
|
||||
|
||||
```bash
|
||||
git checkout v0.8.0
|
||||
./scripts/build_release_image.sh trex-core:0.8.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.8.0
|
||||
docker push registry.example/trex-core:0.8.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를
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "trex-core"
|
||||
version = "0.8.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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+50
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
|
||||
+38
-13
@@ -386,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
|
||||
|
||||
@@ -433,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)
|
||||
@@ -467,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,
|
||||
|
||||
Reference in New Issue
Block a user