Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f87350d415 | ||
|
|
903267189c | ||
|
|
a2be76b199 | ||
|
|
62e4f57c2b | ||
|
|
c66acf6e47 | ||
|
|
3f6a967aa1 | ||
|
|
db2bdd7d4a | ||
|
|
cd2342f2c6 | ||
|
|
bbdacb121c | ||
|
|
c70ac0fb24 | ||
|
|
adf131f19c | ||
|
|
3569d864c9 | ||
|
|
7dc2f65455 | ||
|
|
8378e03dcc | ||
|
|
619feff5f4 | ||
|
|
8d9d627b1c | ||
|
|
f3a6b2c410 | ||
|
|
a6ae800346 | ||
|
|
6dbc92bfa7 | ||
|
|
8577f54ae6 | ||
|
|
635a9a9f9f | ||
|
|
8b3e7ac753 | ||
|
|
5eef76d59e | ||
|
|
1f728f4d7d | ||
|
|
2b5d9043b9 | ||
|
|
19af864bf3 | ||
|
|
3d46485a62 | ||
|
|
8b1de8e26a | ||
|
|
84d81658a1 | ||
|
|
c73e382b70 | ||
|
|
591db7adb7 | ||
|
|
3cb0fdc303 | ||
|
|
762a953c95 | ||
|
|
60a835fde1 | ||
|
|
f1e718e468 | ||
|
|
853943f676 | ||
|
|
9c5a01ed67 | ||
|
|
4a2e7dd893 | ||
|
|
e3142017c9 | ||
|
|
084e05a97d | ||
|
|
f9284d1de8 | ||
|
|
be2e73a2d6 | ||
|
|
f801c53e0c | ||
|
|
5a13c0494b | ||
|
|
0b5b4660e5 | ||
|
|
310b6f6dec | ||
|
|
59bb492883 | ||
|
|
31d6e5c02b | ||
|
|
f2b825e720 | ||
|
|
09b1b4f4c3 | ||
|
|
fe51d211d9 | ||
|
|
45a5e4157d | ||
|
|
ae2e598209 | ||
|
|
304a06f97c | ||
|
|
e47bebacaf | ||
|
|
729abdd946 | ||
|
|
06c3f06b5d | ||
|
|
6017bde744 | ||
|
|
869664aa4a | ||
|
|
f3fbc179b0 | ||
|
|
584e08eb36 | ||
|
|
65df10542b | ||
|
|
f0ac909958 | ||
|
|
2b412fd786 | ||
|
|
16257e65a6 | ||
|
|
4b11e1eeb3 | ||
|
|
3854aabd0d | ||
|
|
4699de61cc | ||
|
|
3f94c19162 | ||
|
|
7af6bdcf93 | ||
|
|
ddf7d6710b | ||
|
|
dd2ae33ee1 | ||
|
|
2363d970c2 | ||
|
|
80cc6c0d73 | ||
|
|
81624931cf | ||
|
|
ac3f27241a | ||
|
|
75406af970 | ||
|
|
1ca9e60c98 | ||
|
|
5dd3d57f86 | ||
|
|
a9e88f9770 | ||
|
|
98c38327b2 | ||
|
|
2e314232e3 | ||
|
|
21f8fdd4b6 | ||
|
|
c246fca206 | ||
|
|
f1864a6aa8 | ||
|
|
153a563f6d | ||
|
|
baae02d2af | ||
|
|
8d0942fb30 | ||
|
|
24a64b55d1 | ||
|
|
f46c2efd8b | ||
|
|
49eb20bd51 | ||
|
|
1179bdfe23 | ||
|
|
d9205fe151 | ||
|
|
d18f098b27 | ||
|
|
d4a968ee1a | ||
|
|
f394f1560d | ||
|
|
30bcbe1d12 | ||
|
|
832dcb5fbb | ||
|
|
131d694ce4 | ||
|
|
169f222042 | ||
|
|
48dfc3e76f | ||
|
|
b88b21b772 | ||
|
|
76cde2edd0 | ||
|
|
df0258bc36 | ||
|
|
231e8e2eec | ||
|
|
8f74c6b67d | ||
|
|
3e74d35299 | ||
|
|
e2c4bbba34 | ||
|
|
f124e9509c | ||
|
|
fbfe026a7d | ||
|
|
9da8c27e52 | ||
|
|
f8ad591f0d | ||
|
|
43f6fea7ac | ||
|
|
64e4e32f9d | ||
|
|
4975d7166f | ||
|
|
359ea098ff | ||
|
|
7366cadf03 | ||
|
|
15aeae6930 | ||
|
|
be98965f2c | ||
|
|
7dadc3dd2d | ||
|
|
03e50dbc26 | ||
|
|
5c9c5ca0ca | ||
|
|
e3fa8243d9 | ||
|
|
cb8fcee226 | ||
|
|
1a2b500722 | ||
|
|
843df1d6f1 | ||
|
|
147873cb5d | ||
|
|
482b00e54d | ||
|
|
59e75c369f |
@@ -1,130 +0,0 @@
|
||||
name: Publish verified container image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out exact source commit
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install repository checks
|
||||
run: |
|
||||
python -m pip install "uv==0.11.14"
|
||||
uv sync --extra dev --locked
|
||||
|
||||
- name: Run repository lint
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Run repository tests
|
||||
run: uv run pytest
|
||||
|
||||
- name: Create excluded synthetic canary
|
||||
run: |
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
Path(".env.ci-canary").write_text("TREX_SECRET_CANARY_DO_NOT_SHIP\n", encoding="ascii")
|
||||
PY
|
||||
|
||||
- name: Build source image once
|
||||
run: |
|
||||
docker build \
|
||||
--provenance=false \
|
||||
--build-arg "SOURCE_COMMIT=${GITHUB_SHA}" \
|
||||
--tag "trex-core-ci:${GITHUB_SHA}" \
|
||||
.
|
||||
|
||||
- name: Smoke built image
|
||||
run: python scripts/image_release.py smoke --image "trex-core-ci:${GITHUB_SHA}"
|
||||
|
||||
- name: Record tested image ID
|
||||
run: |
|
||||
python scripts/image_release.py record-image-id \
|
||||
--image "trex-core-ci:${GITHUB_SHA}" \
|
||||
--output trex-core-image.id
|
||||
|
||||
- name: Export tested image
|
||||
run: docker save --output trex-core-image.tar "trex-core-ci:${GITHUB_SHA}"
|
||||
|
||||
- name: Scan all canary surfaces
|
||||
run: |
|
||||
set +e
|
||||
python scripts/image_release.py scan \
|
||||
--image "trex-core-ci:${GITHUB_SHA}" \
|
||||
--archive trex-core-image.tar \
|
||||
>canary-scan.stdout 2>canary-scan.stderr
|
||||
scan_status=$?
|
||||
set -e
|
||||
python scripts/image_release.py assert-clean-output \
|
||||
canary-scan.stdout canary-scan.stderr
|
||||
exit "${scan_status}"
|
||||
|
||||
- name: Record tested archive checksum
|
||||
run: |
|
||||
python scripts/image_release.py checksum \
|
||||
--archive trex-core-image.tar \
|
||||
--output trex-core-image.tar.sha256
|
||||
|
||||
- name: Upload immutable tested image artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tested-image-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
if-no-files-found: error
|
||||
overwrite: false
|
||||
retention-days: 7
|
||||
path: |
|
||||
trex-core-image.tar
|
||||
trex-core-image.tar.sha256
|
||||
trex-core-image.id
|
||||
scripts/image_release.py
|
||||
|
||||
publish:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
image: ${{ steps.publish.outputs.image }}
|
||||
digest: ${{ steps.publish.outputs.digest }}
|
||||
source_commit: ${{ steps.publish.outputs.source_commit }}
|
||||
steps:
|
||||
- name: Download tested image artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tested-image-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: tested-image
|
||||
|
||||
- name: Verify, load, and publish without rebuilding
|
||||
id: publish
|
||||
env:
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python tested-image/scripts/image_release.py publish \
|
||||
--archive tested-image/trex-core-image.tar \
|
||||
--checksum tested-image/trex-core-image.tar.sha256 \
|
||||
--image-id tested-image/trex-core-image.id \
|
||||
--local-image "trex-core-ci:${GITHUB_SHA}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--ref "${GITHUB_REF}"
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
id: "ac-0004"
|
||||
status: active
|
||||
status: retired
|
||||
---
|
||||
|
||||
# 검증 성공 이미지에 한정된 GHCR 게시
|
||||
# strict version-tag 검증 성공 이미지에 한정된 GHCR 게시
|
||||
|
||||
## Criterion
|
||||
|
||||
`main` 또는 `v*` tag push의 정확한 source commit에 대해 repository가 정의한 검증 gate, image build, built-container smoke와 tested-image artifact 생성·전달·checksum·load 검증이 모두 성공한 경우에만 GitHub Actions가 registry login/push 단계에 진입해야 한다. 이 push 전 gate 중 하나라도 실패하면 registry write 없이 fail-closed 해야 한다. push 이후에만 반환되는 digest가 누락되거나 형식이 잘못되면 job은 nonzero로 종료하고 성공 output·summary를 남기지 않아야 하며, 이미 발생한 registry write를 발생하지 않은 것으로 보고해서는 안 된다.
|
||||
GitHub Actions publication workflow는 strict `vX.Y.Z` tag push만 후보로 받아야 한다. 각 숫자 구성요소는 `0` 또는 leading zero 없는 non-negative decimal integer여야 하며 branch, non-push event, malformed tag, prerelease/build suffix를 거부해야 한다. workflow는 event·ref·40자리 lowercase `${GITHUB_SHA}`를 검증하고 exact source checkout 뒤 실제 `HEAD` 일치를 재검증한 다음, pinned `uv`와 repository `uv.lock`을 사용하는 full Ruff·full pytest, single image build, built-container smoke, tested image ID, archive checksum, canary scan과 immutable artifact handoff가 모두 성공한 경우에만 publish job이 registry credential과 `packages: write`를 사용해야 한다. 이 push 전 gate 중 하나라도 실패하면 registry login/push 없이 fail-closed 해야 한다. push 이후 digest 누락·형식 오류 또는 tag 간 digest 불일치는 nonzero로 종료하고 성공 output·summary를 남기지 않아야 하며, 이미 발생한 registry write를 발생하지 않은 것으로 보고해서는 안 된다. 일반 `main`/branch push는 이 workflow에서 verification이나 publication을 실행하지 않으며 별도 비게시 CI workflow는 이 criterion의 요구사항이 아니다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: synthetic workflow fixture에서 모든 push 전 gate가 성공한 경우에만 publish job의 login/push가 실행 가능하고, push가 반환한 유효한 digest가 확인된 경우에만 성공 output과 summary가 생성된다.
|
||||
- 실패·경계 조건: repository 검증, build, smoke, artifact 생성·upload·download, checksum 비교 또는 load 실패는 login/push 전 nonzero로 종료된다. push 후 digest 누락·형식 오류는 job nonzero와 성공 output·summary 부재로 이어진다.
|
||||
- 검사 방법: workflow parser 기반 구조 테스트와 publication helper의 recording-runner 단위 테스트로 push 전/후 실패 시점을 분리해 검증하고, `publish.needs` 및 `packages: write`의 publish-job 한정을 정적 검사한다. 실제 최초 GHCR 실행은 별도 승인된 `main` 반영 이후 integration/release evidence로 확인한다.
|
||||
- 성공 조건: synthetic strict tag-push fixture에서 exact checkout 재검증, full repository verification, single build, smoke, tested-artifact 생성·전달·checksum·load 검증을 모두 통과해야 publish job의 login/push가 실행 가능하고, 두 push가 같은 유효 digest를 반환한 뒤에만 성공 evidence가 생성된다.
|
||||
- 실패·경계 조건: non-push, branch, malformed/leading-zero/prerelease/build tag, invalid source commit, checkout `HEAD` mismatch, repository 검증·build·smoke·artifact·checksum·load 실패는 registry credential/write 전에 nonzero로 종료된다. post-push digest 오류·불일치는 nonzero와 성공 evidence 부재로 이어진다.
|
||||
- 검사 방법: `tests/release/test_image_contracts.py`, `tests/release/test_image_release.py`의 workflow parser·recording-runner tests로 trigger, guard 순서, exact checkout, `publish.needs`, publish-job-only `packages: write`, registry write 전후 실패 경계를 검증하고 전체 Ruff·pytest를 실행한다. 실제 tag push·GHCR write는 별도 승인된 publication evidence로 확인한다.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: "ac-0005"
|
||||
status: active
|
||||
status: retired
|
||||
---
|
||||
|
||||
# 검증 이미지와 게시 이미지의 동일성
|
||||
@@ -13,4 +13,4 @@ verify job은 한 번 빌드하여 built-container smoke를 통과한 `trex-core
|
||||
|
||||
- 성공 조건: smoke가 성공한 local image를 export한 archive의 SHA-256과 publish job이 download한 archive의 SHA-256이 일치하고, checksum 확인 뒤 load된 image가 GHCR tag의 유일한 원본이 된다.
|
||||
- 실패·경계 조건: archive 또는 checksum 누락, archive byte 변조, checksum 불일치, load 실패, publish job의 checkout·build 또는 별도 source-based image 생성은 registry login/push 전 nonzero로 종료된다.
|
||||
- 검사 방법: workflow 구조 테스트로 smoke→save→checksum→upload 및 download→checksum compare→load→tag→push 순서를 검사하고, recording-runner 단위 테스트에서 원본·변조 archive fixture를 사용해 mismatch 시 load/login/push가 호출되지 않음을 검증한다.
|
||||
- 검사 방법: workflow 구조 테스트로 smoke→save→checksum→upload 및 download→checksum compare→load→tag→push 순서를 검사하고, recording-runner 단위 테스트에서 원본·변조 archive fixture를 사용해 mismatch 시 load/login/push가 호출되지 않음을 검증한다.
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
id: "ac-0006"
|
||||
status: active
|
||||
status: retired
|
||||
---
|
||||
|
||||
# stable SHA tag와 immutable digest 추적성
|
||||
# requester version tag·source tag와 immutable digest 추적성
|
||||
|
||||
## Criterion
|
||||
|
||||
모든 성공 게시 이미지는 전체 40자리 lowercase hexadecimal source commit에서 결정적으로 파생된 앞 12자리 lowercase SHA tag와 전체 commit 값을 담은 `org.opencontainers.image.revision` label을 가져야 한다. SHA tag는 source를 찾기 위한 stable selector이며 배포·rollback의 불변 정본은 registry가 반환한 `sha256` digest다. `v*` tag event에서는 같은 loaded image에 해당 Git tag를 추가하지만 이동 가능한 `latest` tag는 게시하지 않아야 하며, workflow output 또는 summary는 GHCR image reference, 64-hex digest와 전체 source commit을 함께 기록해야 한다.
|
||||
모든 성공 게시 이미지는 전체 40자리 lowercase hexadecimal source commit에서 결정적으로 파생된 앞 12자리 lowercase SHA tag, requester가 선택한 strict `vX.Y.Z` version tag와 전체 commit 값을 담은 `org.opencontainers.image.revision` label을 가져야 한다. version tag는 container publication authority이며 `pyproject.toml` package version과 독립적으로 취급한다. source tag와 version tag는 별도 build 없이 동일하게 검증·load된 image에 부여하고 registry가 반환한 동일 `sha256` digest를 가리켜야 한다. 이동 가능한 `latest` tag는 게시하지 않아야 하며, 성공 output·summary는 immutable GHCR image reference, 64-hex digest, 전체 source commit과 selected version tag를 함께 기록해야 한다. source tag는 source를 찾기 위한 stable selector이고 배포·rollback의 불변 정본은 registry digest다. 이미 게시된 source tag, version tag와 digest는 이 publication flow가 삭제·이동·재지정하지 않아야 한다. operator documentation은 Git version tag가 container publication authority이고 package metadata version과 독립임을 설명하며, version 선택과 tag 생성·push가 구현·검증과 분리된 requester-approved external write임을 명시해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: `main` event는 source commit 앞 12자리 SHA tag를, `v*` event는 같은 loaded image의 SHA tag와 release tag를 생성한다. OCI revision과 summary/output의 image reference·`sha256` digest·전체 source commit이 해당 실행 입력 및 push 결과와 일치한다.
|
||||
- 실패·경계 조건: source commit 형식 오류, SHA tag 파생 불일치, 빈/형식 오류 digest, source commit 불일치, release tag용 별도 build, `latest` tag 생성 또는 OCI revision 누락은 성공 output·summary를 만들 수 없다. digest 오류가 push 이후 발견되면 job은 nonzero로 종료한다.
|
||||
- 검사 방법: synthetic `main`/`v*` event와 유효·무효 commit/digest를 publication helper에 입력하는 단위 테스트, Docker image label inspection, workflow의 tag/summary 정적 검사 및 `latest` 생성 금지 회귀 검사를 실행한다.
|
||||
- 성공 조건: strict `vX.Y.Z` tag event는 같은 loaded image에 `:<source-commit-first-12>`와 `:vX.Y.Z`를 만들고 두 push가 동일한 digest를 반환한다. OCI revision과 output·summary의 immutable image reference, digest, source commit, version tag가 event와 push 결과에 정확히 일치하며 package version 값은 tag 생성·검증에 사용되지 않는다. 운영 문서는 version authority·package version 독립성·별도 승인 tag push 경계를 모두 설명하고 기존 published identity를 보존한다.
|
||||
- 실패·경계 조건: source commit 또는 version tag 형식 오류, SHA tag 파생 불일치, version/package version 일치 강제, tag별 별도 build, `latest` 생성, OCI revision 불일치, digest 누락·형식 오류·tag 간 불일치가 있으면 성공 evidence를 생성할 수 없다. 기존 published tag·digest의 삭제·이동·재지정 또는 operator documentation의 version authority·별도 승인 경계 누락도 FAIL이다. push 이후 오류는 job nonzero로 유지한다.
|
||||
- 검사 방법: `scripts/image_release.py`의 trigger/tag/publish helper 단위 테스트와 workflow contract test로 deterministic tag, same-loaded-image, no rebuild, same digest, no-`latest`와 output·summary exactness를 검증하고 Docker image label을 inspect한다. `docs/operations.md` contract test로 Git tag authority, package version 독립성과 requester-approved tag creation/push 경계를 검사하고, workflow·helper가 기존 published registry identity를 삭제·이동·재지정하는 command를 포함하지 않는지 정적 검사한다. 실제 registry의 두 tag·digest와 기존 identity 보존은 별도 승인된 publication run에서 immutable evidence로 확인한다.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: "ac-0007"
|
||||
status: active
|
||||
status: retired
|
||||
---
|
||||
|
||||
# built-container package 및 entrypoint smoke
|
||||
@@ -13,4 +13,4 @@ GHCR 게시 후보는 source tree가 아니라 실제로 빌드된 image 안에
|
||||
|
||||
- 성공 조건: network가 차단되고 운영 config·credential이 없는 최소권한 container에서 기존 네 probe와 installed migration inventory probe가 각각 정해진 timeout 안에 exit code `0`으로 끝나며 container가 정리된다. 현재 회귀 기준선에는 canonical SQL 파일 20개가 있으나, 영구 성공 조건은 고정 수량이 아니라 해당 source commit의 non-empty exact inventory parity이다.
|
||||
- 실패·경계 조건: package import 실패, entrypoint 누락, installed migration inventory의 empty·누락·추가·filename mismatch, nonzero exit, timeout, 외부 연결 시도 또는 cleanup 실패는 smoke nonzero로 이어지고 image publish를 차단한다. `trex-core-reset` probe에는 `--yes` 또는 config argument를 전달하지 않는다.
|
||||
- 검사 방법: built wheel의 SQL filename set을 source-controlled canonical set과 대조하고, built image를 대상으로 하는 smoke harness와 recording-runner 단위 테스트로 정확한 기존 네 command와 side-effect-free installed inventory probe, per-command timeout, network 차단, no-config/no-secret 환경, failure propagation 및 cleanup을 검증하며 CI에서 실제 local image smoke를 실행한다. Inventory probe는 migration을 적용하거나 DB 또는 다른 service에 연결하지 않는다.
|
||||
- 검사 방법: built wheel의 SQL filename set을 source-controlled canonical set과 대조하고, built image를 대상으로 하는 smoke harness와 recording-runner 단위 테스트로 정확한 기존 네 command와 side-effect-free installed inventory probe, per-command timeout, network 차단, no-config/no-secret 환경, failure propagation 및 cleanup을 검증하며 CI에서 실제 local image smoke를 실행한다. Inventory probe는 migration을 적용하거나 DB 또는 다른 service에 연결하지 않는다.
|
||||
@@ -3,14 +3,14 @@ id: "ac-0008"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 운영 설정과 자격증명의 이미지 비포함
|
||||
# locked runtime build input과 운영 설정·자격증명의 이미지 비포함
|
||||
|
||||
## Criterion
|
||||
|
||||
`trex-core` Dockerfile은 package 설치에 필요한 `pyproject.toml`과 `trex_core/`만 application source로 copy하고 repository 전체를 copy하지 않아야 한다. `.dockerignore`는 최소한 `.git`, `.env`, `.env.*`, `config/`와 `.proofline/`을 build context에서 제외해야 한다. image filesystem·history·inspect metadata와 export archive에는 운영 per-instance TOML, `.env`, credential, token, private key, 실제 connection string 또는 synthetic canary marker가 없어야 한다. daemon의 필수 config와 secret은 runtime에 read-only mount, secret 또는 environment로만 주입하고, 문서는 digest pin과 이전 digest rollback을 안내하며 `trex-core-reset`을 자동 rollback 절차에 포함하지 않아야 한다.
|
||||
`trex-core` Dockerfile은 runtime dependency 설치에 필요한 pinned `uv` tool, `pyproject.toml`, repository-owned `uv.lock`과 allowlisted `trex_core/` package source만 application build input으로 copy하고 repository 전체를 copy하지 않아야 한다. runtime environment는 lockfile을 실제 소비하는 `uv sync --locked --no-dev --no-editable` 또는 의미상 동등한 명령으로 생성해야 한다. `.dockerignore`는 최소한 `.git`, `.env`, `.env.*`, `config/`와 `.proofline/`을 build context에서 제외해야 한다. image filesystem·history·inspect metadata와 export archive에는 운영 per-instance TOML, `.env`, credential, token, private key, 실제 connection string 또는 synthetic canary marker가 없어야 한다. 필수 config와 secret은 runtime에 read-only mount, secret 또는 environment로만 주입하고, 문서는 digest pin과 이전 digest rollback을 안내하며 `trex-core-reset`을 자동 rollback 절차에 포함하지 않아야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 임시 build context의 제외 대상 `.env.ci-canary`에 고정 marker `TREX_SECRET_CANARY_DO_NOT_SHIP`을 둔 상태로 이미지를 빌드해도 marker가 image filesystem, `docker history --no-trunc`, `docker image inspect` 결과와 exported image archive 어디에서도 발견되지 않는다. Dockerfile에는 `COPY .`가 없고 runtime 문서는 외부 주입과 digest rollback 경계를 설명한다.
|
||||
- 실패·경계 조건: 필수 ignore path 누락, repository 전체 copy, synthetic canary 또는 금지 파일·값이 검사 surface에 존재하면 FAIL이다. helper의 locally captured stdout/stderr에 canary가 노출되거나 rollback 안내가 DB reset·자동 data mutation을 실행하도록 제시해도 FAIL이다.
|
||||
- 검사 방법: Dockerfile·`.dockerignore` contract test, synthetic canary가 있는 임시 context의 실제 image build, filesystem/history/inspect/export archive scan, helper의 local output 검사와 runtime documentation contract test를 실행한다. 실제 credential이나 실제 운영 connection string은 fixture·명령·evidence에 사용하지 않는다.
|
||||
- 성공 조건: Dockerfile의 copy/install allowlist에 pinned `uv`, `pyproject.toml`, `uv.lock`, package source만 존재하고 locked runtime sync가 성공한다. 제외 대상 `.env.ci-canary`에 고정 marker `TREX_SECRET_CANARY_DO_NOT_SHIP`을 둔 build에서도 marker가 filesystem, `docker history --no-trunc`, `docker image inspect`와 exported archive 어디에도 없으며 runtime 문서는 외부 주입과 digest rollback 경계를 설명한다.
|
||||
- 실패·경계 조건: missing/mismatched lockfile, unlocked `pip install .` 등 lock 우회, 필수 ignore 누락, broad `COPY .`, synthetic canary 또는 금지 파일·값의 검사 surface 노출은 FAIL이다. helper stdout/stderr에 canary가 노출되거나 rollback 안내가 DB reset·자동 data mutation을 포함해도 FAIL이다.
|
||||
- 검사 방법: `tests/release/test_image_contracts.py`의 Dockerfile·`.dockerignore`·documentation contract tests, 의도적 `pyproject.toml`/`uv.lock` mismatch build, synthetic canary가 있는 실제 image build, filesystem/history/inspect/export archive scan과 helper local-output 검사를 실행한다. 실제 credential이나 운영 connection string은 fixture·명령·evidence에 사용하지 않는다.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0009"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 주문 timeout 기본값과 입력 검증
|
||||
|
||||
## Criterion
|
||||
|
||||
매수·매도 intent가 `timeout_sec`를 생략하거나 `null`로 전달하면 인스턴스의 `default_order_timeout_sec`를 사용하고, 인스턴스 기본값도 없으면 hard fallback `3600`초를 effective timeout으로 저장해야 한다. 전략 timeout과 인스턴스 기본값은 bool·문자열·실수를 포함한 비정수 값을 허용하지 않는 strict integer `1..3600` 범위여야 한다. 전략 입력이 이 계약을 위반하면 clamp 또는 주문 제출 없이 `bad_request`로 거부하고, 인스턴스 설정이 위반하면 daemon이 주문 실행 전에 설정 오류로 시작에 실패해야 한다. 경계값 `1`과 `3600`은 매수·매도 모두 허용해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 매수·매도에서 생략과 `null`이 instance override 또는 `3600`으로 저장되고, explicit integer `1`, 중간값과 `3600`이 그대로 승인된다.
|
||||
- 실패·경계 조건: bool, 문자열, 실수, `0`, 음수와 `3601` 이상은 묵시적 변환·clamp·slot admission·broker submission 없이 거부되며, 잘못된 daemon 설정은 startup config error가 된다.
|
||||
- 검사 방법: `tests/application/test_order_timeout_reconciliation.py`의 direct/wire/config 경계 테스트와 `tests/application/test_execution_policy_handler.py`, `tests/application/test_execution_policy.py`의 매수·매도 회귀를 실행한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0010"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 원주문 timeout 기준점과 주문일 영속성
|
||||
|
||||
## Criterion
|
||||
|
||||
주문 dispatch 시 effective `timeout_sec`, immutable UTC `order_submitted_at`과 그 시각에서 산출한 KST `order_date`를 슬롯에 원자적으로 영속화해야 한다. 일반 `updated_at` 변경, polling, cancel bookkeeping과 broker order ID resolution은 이 anchor를 변경하지 않아야 하며 terminal 전이에서만 정리해야 한다. daemon 재시작과 KST 날짜 rollover 뒤에도 저장된 제출 시각으로 동일 deadline을 계산하고 저장된 원주문 날짜로 KIS 주문을 조회해야 한다. migration 이전 legacy pending row의 원주문 metadata가 없으면 `created_at`·`updated_at` 또는 현재 날짜로 추정하지 않고 fail-closed unresolved로 남겨야 하며, in-memory와 PostgreSQL 저장소는 같은 column·CAS·보존 동작을 제공해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: dispatch, cancel outcome, `replace_dispatch`, 반복 poll과 repository 재생성 뒤에도 anchor가 동일하고, 다음 KST 날짜의 poll도 원주문 날짜를 조회한다. terminal 전이에서는 anchor와 cancel metadata가 계약대로 정리된다.
|
||||
- 실패·경계 조건: migration이 legacy row에 날짜를 backfill하거나 metadata 없는 일반 주문이 현재 날짜 KIS 조회·terminal 전이·cancel·ledger 기록을 수행하면 FAIL이다. mutable timestamp가 deadline 또는 조회일을 이동시켜도 FAIL이다.
|
||||
- 검사 방법: migration SQL discovery, `tests/application/test_order_timeout_reconciliation.py`의 migration·restart·rollover·legacy adversarial tests, `tests/adapters/persistence/test_postgresql_slot_repository_parity.py`와 PostgreSQL integration test를 실행한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0011"
|
||||
status: active
|
||||
---
|
||||
|
||||
# timeout 취소와 terminal 주문상태 권위
|
||||
|
||||
## Criterion
|
||||
|
||||
주문의 effective timeout 도달은 슬롯을 강제 terminal state로 전환하는 조건이 아니라 취소/reconciliation을 시작하는 조건이어야 한다. 실행 루프는 취소 전에 원주문 날짜로 최신 주문 상태를 조회하고, 이미 전량체결·전량취소·terminal reject가 확인되면 해당 terminal 경로를 우선 적용해야 한다. cancel API의 `REQUESTED`, `REJECTED`, `UNKNOWN`은 모두 nonterminal이며, 슬롯의 최종 상태와 체결수량은 KIS 주문조회 terminal evidence 및 broker 잔고에 의해서만 확정해야 한다. 주문 row 없음은 bounded visibility grace 또는 그 이후 unresolved로 구분하되 어느 경우에도 row 부재만으로 terminal 상태를 추론하지 않아야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: timeout 이전에는 cancel이 없고, timeout 이후 nonterminal 주문에만 cancel을 요청한다. cancel과 전량체결 경합에서는 체결 경로가 우선하며 cancel 응답만으로 슬롯이 해제되지 않는다.
|
||||
- 실패·경계 조건: 로컬 timeout, cancel `REQUESTED/REJECTED/UNKNOWN`, HTTP transport 결과, KIS message code 또는 주문 row 부재가 단독으로 슬롯 terminal 전이·보유량 0·ledger 체결을 만들면 FAIL이다.
|
||||
- 검사 방법: `tests/application/test_order_timeout_reconciliation.py`, `tests/application/test_execution_cancel.py`, `tests/adapters/broker/test_kis_cancel.py`와 KIS submission recovery virtual integration을 실행하여 poll-before-cancel, original-date authority, visibility grace와 full-fill race를 검증한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0012"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 미확정 취소의 fail-closed 재조정과 경보
|
||||
|
||||
## Criterion
|
||||
|
||||
cancel `REQUESTED`, `REJECTED`, `UNKNOWN` 또는 visibility grace 이후 terminal 미확정 상태에서는 슬롯을 pending/reconciliation으로 유지하고 daemon 재시작 뒤에도 authoritative polling을 계속해야 한다. `REQUESTED`는 blind retry하지 않아야 하며, `REJECTED` 또는 `UNKNOWN` 재시도는 KIS 조회가 주문을 여전히 open으로 확인한 경우에만 CAS 보호된 최대 시도 횟수와 bounded exponential backoff 안에서 허용해야 한다. `REJECTED`·`UNKNOWN`이 terminal 미확정이거나, `REQUESTED` 후 authoritative poll 결과가 `unknown` 또는 `order_not_visible_unresolved`이면 CRITICAL alarm으로 알려야 한다. 동일 프로세스의 같은 slot·state·attempt alarm은 중복 폭주를 막고 새 프로세스에서는 재발행할 수 있어야 한다. alarm은 allowlist metadata만 포함하고 raw KIS payload, exception·credential·token·connection string을 포함하지 않아야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: rejected/unknown이 pending을 유지하고 CRITICAL alarm을 발행하며, open-confirmed evidence와 backoff·attempt 조건을 모두 만족한 경우에만 한정 재시도가 일어난다. requested 후 `unknown` 또는 `order_not_visible_unresolved` 반복 poll의 alarm은 프로세스 내 1회로 dedupe되고 restart 모사에서는 재발행된다. requested 상태에서 주문이 open으로 확인된 경우에는 pending과 no-retry를 유지하되 unresolved alarm을 요구하지 않는다.
|
||||
- 실패·경계 조건: open evidence 없는 accepted/missing/unknown에서 추가 cancel이 발생하거나, requested를 재시도하거나, 최대 시도·CAS를 우회하거나, alarm에 raw payload·민감 fixture가 포함되거나, 미확정 주문의 슬롯이 재사용 가능 상태가 되면 FAIL이다.
|
||||
- 검사 방법: `tests/application/test_order_timeout_reconciliation.py`의 requested dedupe/restart 및 rejected/unknown open-confirmed bounded retry tests와 alarm emitter contract를 실행하고 rendered log·alarm fields를 점검한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0013"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 장마감 자동취소와 체결 경합의 슬롯·원장 정합성
|
||||
|
||||
## Criterion
|
||||
|
||||
KIS 주문조회가 장마감 자동취소의 terminal evidence를 제공하면 무체결 초기 매수는 `PENDING_BUY → INACTIVE`, 무체결 증액 매수는 broker 기존 보유량 기준 `PENDING_BUY → HOLDING`으로 전환해야 한다. 매수 부분체결 후 잔량취소는 체결분을 deterministic execution identity로 ledger에 exactly-once 기록하고 broker 잔고 기준으로 `HOLDING`을 확정해야 한다. 매도 무체결 또는 부분체결 후 잔량취소는 broker 잔고에 따라 `HOLDING` 또는 기존 terminal sell 경계를 적용해야 하며, 취소 flag와 전량체결이 경합하면 전량체결 경로를 우선해야 한다. 반복 polling·outbox 재처리·daemon 재시작은 슬롯수량이나 ledger 체결을 중복시키지 않아야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 초기/증액 매수 무체결, 매수·매도 부분체결, 전량체결 race가 각각 정해진 슬롯 상태·broker-authoritative 수량·ledger 결과를 만들고 동일 execution의 반복 처리에도 ledger item은 하나다.
|
||||
- 실패·경계 조건: 부분체결을 전량취소로 소실하거나, 전량체결을 취소로 rollback하거나, 로컬 추정수량을 broker 잔고보다 우선하거나, 동일 체결을 중복 ledger 기록하거나, terminal evidence 없이 상태를 확정하면 FAIL이다.
|
||||
- 검사 방법: `tests/application/test_execution_cancel.py`, `tests/application/test_execution_ledger.py`, `tests/adapters/broker/test_kis_cancel.py`와 `tests/application/test_order_timeout_reconciliation.py`를 실행하여 zero/partial/full race, broker balance authority와 deterministic ledger idempotency를 검증한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0014"
|
||||
status: active
|
||||
---
|
||||
|
||||
# repository-owned lockfile 기반 runtime dependency identity
|
||||
|
||||
## Criterion
|
||||
|
||||
repository verification environment와 published container의 runtime environment는 같은 revision의 repository-owned `uv.lock` dependency resolution을 사용해야 한다. version-tag verification은 pinned `uv`로 `uv sync --extra dev --locked`를 수행해야 하고, Docker runtime stage는 같은 `pyproject.toml`과 `uv.lock`을 입력으로 `uv sync --locked --no-dev --no-editable` 또는 의미상 동등한 runtime-only locked installation을 수행해야 한다. lockfile이 없거나 project metadata와 불일치하거나 install command가 lock resolution을 우회하면 image build와 publication은 fail-closed 해야 한다. Git publication tag와 Python package metadata version의 독립성은 이 lock identity를 변경하지 않는다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 동일 source revision의 `pyproject.toml`과 `uv.lock`으로 dev verification sync와 runtime-only image sync가 모두 성공하며 built image에서 `trex_core` import, `trex-core-daemon`, `trex-core`, `trex-core-reset` CLI와 canonical SQL migration inventory가 network 없이 동작한다.
|
||||
- 실패·경계 조건: missing lockfile, project metadata/lock mismatch, `--locked` 누락, runtime dependency를 새로 resolve하는 install, dev dependency의 runtime 포함 또는 editable install은 build/publication 전에 FAIL이다. tag version과 package version 불일치 자체는 실패 조건이 아니다.
|
||||
- 검사 방법: workflow·Dockerfile contract tests로 pinned `uv`와 두 locked sync command를 검사하고, 정상 lockfile의 실제 Docker build·built-container smoke와 의도적으로 불일치시킨 `pyproject.toml`/`uv.lock` build failure를 실행한다. release 집중 테스트와 full repository regression을 함께 실행한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0015"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 명시적 sell 미수락·미체결 상태 수렴
|
||||
|
||||
## Criterion
|
||||
|
||||
KIS submission 또는 authoritative order lookup이 sell 주문의 미수락·미체결을 명시적으로 확인하면 core는 실패 evidence를 기록하고 해당 dispatch를 정리하며 새 broker attempt를 생성하지 않고 slot을 broker-authoritative 보유수량의 `holding`으로 수렴시켜야 한다. 이 경로는 로컬 추정수량으로 보유량을 변경하거나 strategy에 broker retry 판단을 요구해서는 안 된다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: `pending_sell`에서 명시적 rejection 또는 lookup-confirmed absence가 발생하면 기존 보유수량을 유지한 `holding`으로 전이하고 dispatch metadata가 정리되며 broker submit 호출은 한 번뿐이다.
|
||||
- 실패·경계 조건: 체결 가능성이 남은 unknown outcome을 rejection으로 취급하거나, 수량을 0으로 만들거나, 자동 새 주문을 제출하거나, broker-authoritative 수량과 다른 값으로 수렴하면 FAIL이다.
|
||||
- 검사 방법: `tests/application/test_execution_policy.py`, `tests/application/test_order_timeout_reconciliation.py`와 관련 repository transition tests에서 상태·수량·dispatch·broker call count를 단언하고 전체 회귀를 실행한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0016"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 결과 불명확 submission의 중복 제출 방지와 reconciliation
|
||||
|
||||
## Criterion
|
||||
|
||||
주문 submission timeout, 연결 단절, 응답 누락 또는 응답 해석 실패로 broker acceptance 여부가 불명확하면 core는 original dispatch identity와 결과 불명확 sentinel을 durable하게 보존하고 새 broker order를 제출하지 않아야 한다. daemon 재시작과 반복 tick에서도 original order date·client identity를 사용한 broker lookup과 reconciliation을 먼저 수행하고 authoritative terminal evidence가 생기기 전에는 slot을 재사용 가능한 상태로 전환하지 않아야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: submit 결과 불명확 후 동일 slot의 반복 tick과 restart 모사에서 broker submit call count가 증가하지 않고 lookup/reconciliation이 실행되며 durable dispatch metadata가 유지된다.
|
||||
- 실패·경계 조건: timeout 자체로 terminal reject·holding·inactive를 추론하거나, sentinel을 지우거나, 새 broker attempt를 만들거나, 현재 날짜·wall-clock 값으로 original order identity를 대체하면 FAIL이다.
|
||||
- 검사 방법: `tests/application/test_order_timeout_reconciliation.py`의 timeout, restart, order visibility와 lookup fixture에서 call ordering·dispatch persistence·상태를 검증한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0017"
|
||||
status: active
|
||||
---
|
||||
|
||||
# strategy·TVR과 broker failure ownership 분리
|
||||
|
||||
## Criterion
|
||||
|
||||
KIS failure, raw broker error, broker attempt identity, retryability, retry schedule과 retry budget은 `trex-core` 내부 execution/reconciliation 경계를 벗어나 strategy 또는 TVR wire에 노출되어서는 안 된다. core도 broker failure를 retryable/non-retryable로 분류하여 자동 새 주문 attempt를 생성해서는 안 되며 strategy/TVR은 core가 수렴한 slot snapshot만 소비해야 한다. 이후 strategy가 독립적으로 생성한 별도 intent는 core의 자동 retry와 구분한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: sell response와 strategy-facing schema에 retryability·KIS error·attempt field가 없고 explicit reject 및 unknown outcome에서 자동 resubmit이 발생하지 않는다.
|
||||
- 실패·경계 조건: raw KIS payload나 retryable boolean이 strategy/TVR wire에 나타나거나, core가 retry schedule·budget을 산출하거나, 동일 intent에서 새 broker attempt를 자동 생성하면 FAIL이다.
|
||||
- 검사 방법: `tests/application/test_sell_retryable_flag.py`, messaging handler tests, execution rejection·unknown tests와 schema/document contract 검색을 실행한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0018"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 주문 venue 기본값·명시값 보존과 AUTO 제거
|
||||
|
||||
## Criterion
|
||||
|
||||
intent venue가 누락되거나 null이면 core는 original order venue를 `SOR`로 정규화해야 하고 explicit `KRX`, `NXT`, `SOR`는 submit·ack·slot·restart·cancel 전 과정에서 변경 없이 보존해야 한다. `AUTO`는 admission, broker request와 persistence에서 거부해야 하며 기존 persisted AUTO는 migration으로 SOR로 전환해야 한다. core는 wall-clock time이나 session 추정으로 order venue를 KRX/NXT로 재라우팅해서는 안 된다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 누락/null→SOR, explicit KRX/NXT/SOR identity 보존, SOR 부분체결 후 잔량 cancel과 restart가 original SOR를 사용하는 흐름이 통과한다.
|
||||
- 실패·경계 조건: AUTO acceptance·저장, 시간 기반 venue 변환, ack actual venue로 slot order venue 덮어쓰기 또는 restart/cancel에서 original venue 손실은 FAIL이다.
|
||||
- 검사 방법: `tests/adapters/broker/test_venue.py`, `tests/application/test_venue_intent_flow.py`, migration/drift tests와 KIS request capture를 실행하여 wire·slot·SQL constraint를 단언한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0019"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 원주문 venue와 실제 체결 venue의 durable 분리
|
||||
|
||||
## Criterion
|
||||
|
||||
core는 original `order_venue`와 broker-authoritative actual `execution_venue`를 서로 다른 의미로 보존해야 한다. `order_venue`는 KRX/NXT/SOR이며 submit, ack, slot, restart와 cancel에 사용한다. actual venue는 KRX/NXT 또는 미확정 NULL이며 KIS의 검증된 `ORD_EXG_GB`와 명시 exchange-id allowlist에서만 결정하여 fill, ledger와 outbox에 기록한다. 완료된 SOR fill의 actual venue가 확인되지 않으면 core는 `venue=NULL`, `order_venue=SOR`로 정상 ledger outbox work item을 enqueue해야 한다. `SOR`, 누락, 미지원 코드 또는 wall-clock time으로 actual venue를 추론해서는 안 된다. historical outbox SOR는 `order_venue=SOR`, actual NULL로 변환하고 migration replay는 신규 정상 조합을 오염시키지 않아야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: SOR order의 actual KRX/NXT fill은 `(order_venue=SOR, venue=KRX|NXT)`로 기록되고, actual venue가 확인되지 않은 완료 SOR fill은 `(order_venue=SOR, venue=NULL)`인 durable outbox 1건을 생성해 기존 execution-ID 멱등 `LedgerWriter` 경로로 기록된다. cancel/restart는 SOR를 유지하며, historical SOR는 `(SOR,NULL)`이고 정상 `(order=KRX, actual=NXT)`는 migration 반복 후 보존된다.
|
||||
- 실패·경계 조건: actual SOR 저장, unknown code를 KRX/NXT로 추론, actual venue로 slot order venue 덮어쓰기, actual venue가 NULL인 완료 SOR fill의 outbox 미생성·원장 유실, AUTO·NULL order 저장 또는 migration replay 오염은 FAIL이다.
|
||||
- 검사 방법: KIS parser tests와 KIS daily-ccld SOR fixture의 execution→outbox→`LedgerWriter` 회귀·replay idempotency, `tests/adapters/persistence/test_postgresql_ledger_outbox.py`, `tests/adapters/persistence/test_migration_and_drift.py`를 실행한다. 실제 PostgreSQL에서 historical conversion, replay, round-trip과 check violation을 검증하고 전체 회귀·Ruff 및 독립 read-only QC를 수행한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0020"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 완료됐지만 원장 기록 불가인 fill의 durable skip 감사 기록
|
||||
|
||||
## Criterion
|
||||
|
||||
core는 완료된 broker fill이 정상 `ledger_outbox` 경로로 들어갈 수 없을 때 WARN 로그만 남기고 끝내서는 안 된다. genuinely non-recordable 사례(예: `portfolio_id` 누락, `execution_id` 누락, 지원하지 않는 actual execution venue)마다 durable `ledger_skip` 감사 레코드 1건을 멱등하게 유지해야 하며, 이 레코드는 최소한 daemon instance, slot ID, portfolio ID, symbol, order ID, execution ID(있으면), side, qty, price, execution time, `order_venue`, `execution_venue` 또는 명시적 unknown 상태, machine-readable `skip_reason`, 생성/갱신/해결 timestamp, resolution state를 포함해야 한다. 이 경로는 broker·slot·portfolio 수량을 자동 보정하거나 mutate하지 않아야 하며, retryable ledger delivery failure는 계속 `ledger_outbox`와 `LedgerWriter` 재시도 의미론을 사용해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: completed fill이 `missing_portfolio_id`, `missing_execution_id`, `unsupported_execution_venue` 등으로 정상 원장 기록이 불가능하면 slot transition 이후에도 `ledger_skip`에 동일 fill 식별자의 unresolved row가 남고, 같은 fill을 다시 관찰해도 duplicate row가 생기지 않는다. 운영자는 instance/date/portfolio/resolution state 조건으로 unresolved row를 조회할 수 있다.
|
||||
- 실패·경계 조건: completed-but-unrecordable fill이 ephemeral log에만 남음, skip row가 retryable delivery failure row를 대체함, skip 처리 중 slot/broker/portfolio 수량이 암묵적으로 바뀜, unknown execution venue alone(SOR actual NULL)는 정상 outbox 경로 대신 skip으로 빠짐은 FAIL이다.
|
||||
- 검사 방법: execution ledger unit tests, PostgreSQL `ledger_skip` store round-trip/filter/resolve tests, CLI skipped-ledger query tests, targeted Ruff/pytest와 ProofLine validate를 수행한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0021"
|
||||
status: active
|
||||
---
|
||||
|
||||
# skipped-ledger 알림과 해결 이벤트
|
||||
|
||||
## Criterion
|
||||
|
||||
unresolved `ledger_skip` 레코드는 best-effort·non-blocking 방식으로 deduplicated `trex.alerts` 이벤트를 발행해야 하며, notifier 부재나 transport 오류가 fill lifecycle을 막아서는 안 된다. 동일 unresolved event는 중복 알림 없이 1회만 알리고, 이후 정상 ledger delivery가 성공하거나 operator가 명시적으로 reconcile/resolve 하면 해당 `ledger_skip` 레코드는 resolved 상태로 전이되고 distinct resolved alert event를 발행해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 새로운 unresolved skip 1건은 `trex.alerts`에 `ledger_skip_unresolved` alarm 1건을 만들고, manual backfill 또는 operator resolve 후에는 동일 skip에 대해 `ledger_skip_resolved` alarm이 별도로 발행된다. 알림 publish 실패는 swallow되며 durable state 전이는 유지된다.
|
||||
- 실패·경계 조건: unresolved skip마다 중복 알림이 반복 발행됨, resolved 전이가 알림 없이 묵살됨, alert publish failure가 fill/backfill/resolve 경로를 abort시킴은 FAIL이다.
|
||||
- 검사 방법: in-memory alarm transport tests, CLI ledger backfill resolution tests, skipped-ledger resolve command tests, PostgreSQL resolution-state verification을 실행한다.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
id: "ac-0022"
|
||||
status: active
|
||||
---
|
||||
|
||||
# strategy slots RPC broker-authoritative holding quantity
|
||||
|
||||
## Criterion
|
||||
|
||||
`trex.slots.req.<instance>`의 **전략용 slots 응답에 포함되는** `holding` 슬롯(AC-0023에 따라 B 또는 C reconcile warning으로 제외된 slot 제외)의 `qty`는 요청한 instance에 결합된 KIS account broker holdings(`hldg_qty`)를 authoritative source로 사용해야 한다. `daily_slot.qty`와 portfolio-hub 집계값은 각각 `slot_quantity`, `asset_quantity` 진단 필드로 분리 유지한다. broker 수량을 확보하지 못했거나 freshness 허용시간을 초과하면 core/hub mirror fallback 없이 `qty=null`, `qty_source="unavailable"`, `broker_quantity_available=false`, `broker_quantity_captured_at=null`을 반환해 전략이 fail-closed하도록 해야 한다. confirmed broker zero는 `qty=0`, `qty_source="broker"`로 unavailable과 구분되어야 한다. 이 read path는 주문 submit/cancel, ledger write, slot lifecycle mutation을 유발하지 않아야 한다.
|
||||
|
||||
**Scope note:** AC-0023에 따라 B 또는 C reconcile warning(`case_code=B` 또는 `case_code=C`)으로 전략용 slots RPC에서 제외된 slot에는 이 AC의 broker-authoritative `qty` 응답 형식을 적용하지 않는다. AC-0023 제외가 AC-0022 broker-qty 형식보다 우선한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: mismatch fixture(`broker=440`, `slot=435`, `hub=435`)에서 holding row가 `qty=440`, `slot_quantity=435`, `asset_quantity=435`, `qty_source="broker"`를 반환한다. broker zero fixture는 `qty=0`으로 유지된다.
|
||||
- 실패·경계 조건: broker unavailable/stale fixture에서 `qty`가 slot/hub 값으로 대체되면 FAIL이다. 이 경우 `qty=null`과 명시적 unavailable metadata를 반환해야 한다.
|
||||
- 격리 조건: 다중 instance 테스트에서 각 `trex.slots.req.<instance>`는 해당 instance account snapshot만 참조하며 타 instance broker/snapshot/slot 데이터가 섞이지 않아야 한다.
|
||||
- 비침습 조건: slots RPC 호출 전후로 slot 상태 전이, broker mutation, ledger write side effect가 없어야 한다.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
id: "ac-0023"
|
||||
status: active
|
||||
---
|
||||
|
||||
# B/C reconcile warning — strategy RPC exclusion and intent blocking
|
||||
|
||||
## Criterion
|
||||
|
||||
`case_code=B`(core slot↔KIS 수량 불일치) 또는 `case_code=C`(portfolio-hub↔KIS 수량 불일치) reconcile-warning이 활성인 `holding` slot은 `trex.slots.req.<instance>` 응답에서 제외해야 한다. 동일 slot의 B/C 원인은 서로 덮어쓰지 않고 동시에 보존하며, 응답에는 제외된 slot 수와 활성 case별 count를 포함한다. 신규 buy/sell intent는 같은 durable warning 원인 집합을 재확인하여 `reconcile_warned` 사유로 거부한다. warn-store를 읽지 못하거나 warning metadata가 비어 있거나 지원하지 않는 case를 포함하면 안전한 eligibility를 입증할 수 없으므로 slots RPC는 `degraded=true` / `reason="warn_store_unavailable"`, intent는 `reconcile_warning_unavailable`(조회 실패) 또는 `reconcile_warned`(empty/unsupported metadata)로 fail-closed 처리해야 한다. B-case는 수량 일치 확인을 마친 operator `sync` 또는 AC-0032~AC-0034를 모두 만족해 qty·avg_price·audit·B warning clear가 한 transaction으로 commit된 `auto_reconcile`만 해제할 수 있다. `verify`는 instance+symbol 단위 KIS와 portfolio-hub 합계가 다시 일치한 경우 C-case만 해제하여 다른 활성 원인을 보존한다. C-case 수량 대조는 **instance_key + symbol 단위 합계**로 수행하며, 개별 portfolio 수량을 KIS 종목 합계와 직접 비교해서는 안 된다. 이 경로는 주문 submit/cancel, portfolio-hub 또는 ledger write, slot lifecycle mutation을 유발하지 않아야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: KIS 440주, hub portfolio A 200주 + B 240주(합계 440) → A·B 모두 반환되고 `excluded_slots.reconcile_quantity_mismatch=0`.
|
||||
- 불일치 조건: KIS 440주, hub A 200주 + B 235주(합계 435) → A·B 모두 제외되고 `excluded_slots.reconcile_quantity_mismatch=2`, `active_reconcile_warning_cases.C=2`. 다른 symbol 정상 slot은 같은 응답에 포함된다.
|
||||
- 복합 원인: 동일 slot에 B와 C가 동시에 활성일 때 두 원인이 모두 조회·차단되며, B 또는 C 하나를 해제해도 다른 원인은 보존된다.
|
||||
- B-case: B warning이 활성인 holding slot은 slots RPC에서 제외되고 신규 buy/sell intent도 거부된다.
|
||||
- 단일 portfolio 불일치: hub 단일 portfolio qty != KIS → 해당 symbol 전체 slot 제외.
|
||||
- fail-closed (slots RPC): warn-store 조회가 실패하거나 empty/unknown case metadata를 반환하면 `slots=[], degraded=true, reason="warn_store_unavailable"` 반환.
|
||||
- fail-closed (intent): intent 처리 시 `warn_store.is_warned()` 호출이 실패하면 `reconcile_warning_unavailable` 사유로 거부해야 한다 — fail-open 허용 불가.
|
||||
- intent race: 마지막 slots RPC 이후 경고가 설정된 경우에도 intent 처리 시점의 durable warning을 재확인해 `reconcile_warned`로 거부. unknown case도 거래 허용 근거로 사용하지 않는다.
|
||||
- 해제: symbol 합계가 다시 일치하면 해당 symbol의 모든 **C-case 경고만** 함께 해제. 같은 symbol의 B-case 등 다른 case 경고는 보존해야 한다.
|
||||
- 해제 대상: HOLDING 뿐 아니라 모든 상태의 slot에 걸린 C-case 경고를 포함한다.
|
||||
- 해제 실패: 해제 도중 예외가 발생하면 `result='clear_failed'`를 반환하고 완전 성공으로 보고하지 않아야 한다(부분 성공 = 실패).
|
||||
- 시간이 지났다는 이유만으로 자동 해제하지 않음.
|
||||
- 자동 해제: AC-0032의 authoritative evidence와 eligibility를 통과하고 AC-0033 transaction이 성공한 경우에만 B-case를 해제한다. CAS 실패·불완전 evidence·write 실패에서는 B-case를 유지한다.
|
||||
- 비침습 조건: slots RPC 호출 전후로 slot 상태 전이, broker mutation, ledger write side effect가 없어야 한다.
|
||||
- AC-0022 우선순위: B 또는 C reconcile warning으로 제외된 slot에는 broker-authoritative `qty` 응답 형식(AC-0022)을 적용하지 않는다.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
id: "ac-0024"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Reconciliation 수량 불일치 탐지·변경·해소 알림
|
||||
|
||||
## Criterion
|
||||
|
||||
`trex-core`는 B-case(core slot quantity와 broker quantity 불일치)를 stable slot별로, C-case(같은 instance·symbol의 portfolio-hub `asset_quantity` 합계와 broker quantity 불일치)를 `instance_key + symbol`별로 하나의 durable notification lifecycle로 추적해야 한다. 새 mismatch와 동일 identity의 case별 비교 quantity tuple 변경은 sanitized `trex.alerts` detected/changed event를 각각 한 번 발행하고, 성공한 publish fingerprint를 process restart 뒤에도 보존하여 unchanged scheduler tick을 억제해야 한다. B-case는 operator `sync` 또는 AC-0033 transaction이 성공한 `auto_reconcile`, C-case는 operator `verify`의 authoritative durable warning clear에서만 resolved event를 한 번 발행해야 하며 수량 일치의 단순 scheduler 관찰만으로 recovery를 발행해서는 안 된다. Event는 lifecycle state, `instance_key`, `symbol`, `case_code`, broker quantity와 case별 비교 quantity를 포함하고 account number/hash, credential, token, DSN, raw payload와 exception 본문을 포함해서는 안 된다. Publish 성공 뒤에만 fingerprint를 전진시키고 publish 또는 notification-state persistence 실패는 reconciliation, warning set/clear, sync/verify/auto_reconcile, scheduler와 slot lifecycle을 중단하거나 바꾸지 않아야 한다. Resolved publish 실패 시 notification state가 writable이면 pending transition을 durable하게 보존하여 후속 retry opportunity에서 재발행한다. Notification-state persistence 자체가 실패하면 authoritative warning clear를 유지하고 event 유실 가능성을 sanitized log로 남긴다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 새로운 B-case slot mismatch는 B detected event 한 건, 새로운 C-case symbol aggregate mismatch는 portfolio 수와 무관하게 C detected event 한 건을 만든다. 동일 fingerprint의 반복 tick은 추가 event를 만들지 않고 quantity tuple 변경은 changed event 한 건을 만든다. process restart 모사 뒤 unchanged mismatch는 억제되며, B `sync` 또는 성공한 `auto_reconcile`과 C aggregate `verify` clear는 각 identity의 resolved event 한 건만 만든다.
|
||||
- publish 실패 조건: transport failure에서는 detected/changed/resolved fingerprint가 전진하지 않아야 한다. detected/changed는 다음 동일 detection에서 재시도하고, resolved는 notification state가 writable한 경우 warning clear를 유지하면서 pending transition을 durable하게 보존해 후속 retry opportunity에서 재시도해야 한다. Notification-state persistence 실패도 business path를 중단하지 않아야 하며 resolved event 유실 가능성을 credential·DSN·raw exception 없는 log로 남겨야 한다.
|
||||
- 경계 조건: C-case 동일 symbol의 여러 portfolio/slot이 중복 event를 만들거나, scheduler의 단순 quantity 일치가 durable warning clear·resolved event를 만들거나, B slot 간 fingerprint가 섞이거나, restart 뒤 unchanged event가 재발행되거나, publish 전 fingerprint 기록으로 event가 유실되면 FAIL이다.
|
||||
- 보안·비변경 조건: 모든 lifecycle event의 allowlist fields와 rendered payload에 account number/hash, credential, token, DSN, raw KIS/portfolio-hub payload 및 exception 본문이 없어야 한다. 알림 전후 broker call mutation, portfolio-hub write, slot lifecycle/quantity mutation과 기존 AC-0023 blocking/clear semantics 변화가 없어야 한다.
|
||||
- 검사 방법: in-memory alarm transport와 restart 가능한 notification-state fixture로 B/C 최초 탐지, C multi-portfolio aggregation, unchanged suppression, quantity change, restart, publish failure retry, B sync resolution, C verify resolution, duplicate clear와 sensitive-field absence를 검증한다. focused reconciliation·warning·alarm tests, persistence migration/round-trip tests와 전체 repository regression 및 Ruff를 실행한다.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
id: "ac-0025"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 재조정 경고에 따른 전략 주문 요청 최초 거부 알림
|
||||
|
||||
## Criterion
|
||||
|
||||
`trex-core`는 해당 slot에 해제되지 않은 재조정 경고가 남아 있어 `ReconcileWarningStore.is_warned(slot_id)`가 참을 반환하고, 그 때문에 외부 전략의 새로운 매수 또는 매도 요청을 `status="rejected"`, `reason="reconcile_warned"`로 거부한 경우, 그 결과를 `IntentStore.finalize()`에 저장한 뒤 해당 `intent_id`에 대한 경고를 한 건 발행해야 한다. 경고는 `trex.alerts`로 보내며 `severity="warn"`, `code="strategy_intent_rejected"`를 사용한다. 같은 `intent_id`가 다시 들어오거나 최초 요청을 처리하는 도중 중복 요청이 들어오더라도 경고를 다시 발행해서는 안 된다. 반면, 같은 slot을 대상으로 하더라도 `intent_id`가 다르면 각각 별개의 최초 거부로 보고 경고를 발행해야 한다.
|
||||
|
||||
경고에는 `instance_key`, `intent_id`, `intent_type`, `reason="reconcile_warned"`, `broker_submitted=false`를 반드시 포함한다. `slot_id`, `portfolio_id`, `symbol`, `strategy_id`는 신뢰할 수 있는 값이 확인된 경우에만 포함하며, 알 수 없는 값을 추정하거나 원본 요청에서 그대로 복사해서는 안 된다. 매수 요청의 `strategy_id`와 매도 요청의 `sell_strategy_id`는 경고에서 공통 필드인 `strategy_id`로 표현한다.
|
||||
|
||||
정상 승인된 요청, `reconcile_warned` 이외의 사유로 거부된 요청, 최종 결과 저장에 실패한 요청, 그리고 safety-sell·kill-switch·watchdog 같은 내부 안전 경로에서는 이 경고를 발행하지 않는다. 경고 발행기가 없거나 부가 정보를 조회하지 못하거나 메시지 전송이 실패하더라도 이미 결정된 거부 결과, RPC 응답, 재조정 경고, slot 상태 및 broker·ledger·portfolio-hub 동작을 중단하거나 변경해서는 안 된다. 경고와 이 처리 경로의 로그에는 계좌 식별정보, credential, token, password, DSN, 원본 요청, 전략의 `extra`, 외부 시스템의 원본 payload 또는 예외 원문을 포함해서는 안 된다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 정상 동작: 재조정 경고가 설정된 slot에 새로운 매수 요청과 매도 요청을 각각 제출하면, 최종 거부 결과가 저장된 뒤 `strategy_intent_rejected` 경고가 `warn` 등급으로 요청마다 한 건씩 발행된다. 각 경고에는 필수 필드와 `broker_submitted=false`가 들어 있다. 매수의 `strategy_id`와 매도의 `sell_strategy_id`는 값이 있을 때 경고의 `strategy_id`로 나타나며, `slot_id`, `portfolio_id`, `symbol`은 저장소에서 확인된 값만 포함된다.
|
||||
- 중복 및 제외 조건: 같은 `intent_id`를 같은 프로세스에서 반복하거나 기존 영속 intent 저장소로 서비스를 다시 구성한 뒤 반복해도 경고는 추가로 발행되지 않고 저장된 기존 결과가 그대로 반환된다. 재조정 경고가 설정된 같은 slot에 서로 다른 `intent_id` 두 건을 제출하면 경고는 두 건 발행된다. 최초 요청을 처리하는 도중 들어온 중복 요청과 `duplicate_in_flight`, 정상 승인, `reconcile_warning_unavailable`, `capacity_full`, 입력 검증 실패 등 `reconcile_warned`가 아닌 결과는 경고를 만들지 않는다.
|
||||
- 처리 순서 및 장애 조건: 호출 순서를 기록하는 테스트용 저장소와 경고 발행기를 사용해 `IntentStore.finalize()`가 경고 발행보다 먼저 완료되는지 확인한다. 최종 결과 저장이 실패하면 경고를 발행하지 않는다. 경고 발행기가 없거나 실패를 반환하거나 전송 계층에 장애가 발생해도 원래의 `reconcile_warned` 거부 결과와 RPC 응답 본문은 유지된다. 이 과정에서 broker 주문 제출·취소, slot 상태 전이, 재조정 경고 설정·해제, ledger 기록 또는 portfolio-hub 쓰기가 추가로 발생하지 않는다.
|
||||
- 보안 및 호환성: 원본 요청과 `extra`에 credential·token·account·DSN·exception 식별용 값을 넣더라도 경고 제목, 필드, 직렬화된 전송 내용과 출력 로그에 해당 값이나 금지된 key가 나타나지 않는다. 부가 정보를 읽지 못하거나 값이 없으면 해당 선택 필드만 생략하고 필수 필드와 거부 결과는 유지한다. 운영 daemon의 `IntentService`에는 기존 공용 `AlarmEmitter`가 연결되며, 경고 발행기 없이 `IntentService`를 직접 생성하는 기존 사용 방식도 계속 동작한다.
|
||||
- 검사 방법: `tests/application/test_reconcile_warning_blocking.py` 또는 별도의 intent 경고 테스트에 매수·매도, 같은 `intent_id`와 다른 `intent_id`, 재시작을 모사할 수 있는 intent 저장소, 처리 순서, 발행 실패, 경고 필드 허용 목록과 부작용 없음 조건을 추가한다. 이어서 `tests/application/test_messaging_intent_consumer.py`, `tests/application/test_alarm_emitter.py`, daemon 연결 테스트, AC-0023·AC-0024 관련 회귀 테스트, 전체 저장소 테스트, Ruff, ProofLine validation과 `git diff --check`를 실행한다.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
id: "ac-0026"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Slot occupancy와 독립된 strategy state lifecycle
|
||||
|
||||
## Criterion
|
||||
|
||||
`trex-core`는 슬롯의 strategy-state map과 각 entry를 slot occupancy lifecycle과 독립적으로 보존해야 한다. Slot이 어떤 경로로든 `INACTIVE`가 되어도 일반 lifecycle transition은 strategy state를 삭제하지 않아야 하며, inactive 상태와 이후 재점유 상태에서 저장된 entry와 version을 다시 읽을 수 있어야 한다. 재점유 뒤 같은 entry에 성공한 write는 close 전 version에 이어서 증가해야 한다. Strategy-state persistence schema, application model, store protocol, read filtering, `strategy_state.set` request/response 및 state CAS에는 `occupancy_seq`나 `expected_occupancy_seq`가 없어야 하며, occupancy 변경만으로 state를 숨기거나 write를 `occupancy_changed`로 거부해서는 안 된다. Slot 자체의 `occupancy_seq`, active-slot write gate, opaque data size limit, slot transition의 나머지 원자성과 별도 파괴적 전체 DB reset은 변경하지 않는다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 보존 조건: active slot의 instance entry를 version 1 이상으로 저장한 뒤 전략 매도, safety-sell, forced-close 및 operator close를 포함한 각 `→ INACTIVE` 경로를 실행해도 저장소 entry와 version이 삭제되지 않는다. Inactive slot 조회와 이후 재점유 조회에서 같은 data·version이 보이고, 재점유 뒤 기존 version을 `expected_version`으로 사용한 성공 write가 version을 1 증가시킨다.
|
||||
- 격리 조건: 한 slot의 lifecycle transition이 해당 slot map의 일부 또는 전체 entry를 삭제하거나 다른 slot의 state를 변경하면 FAIL이다. Close-delete hook 제거 전후로 slot update, transition log, broker·ledger side effect와 transition failure 원자성이 기존 계약에서 달라지지 않아야 한다.
|
||||
- Occupancy 분리 조건: strategy-state migration/schema, dataclass, in-memory/PostgreSQL store contract, read path와 write RPC payload/response에 `occupancy_seq` 또는 `expected_occupancy_seq`가 남거나, occupancy mismatch read filtering 또는 `occupancy_changed` reject path가 실행되면 FAIL이다. Slot domain의 자체 `occupancy_seq`와 증가 규칙은 그대로 유지되어야 한다.
|
||||
- 경계 조건: inactive slot에 대한 신규 state write는 계속 `slot_inactive`로 거부되고 기존 state는 변경되지 않는다. Size limit 초과 write도 `state_too_large`로 거부되며 보존 중인 entry를 손상시키지 않는다. 명시적 전체 DB reset은 기존 범위의 데이터를 계속 제거할 수 있다.
|
||||
- 검사 방법: InMemory와 PostgreSQL fixtures에서 close 경로별 보존, inactive read, 재점유, version 연속성, write gate와 size limit parity를 검증한다. 기존 slot transition 회귀 테스트, persistence migration/round-trip 테스트, production daemon wiring, 전체 repository test와 정적 검사를 실행한다.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
id: "ac-0027"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Strategy-instance별 state identity와 version 격리
|
||||
|
||||
## Criterion
|
||||
|
||||
`trex-core`는 각 slot의 strategy state를 `data[strategy_instance_id] = {version, data, updated_at}` 형태의 하나의 slot-scoped map으로 저장해야 한다. `strategy_instance_id`는 entry의 저장·조회·CAS key이며 entry에 별도의 `strategy_id` metadata를 저장해서는 안 된다. `strategy_state.set`은 `{slot_id, strategy_instance_id, expected_version, data}`를 받아 required non-empty string instance ID, active slot, opaque data size limit와 해당 entry의 version만 검증해야 한다. Entry가 없을 때 `expected_version=0`인 성공 write는 version 1 entry를 만들고, 이후 성공 write는 그 entry의 version만 1 증가시켜야 한다. 같은 slot의 서로 다른 instance entry와 version은 서로 격리되어야 하며, 서로 다른 entry에 대한 동시 성공 write는 어느 entry도 유실하지 않고 모두 보존해야 한다. 기존 state에 대한 `strategy_id → strategy_instance_id` mapping·backfill·귀속 정책과 core-side instance registry를 도입해서는 안 된다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 저장 형태: 한 slot에 instance A와 B를 저장하면 persistence round-trip 결과가 A·B key 아래 각각 `{version, data, updated_at}`를 보존하고 entry 내부에 `strategy_id`, `occupancy_seq` 또는 다른 identity metadata를 포함하지 않는다. Inner `data`는 입력한 opaque string과 byte 단위로 동일해야 한다.
|
||||
- Version 조건: 새 A에 `expected_version=0` write는 A version 1을 만들고 후속 성공 write는 A만 증가시킨다. B의 version과 data는 변하지 않는다. 존재하는 A에 `expected_version=0` 또는 stale version을 보내면 `version_conflict`로 거부하고 A·B 어느 entry도 변경하지 않는다.
|
||||
- 동시성 조건: 같은 slot의 A와 B에 동시에 유효한 write를 수행하면 둘 다 성공하고 두 entry의 새 data와 version이 모두 남아야 한다. 같은 entry에 경쟁하는 write는 version CAS에 따라 최대 한 건만 해당 expected version에서 성공하며 lost update가 없어야 한다.
|
||||
- Identity 조건: 같은 전략 종류의 서로 다른 `strategy_instance_id`는 독립 entry를 사용하고 다른 ID의 state를 상속하지 않는다. 재시작 뒤 같은 ID를 사용하는 owner는 기존 entry를 이어 사용한다. 누락·비문자열·빈 ID write는 non-retryable `bad_request`로 거부되어 map을 만들거나 변경하지 않는다.
|
||||
- 비변경 조건: active-slot gate와 data size limit를 우회하지 않으며 `slot_inactive`·`state_too_large`·`bad_request:*` 거부가 다른 entry를 변경하지 않는다. `correlation_id` 재사용만으로 write가 deduplicate되어서는 안 된다.
|
||||
- 검사 방법: InMemory/PostgreSQL 공통 contract suite, 실제 PostgreSQL row-lock 또는 동등한 원자적 update fixture, migration 및 restart round-trip에서 create/update/conflict, same-type instance 격리, A·B 동시 write, same-entry 경쟁, malformed ID와 다른 entry 무변경을 검증한다.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
id: "ac-0028"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 전략·operator slots RPC의 strategy state 가시성
|
||||
|
||||
## Criterion
|
||||
|
||||
`trex-core`의 `slots.req`는 전략 요청과 operator 조회의 strategy-state 가시성을 구분해야 한다. `spy=false`인 전략용 요청은 required non-empty string `strategy_instance_id`를 받아 각 반환 slot에서 `data[request.strategy_instance_id]`와 정확히 일치하는 entry 하나만 `strategy_state: {version, data, updated_at}` 단일 object로 제공해야 한다. 요청 ID에 해당하는 entry가 없으면 `strategy_state` field를 생략하고 다른 instance의 ID·state 또는 전체 map을 대신 반환해서는 안 된다. 누락·비문자열·빈 ID는 RPC-level error나 새 envelope 없이 `slots=[]`, `available_slots=null`, `degraded=false`, `reason="bad_request:strategy_instance_id"`, `message="strategy_instance_id must be a non-empty string"`인 non-retryable response를 반환해야 한다. `spy=true`인 operator 요청은 `strategy_instance_id`를 요구하지 않고 기존 filter·slot 조회와 watchdog 비갱신 동작을 유지하며, 각 반환 slot의 `strategy_state`에 전체 `strategy_instance_id → {version, data, updated_at}` map을 제공해야 한다. Operator 요청에 `strategy_instance_id`가 포함되어도 map을 특정 entry로 제한해서는 안 된다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 전략 조회: 같은 slot map에 A와 B가 있을 때 `spy=false, strategy_instance_id=A` 요청은 `strategy_state`에 A의 `{version, data, updated_at}`만 반환하고 B의 ID·data와 outer map을 serialized payload 어디에도 포함하지 않는다. B 요청은 B만 반환하며 존재하지 않는 C 요청은 slot row의 `strategy_state`를 생략한다.
|
||||
- 잘못된 전략 요청: 누락, `null`, boolean, number, collection 및 빈 문자열 ID 각각에서 exact 기존 envelope인 `slots=[]`, `available_slots=null`, `degraded=false`, `reason="bad_request:strategy_instance_id"`, `message="strategy_instance_id must be a non-empty string"`을 반환한다. Slot, state 또는 다른 instance ID가 노출되거나 watchdog이 갱신되면 FAIL이다.
|
||||
- Operator 조회: `strategy_instance_id` 없이 `spy=true`로 기존 `universe`와 `slots` CLI가 성공하고 filter 결과와 watchdog 비갱신을 유지한다. A·B state가 있는 slot row의 `strategy_state`는 A와 B를 각각 `{version, data, updated_at}` entry로 포함한 전체 map이어야 한다. 같은 operator 요청에 A ID를 추가해도 B가 제거되지 않아야 한다.
|
||||
- 기존 계약 조합: AC-0023에 따라 제외된 slot은 strategy-state 가시성 형식을 적용하기 전에 계속 제외되고 warn-store failure는 기존 fail-closed 결과를 유지한다. 포함되는 holding slot의 broker-authoritative quantity와 unavailable metadata는 AC-0022를 유지하며 state 선택 때문에 바뀌지 않는다.
|
||||
- Wire 경계: 전략 응답의 단일 `strategy_state` object 안에는 `strategy_instance_id` key나 collection을 두지 않는다. Operator map의 key만 instance ID를 나타내며 각 value에는 identity metadata를 중복 저장하지 않는다. 별도 operator/diagnostic API, core-side instance registry 또는 caller 인증을 새로 요구하지 않는다.
|
||||
- 검사 방법: slots handler contract tests에서 전략 A/B/absent/malformed 요청과 operator no-ID/ID-present 요청을 serialized response 기준으로 검증한다. Operator CLI, watchdog, reconciliation exclusion, broker quantity, InMemory/PostgreSQL read parity와 production daemon handler wiring 회귀 테스트를 함께 실행한다.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
id: "ac-0029"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Ledger outbox instance ownership and lease fencing
|
||||
|
||||
## Criterion
|
||||
|
||||
`trex-core`의 durable ledger outbox row는 enqueue 시점의 required non-empty `instance_key`를 immutable owner로 저장해야 하며, daemon·operator backfill·targeted processing을 포함한 모든 delivery 경로는 자기 instance의 row만 claim하고 처리해야 한다. Shared PostgreSQL에서 claim은 pending·retry-due·unclaimed 또는 lease-expired row를 원자적으로 선택해 worker별 bounded lease를 설정해야 하고, 성공·중복·retryable·conflict·permanent·dead-letter outcome은 해당 claim의 fencing 정보가 일치할 때만 반영해야 한다. 다른 instance, legacy `instance_key IS NULL`, 아직 유효한 foreign lease 또는 이미 새 worker가 reclaim한 row는 처리·상태 변경·성공 통계·ledger-skip resolution 없이 fail closed 해야 한다.
|
||||
|
||||
Claim owner가 crash하면 lease 만료 후 같은 instance의 다른 worker가 row를 reclaim할 수 있어야 한다. Outcome CAS가 0건이면 claim 상실로 명시적으로 처리하고 성공 outcome으로 보고해서는 안 된다. InMemory와 PostgreSQL outbox는 instance filtering, active lease exclusion, expiry recovery와 stale-claim rejection에서 같은 의미를 가져야 한다. Claim·outcome 관측 정보에는 `instance_key`, sanitized result/reason과 correlation identity만 허용하며 credential, account identifier, token, password, DSN, provider payload 또는 exception 원문을 로그·metric·alert에 포함해서는 안 된다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 격리 조건: 서로 다른 `instance_key`의 daemon/writer 두 개가 한 PostgreSQL을 공유할 때 각 writer는 자기 row만 claim·전송·집계한다. `list_pending`, `pending_cash_delta`, batch claim과 correlation-targeted claim 모두 foreign row와 `instance_key IS NULL` legacy row를 제외한다. 누락·빈 instance key로 outbox 또는 writer를 생성하면 외부 연결이나 row 생성 전에 거부된다.
|
||||
- lease 조건: worker A가 claim한 row는 lease 동안 worker B가 claim하지 못한다. A가 outcome을 기록하면 lease가 해제되고 terminal 또는 retry 상태가 정확히 저장된다. A가 crash한 경우 expiry 전에는 B가 claim하지 못하고 expiry 뒤에는 B가 reclaim할 수 있다. Reclaim 뒤 A의 stale outcome은 typed claim-lost 결과로 거부되며 B의 row 상태, outcome, attempt count, next retry와 lease를 변경하지 않는다.
|
||||
- outcome 조건: adapter의 신규 성공과 `DUPLICATE_OK`, retryable, conflict, permanent 및 dead-letter 모든 분기는 claim fencing을 전달한다. CAS 실패 시 completion/retry counter, 성공 로그, snapshot write-back 이후의 ledger-skip resolution을 실행하지 않는다. Adapter duplicate는 row를 `completed/duplicate_completed`로 만들고 lease를 즉시 해제하여 재전송되지 않는다.
|
||||
- InMemory parity: 두 번의 동시 claim에서 첫 claim만 성공하고 active lease row는 pending scan에서 제외된다. clock을 이동하면 expiry recovery가 가능하며 이전 claim token으로 outcome을 기록할 수 없다. Instance filter와 targeted processing의 fail-closed 동작이 PostgreSQL과 같다.
|
||||
- 보안 조건: exception에 credential·account·token·password·DSN·provider payload를 식별하는 sentinel을 넣어 claim 실패, retry, permanent, dead-letter와 write-back 오류를 실행해도 rendered log, metric key/value와 alert에 sentinel 또는 exception 원문이 나타나지 않는다.
|
||||
- migration 및 회귀: migration 번호는 저장소에서 유일하고 기존 row를 자동 귀속시키지 않으며 `instance_key IS NULL`로 격리한다. 빈 DB와 기존 schema 모두에서 migration apply/replay, PostgreSQL claim round-trip, restart/expiry 회수, daemon·CLI wiring을 검증한다. 전체 repository test, Ruff, compileall, ProofLine validation과 `git diff --check`를 실행한다.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
id: "ac-0030"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Unmanaged position durable lifecycle, authority fencing, and operator audit
|
||||
|
||||
## Criterion
|
||||
|
||||
`trex-core`는 broker 계정에 존재하지만 대상 instance의 managed slot/ledger가 소유하지 않는 종목을 `(instance_key, symbol)` 단위의 durable unmanaged-position lifecycle로 추적해야 한다. Lifecycle state는 instance 간에 섞이지 않아야 하며, 최소 `detected`, `quantity_changed`, `resolved`, `reopened` 전이와 `first_seen_at`, `last_seen_at`, `quantity_changed_at`, `resolved_at` 보존을 제공해야 한다. 같은 수량의 반복 authoritative 관측은 `last_seen_at`만 갱신하고 lifecycle audit/alarm을 다시 만들지 않아야 한다.
|
||||
|
||||
absence resolution은 explicit authoritative full-balance snapshot에서만 허용되며 projected/partial/non-authoritative broker view 또는 broker read failure는 resolve authority가 아니다. observe와 resolve는 reconcile pass 전체에 전달된 동일 snapshot timestamp 또는 monotonic generation으로 fenced 되어야 하며 stale observe/stale resolve는 더 최신 state나 audit를 reopen, overwrite 또는 resolved 처리하지 못해야 한다. 최종 state와 append-only audit는 항상 가장 최신 authoritative snapshot이 결정해야 한다.
|
||||
|
||||
운영자 disposition은 explicit evidence reference가 있는 명령으로만 기록되고 inspectable append-only audit trail에 남아야 한다. 이 기능은 broker order submit/cancel, slot 생성·상태 변경, synthetic ledger mutation 또는 warning 자동 삭제를 수행해서는 안 된다.
|
||||
|
||||
## Verification
|
||||
|
||||
- identity/lifecycle: 같은 symbol을 서로 다른 `instance_key`에서 독립 state로 저장하고 first detect, quantity change, resolved, resolved 뒤 reopened, first/last-seen 보존을 InMemory/PostgreSQL parity로 검증한다.
|
||||
- authority boundary: missing marker, `False`, ownership/projected broker, partial reader, broker read failure에서는 absence resolution이 발생하지 않고 explicit authoritative full-balance snapshot에서만 resolved가 기록됨을 검증한다.
|
||||
- temporal fencing: 더 오래된 absent snapshot이 더 최신 observe 뒤늦게 commit 되어도 resolved 되지 않고, 더 오래된 observe가 더 최신 resolved/observe를 reopen 또는 overwrite하지 않으며 final state/audit가 최신 authoritative snapshot 순서를 따른다는 PostgreSQL concurrency/interleaving 회귀를 검증한다.
|
||||
- suppression/audit: 같은 수량 반복 관측은 duplicate lifecycle alarm/audit를 만들지 않고 `last_seen_at`만 갱신한다. operator `record`는 `evidence_ref`를 요구하고 `unmanaged audit`로 append-only history를 조회할 수 있어야 한다.
|
||||
- safety boundary: unmanaged lifecycle path가 broker order, slot mutation, ledger write 또는 warning auto-clear를 수행하지 않음을 existing regression surface와 focused tests로 유지한다.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
id: "ac-0031"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Broker balance failure lifecycle observability and worker-isolated retry
|
||||
|
||||
## Criterion
|
||||
|
||||
`trex-core`는 broker balance snapshot을 사용하는 worker(`execution`, `position_reconciliation`, `core_holding_reconciliation`)별로 독립 상태를 유지해야 한다. 상태는 최소 `state(ok|unavailable|stale)`, `last_success_at`, `consecutive_failures`, sanitized `failure_code`, `next_retry_at`를 포함해야 하며, 한 worker의 성공/실패가 다른 worker의 상태를 덮어쓰면 안 된다.
|
||||
|
||||
balance read failure는 `timeout`, `http_5xx`, `transport_failed`, `authentication_failed`, `token_issuance_failed`(credential failure), `authorization_failed`, `malformed_response`, `broker_rejected`, `ownership_mismatch`를 구분해야 하며, validated empty success(`[]`)는 failure로 취급하면 안 된다. live read transient failure 뒤 snapshot fallback이 stale/expired여도 downstream 상태에는 transient failure 분류가 보존되어야 한다.
|
||||
|
||||
daily snapshot cadence와 balance-worker retry cadence는 분리되어야 한다. daily refresh가 이미 수행된 날짜에도 failure worker는 `next_retry_at` 이후에만 재시도되어야 하며(backoff 중 broker read 금지), 성공 시 lifecycle recovery와 정상 cadence로 자동 복귀해야 한다. fail-closed 경계는 유지되어 unavailable/stale snapshot에서 신규 주문/경고 해제 경로가 정상 snapshot으로 해석되어서는 안 된다.
|
||||
|
||||
health tracker의 상태는 process-local로 유지되며 daemon restart 시 초기화된다. restart 이후 첫 failure는 새 lifecycle `detected`로 다시 기록되어야 한다.
|
||||
|
||||
operator-visible status surface는 instance+worker별 상태를 노출해야 하며 account/token/credential/raw broker payload/예외 원문을 포함해서는 안 된다.
|
||||
|
||||
## Verification
|
||||
|
||||
- worker isolation: execution/position/core-holding 중 하나만 실패시켜도 다른 worker의 상태가 변경되지 않음을 검증한다.
|
||||
- retry cadence: production scheduler path(`run_forever`/`_maybe_tick`)에서 daily refresh 여부와 무관하게 due worker만 backoff deadline 이후 재시도되는지 검증한다.
|
||||
- backoff/lifecycle: 장기 outage에서 backoff가 cap에 고정되고, detected/ongoing/recovered lifecycle이 중복 경고 없이 전이되는지 검증한다.
|
||||
- classification/fail-closed: empty success, timeout, HTTP 5xx, auth/credential(`authentication_failed`, `token_issuance_failed`), malformed, pagination-middle failure를 구분하고 각 worker의 fail-closed 결과와 health state를 검증한다.
|
||||
- restart semantics: daemon restart(새 tracker 인스턴스) 후 이전 health/backoff가 초기화되고 첫 failure가 detected로 재발행되는지 검증한다.
|
||||
- sanitization/visibility: operator status 응답에 instance+worker 상태 필드는 노출되지만 민감정보/원시 payload/예외 문자열은 노출되지 않음을 검증한다.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
id: "ac-0032"
|
||||
status: active
|
||||
---
|
||||
|
||||
# B-case auto-reconcile authoritative evidence와 eligibility
|
||||
|
||||
## Criterion
|
||||
|
||||
`reconcile_core_holdings`는 하나의 성공한 broker balance snapshot에서 `broker_qty > 0`이고, 같은 scheduler refresh의 portfolio-hub item이 slot의 `instance_key`·`portfolio_id`·`symbol`과 일치하며 `asset_quantity == broker_qty`일 때만 B-case 자동 동기화를 검토한다. 별도 canonical cost-basis 응답은 같은 portfolio·symbol·current quantity를 증명하고, fetch 시각이 60초 이내이며, `data_completeness_warning`이 없고, 양의 finite `cost_basis_per_unit`과 수량×평단에 일치하는 양의 finite `total_cost_basis`를 제공해야 한다. 대상 slot은 `holding`, 미퇴역, broker dispatch/pending metadata 없음이어야 한다. 첫 구현에서 `inactive`, `pending_buy`, `pending_sell`, `error`, retired slot은 자동 동기화하지 않는다. 어느 evidence든 missing·stale·invalid·불일치이거나 read/set-warning가 실패하면 fail-closed하여 slot과 warning을 변경하지 않는다. Operator `sync`의 legacy/graceful-degrade 계약은 변경하지 않는다.
|
||||
|
||||
## Verification
|
||||
|
||||
- hub·broker 수량과 canonical cost-basis identity/current quantity가 모두 일치하는 holding만 통과한다.
|
||||
- hub/broker mismatch, cost-basis warning·stale·legacy-only·missing·non-finite·0 이하·identity mismatch·total mismatch는 모두 자동 변경 없이 B warning을 유지한다.
|
||||
- broker balance는 reconcile pass당 한 번만 읽고 판단과 write가 같은 snapshot을 사용한다.
|
||||
- pending/dispatch metadata, retired 또는 holding 외 상태는 fail-closed한다.
|
||||
- 기존 `sync_slot_to_broker`의 canonical/legacy diagnostics와 inactive onboarding 회귀 테스트는 그대로 통과한다.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
id: "ac-0033"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Holding mirror qty·avg_price·B warning·audit 원자적 갱신
|
||||
|
||||
## Criterion
|
||||
|
||||
AC-0032를 통과한 자동 동기화는 instance advisory lock과 database transaction 안에서 관측한 slot identity/status/qty/avg_price 및 dispatch metadata를 CAS 재검증하고, `qty=broker_qty`, `avg_price=portfolio-hub cost_basis_per_unit` 갱신, `case_code=B` warning만 삭제, `actor=auto_reconcile`·`reason=hub_broker_matched_core_mirror_stale` transition audit 추가를 하나의 commit으로 수행해야 한다. Audit extra에는 allowlist된 before/after qty·avg_price와 hub/broker quantity, portfolio_id, symbol만 포함한다. CAS 실패, B warning 부재 또는 어느 write든 실패하면 네 변경 모두 없어야 한다. C/unknown warning과 다른 instance·symbol·slot은 보존한다. 같은 evidence 재실행은 no-op이며 audit를 중복 추가하지 않는다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 시 slot status는 holding으로 유지되고 qty·avg_price, B clear, audit 한 건이 함께 반영된다.
|
||||
- C와 기타 warning은 보존된다.
|
||||
- CAS race와 강제 예외에서 transaction rollback 후 slot·warning·audit가 모두 이전 상태다.
|
||||
- PostgreSQL 실제 transaction 테스트와 in-memory parity 테스트로 commit, rollback, isolation, idempotency를 검증한다.
|
||||
- broker order submit/cancel, portfolio-hub write, ledger/outbox write는 발생하지 않는다.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
id: "ac-0034"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Auto-reconcile scheduler·daemon wiring과 오류 격리
|
||||
|
||||
## Criterion
|
||||
|
||||
Production daemon은 기존 portfolio-hub read adapter, authoritative reconciliation broker, slot persistence, durable warning/notification store를 `SnapshotScheduler`의 B-case auto-reconcile 경로에 명시적으로 연결해야 한다. Scheduler는 성공한 universe refresh의 items와 core-holding worker가 한 번 읽은 complete broker snapshot을 사용하며 ad-hoc broker/Hub write client 또는 legacy cost-basis fallback을 만들지 않는다. Auto-reconcile evidence/read/CAS/transaction 오류는 해당 slot을 `qty_warning`으로 유지하고 scheduler·다른 slot·C-case reconciliation을 중단하지 않는다. 성공 commit 뒤에만 B resolved notification을 발행하며 publish 실패의 pending retry·fingerprint·instance isolation 및 sanitized payload 계약은 AC-0024를 유지한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- daemon assembly test가 read port, reconciliation broker, atomic store, warn/notification store의 production 연결을 확인한다.
|
||||
- scheduler test가 현재 refresh items 전달, broker 단일 read, auto-sync 성공/실패 격리와 다음 tick idempotency를 확인한다.
|
||||
- resolved notification은 commit/B clear 뒤 한 번 발생하고 publish 실패는 business commit을 되돌리지 않으며 pending retry된다.
|
||||
- synthetic 회귀: symbol `019685`, hub=broker=726, core=1452, canonical WAC≈2172.46829187에서 core만 726/WAC로 갱신되고 B만 해제되며 주문·Hub·ledger side effect가 없다.
|
||||
@@ -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": 3,
|
||||
"next_ac_number": 9
|
||||
"next_line_number": 13,
|
||||
"next_ac_number": 41
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
id: "dcy-0003"
|
||||
status: confirmed
|
||||
---
|
||||
|
||||
# 주문 기본 timeout 상한과 장마감 자동취소·부분체결 reconciliation
|
||||
|
||||
## Problem
|
||||
|
||||
`trex-core`는 전략의 주문 intent가 `timeout_sec`를 생략하면 인스턴스의 `default_order_timeout_sec`를 사용하지만, 인스턴스 설정도 생략된 경우 최종 timeout이 `None`이 되어 주문을 무기한 기다릴 수 있다. 전략 payload와 인스턴스 설정에도 3,600초 상한이 없어, 현재 계약만으로는 모든 매수·매도 주문이 늦어도 60분 안에 취소 또는 취소 후 reconciliation 절차를 시작한다는 안전 경계를 보장하지 못한다.
|
||||
|
||||
timeout 후 KIS 취소 요청이 장마감 등으로 거부되더라도 취소 API의 즉시 응답은 최종 주문 상태가 아니다. KIS는 장마감 시 당일 미체결 잔량을 자동 취소할 수 있고 그 전에 일부 또는 전량이 체결될 수 있으므로, `trex-core`는 후속 주문조회에서 전량체결, 무체결 자동취소, 부분체결 후 잔량취소를 구분하여 슬롯과 ledger를 확정해야 한다.
|
||||
|
||||
현재 실행 루프와 KIS adapter는 같은 날 주문조회가 `cncl_yn="Y"`를 반환하면 무체결 취소와 부분체결 후 취소를 처리한다. 그러나 KIS 주문조회는 polling 시점의 오늘(KST)을 조회일로 사용하고 주문번호 row를 찾지 못하면 `accepted`로 간주한다. 장마감 결과를 확인하기 전에 날짜가 넘어가거나 daemon이 재시작되면 전날 주문을 다시 찾지 못해 슬롯이 `PENDING_BUY` 또는 `PENDING_SELL`에 고착될 수 있다.
|
||||
|
||||
이 Line은 주문 제출 후 최대 3,600초 안에 취소/reconciliation을 시작하는 실행 timeout과, KIS의 authoritative 주문조회로 체결·취소를 확인한 뒤에만 슬롯을 terminal business state로 전환하는 최종 상태 확정을 분리한다. KIS 상태가 불명확할 때 로컬 timeout만으로 슬롯을 임의 해제해서는 안 된다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- GitHub issue #58 `https://github.com/fourblocks2020/trex-core/issues/58`은 이 문제와 현재 코드 근거를 기록한 Discovery 제안이다. 요청자는 2026-08-14에 OQ 선택을 완료한 뒤 문서 갱신과 Discovery 승인을 명시적으로 지시했다.
|
||||
- issue #58의 live 본문에서 요청자는 `OQ-001 A`를 선택했다. hard fallback은 3,600초이며 인스턴스 설정은 `1..3600` 범위에서 더 짧게 설정할 수 있어야 한다.
|
||||
- 같은 본문에서 `OQ-002 A`를 선택했다. 전략 payload의 `timeout_sec <= 0` 또는 `timeout_sec > 3600`은 보정하지 않고 주문 제출 전에 명시적으로 거부한다.
|
||||
- 같은 본문에서 `OQ-003 A`를 선택했다. KIS terminal evidence가 확보될 때까지 슬롯을 fail-closed pending/reconciliation 상태로 유지하고 CRITICAL alarm을 발생시키며, 원주문 날짜 조회와 재시작 복구를 계속하고 로컬에서 보유량 0을 추론하지 않는다.
|
||||
- 같은 본문에서 `OQ-004 B`를 선택했다. 기본 timeout과 3,600초 상한은 매수·매도 주문에 공통 적용한다.
|
||||
- 조사 기준 source revision은 `90b72863a2258f8b8ed4f48a6c84dba32c2f74fd`이며 조사 시 `main`과 `origin/main`은 동기화되어 있었다.
|
||||
- `trex_core/application/intents.py`의 `IntentService`는 `default_order_timeout_sec: int | None = None`을 허용하고, intent와 config에 값이 모두 없으면 슬롯에 `None`을 저장한다.
|
||||
- `trex_core/application/messaging_handlers.py`는 매수·매도 payload의 `timeout_sec`를 `_opt_int()`로 파싱하지만 `1..3600` 범위 계약을 적용하지 않는다.
|
||||
- `trex_core/runtime/config.py`의 `default_order_timeout_sec` 검증은 값이 존재할 때 양수인지만 확인하며 3,600초 상한과 설정 부재 시 hard fallback을 강제하지 않는다.
|
||||
- `trex_core/application/execution.py::_try_timeout_cancel()`은 timeout 도달 시 `BrokerPort.cancel_order()`를 호출하고 즉시 결과를 영속화한다. `REQUESTED`, `REJECTED`, `UNKNOWN`은 최종 주문 상태가 아니며 이후 `_poll()`의 `poll_fill()` 결과가 terminal authority다. 다만 `slot.cancel_state`가 설정된 뒤에는 추가 취소를 요청하지 않는다.
|
||||
- `trex_core/adapters/broker/kis.py::cancel_order()`는 KIS `rt_cd != "0"`을 `CancelState.REJECTED`로 매핑하고 `msg_cd`를 reason으로 보존한다. transport·5xx·malformed response는 `UNKNOWN`으로 구분한다.
|
||||
- `KISBroker.poll_fill()`은 polling 당시의 KST 날짜로 `inquire-daily-ccld`를 호출한다. 해당 주문 row가 없으면 `order_not_visible_in_fill_inquiry` reason과 함께 `accepted`를 반환하며 원주문 날짜를 영속화하거나 전날 주문을 조회하는 경로는 없다.
|
||||
- `KISBroker._fill_status_from_row()`는 `cncl_yn="Y"`이고 `tot_ccld_qty=0`이면 `cancelled`, `0 < tot_ccld_qty < ord_qty`이면 `cancelled=True`인 `partially_filled`, 전량 체결이면 `filled`로 분류한다.
|
||||
- `ExecutionService._on_partial_cancel()`은 매수 부분체결분의 deterministic `execution_id`를 사용해 ledger 기록을 만들고 broker 잔고를 authority로 수량을 정한 뒤 `PENDING_BUY → HOLDING`으로 전환한다. 무체결 취소는 초기 매수라면 `PENDING_BUY → INACTIVE`, 증액 매수라면 기존 broker 보유량 기준 `PENDING_BUY → HOLDING`으로 복구한다. 매도도 부분체결·취소 결과를 broker 잔고에 맞춰 처리한다.
|
||||
- 종료된 issue #21은 무기한 pending 문제와 timeout 취소 기능의 선행 이력이고, 종료된 issue #9는 KIS `cncl_yn` 외부취소 감지의 선행 이력이다. 열린 issue #45는 장 시작 KIS REST timeout과 account snapshot fallback을 다루므로 주문별 timeout·장마감 자동취소 확정과는 범위가 다르다.
|
||||
- 기준 revision에서 `uv run --extra dev pytest tests/application/test_execution_policy.py tests/application/test_execution_policy_handler.py tests/application/test_execution_cancel.py tests/adapters/broker/test_kis_cancel.py -q -o 'addopts='`를 실행한 결과는 `53 passed in 0.10s`였다. 이는 기존 timeout 취소와 같은 날 외부취소·부분체결 회귀가 통과함을 보여줄 뿐, hard fallback, 3,600초 상한, 장마감 취소 API 오류 또는 날짜 rollover 복구가 이미 구현됐다는 증거는 아니다.
|
||||
|
||||
## Scope
|
||||
|
||||
- AC admission은 기존 active AC를 변경하지 않고 새로운 장기 동작을 정의하는 `create`다. 현재 active AC-0001~AC-0008은 KIS 잔고 페이지네이션 및 GHCR 게시 경계를 소유하며 주문 timeout·취소 reconciliation을 소유하지 않는다.
|
||||
- 매수·매도 intent가 `timeout_sec`를 생략하거나 명시적으로 `null`을 전달하면 hard fallback `3600`초를 적용한다. 인스턴스 `default_order_timeout_sec`는 `1..3600`초 범위에서 hard fallback보다 짧거나 같은 기본값을 제공할 수 있으며 설정 자체가 없어도 timeout을 비활성화하지 않는다.
|
||||
- 전략 payload의 `timeout_sec`는 `1..3600`초만 허용한다. `timeout_sec <= 0`, `timeout_sec > 3600` 또는 정수로 해석할 수 없는 값은 clamp하지 않고 주문 제출 전에 `bad_request` 계열 reason으로 거부한다.
|
||||
- 인스턴스 `default_order_timeout_sec`가 허용 범위를 벗어나면 daemon 시작 시 설정 오류로 실패하며 주문 실행을 시작하지 않는다.
|
||||
- effective timeout과 immutable 주문 제출 시각 또는 원주문 날짜를 영속화하여 daemon 재시작 후에도 같은 deadline과 주문일을 사용한다. 일반적인 `updated_at` 변경으로 timeout 기준점이나 주문 조회일이 이동해서는 안 된다.
|
||||
- timeout 도달은 슬롯의 강제 종료가 아니라 취소/reconciliation 시작 조건이다. 실행 루프는 외부 주문의 최신 상태를 먼저 조회하고 non-terminal인 경우에만 취소를 요청한다.
|
||||
- 취소 API의 `REQUESTED`, `REJECTED`, `UNKNOWN`은 final cancellation이 아니다. terminal 주문 상태는 원주문 날짜를 사용하는 KIS 주문조회로만 확정한다.
|
||||
- 취소 응답이 `REJECTED` 또는 `UNKNOWN`이고 terminal 상태를 즉시 확보하지 못하면 슬롯을 fail-closed pending/reconciliation 상태로 유지한다. 주문조회와 재시작 복구를 계속하고 CRITICAL alarm을 발행하며 로컬에서 취소, 미체결 또는 보유량 0을 추론하지 않는다.
|
||||
- `UNKNOWN` 또는 retryable 취소 결과의 재시도는 주문조회를 먼저 수행하고 broker가 주문을 여전히 open으로 확인한 경우에만 bounded backoff로 허용한다. 응답 유실 가능성이 있는 취소를 blind retry해서는 안 된다.
|
||||
- 날짜 rollover와 daemon 재시작 후에도 영속화된 원주문 날짜로 KIS 주문 상태를 조회한다. 주문 row 미가시성은 제출 직후의 bounded visibility grace와 그 이후의 unresolved 상태로 구분하며, row가 없다는 사실만으로 terminal 상태를 추론하지 않는다.
|
||||
- KIS 장마감 자동취소 결과는 다음과 같이 처리한다.
|
||||
- 무체결 초기 매수 취소: `PENDING_BUY → INACTIVE`
|
||||
- 무체결 증액 매수 취소: broker 기존 보유량 기준 `PENDING_BUY → HOLDING`
|
||||
- 매수 부분체결 후 잔량취소: 체결분 ledger exactly-once 기록, broker 잔고 기준 `PENDING_BUY → HOLDING`
|
||||
- 매도 무체결 또는 부분체결 후 잔량취소: broker 잔고 기준으로 `HOLDING` 또는 기존 terminal sell 경계를 적용
|
||||
- 취소와 전량체결 경합: 전량체결을 우선하여 정상 체결 경로 적용
|
||||
- 취소 응답 유실, 취소 API 거부, 장마감 자동취소, 전량·부분·무체결, 날짜 rollover, daemon 재시작, 주문 row 미가시성과 ledger 멱등성을 repository-local synthetic KIS fixture와 application 통합 테스트로 검증한다.
|
||||
- account, token, credential, connection string과 raw KIS payload 등 민감한 값은 로그·오류·fixture·evidence에 포함하지 않는다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- KIS 거래소 자체의 당일주문 유효기간 또는 장마감 자동취소 정책 변경
|
||||
- strategy daemon의 매수·매도 조건, 가격 결정, slippage 또는 주문수량 정책 변경
|
||||
- portfolio-hub API·ledger schema 또는 다른 TREX 서비스의 wire/HTTP 계약 변경
|
||||
- 운영계좌를 이용한 실제 주문·취소 실행, 운영 데이터 수정 또는 기존 pending 슬롯의 사후 자동 정리
|
||||
- KIS account snapshot fallback과 장 시작 REST 장애 복구(issue #45 범위)
|
||||
- terminal broker evidence 없이 별도 유예시간만으로 슬롯을 강제 `INACTIVE` 또는 재사용 가능 상태로 전환하는 정책
|
||||
- timeout 범위 밖 값을 clamp하여 주문을 계속하는 호환 동작
|
||||
- 배포, release, 운영환경 설정 변경 또는 delivery/lifecycle 완료 주장
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
### Risks
|
||||
|
||||
- timeout 시점에 슬롯을 즉시 해제하면 취소 요청과 실제 체결이 경합하거나 KIS 응답이 유실된 경우 실제 보유량과 슬롯이 어긋날 수 있다. 요청자가 선택한 fail-closed 정책에 따라 terminal broker evidence 전에는 슬롯을 재사용 가능하게 만들지 않고 CRITICAL alarm과 reconciliation을 유지한다.
|
||||
- 원주문 날짜가 아닌 현재 날짜로 조회하면 장마감 직전 주문이나 재시작된 주문을 다음 날 찾지 못해 무기한 pending이 재발할 수 있다. immutable 주문 제출 시각 또는 원주문 날짜를 영속화하고 조회 기준으로 사용한다.
|
||||
- 주문조회 row 없음은 제출 직후 eventual consistency와 오래된 주문 누락을 모두 포함할 수 있다. bounded visibility grace 이후에도 row 없음만으로 취소·미체결을 추론하지 않고 unresolved로 유지한다.
|
||||
- `UNKNOWN` 취소를 맹목적으로 재시도하면 최초 취소가 실제 접수됐지만 응답만 유실된 경우 중복 요청이 발생할 수 있다. 재시도 전 authoritative 조회를 우선하고 open 주문이 확인된 경우에만 bounded backoff를 적용한다.
|
||||
- 부분체결 후 자동취소를 여러 번 관찰하면 ledger가 중복 기록될 수 있다. deterministic `execution_id`와 기존 outbox/ledger 멱등 경계를 보존하고 반복 polling·재시작 fixture로 검증한다.
|
||||
- KIS의 실제 장마감 error `msg_cd`와 자동취소 row shape가 synthetic fixture와 다를 수 있다. 특정 message code를 terminal authority로 고정하지 않고 주문조회 결과를 정본으로 사용하며, code-specific retry 분류가 필요하면 민감정보를 제거한 별도 운영 evidence로 검증한다.
|
||||
- 매수뿐 아니라 매도에도 공통 timeout을 적용하면 기존에 timeout을 생략하던 매도 전략의 동작이 바뀐다. 이 변경은 요청자가 `OQ-004 B`로 선택한 승인 범위이며, 양쪽 side의 기존 명시 timeout과 생략 경로를 독립 회귀 테스트로 보호한다.
|
||||
@@ -0,0 +1,53 @@
|
||||
# 구현 완료 evidence
|
||||
|
||||
이 파일은 ProofLine canonical lifecycle artifact가 아닌 GitHub issue comment 원문 evidence입니다.
|
||||
|
||||
- Source URL: https://github.com/fourblocks2020/trex-core/issues/58#issuecomment-5290331952
|
||||
- Comment ID: `5290331952`
|
||||
- Author: `genichin`
|
||||
- Created at: `2026-08-14T06:41:15Z`
|
||||
- Updated at: `2026-08-14T06:41:15Z`
|
||||
- Original body SHA-256: `10ea8235709eda917a9e64a6434a4f01ac9cd630ea965ce48d6134f0a4e0c22e`
|
||||
- Captured from GitHub API: `2026-08-14`
|
||||
|
||||
## Original Comment Body
|
||||
|
||||
<!-- BEGIN ORIGINAL COMMENT BODY -->
|
||||
## 구현 완료
|
||||
|
||||
승인된 #58 범위의 주문 timeout·장마감 자동취소·부분체결 reconciliation 구현과 독립 검증을 완료했습니다.
|
||||
|
||||
### 구현 내용
|
||||
|
||||
- 매수·매도 주문 timeout 생략 또는 `null` 시 3,600초 fallback 적용
|
||||
- 전략 및 인스턴스 timeout을 strict integer `1..3600`으로 검증하고 잘못된 값은 주문 제출 전에 거부
|
||||
- 원주문 제출 시각·KST 주문일·effective timeout을 일반 `updated_at`과 분리하여 영속화하고 재시작·날짜 rollover 후에도 보존
|
||||
- 취소 `REQUESTED`/`REJECTED`/`UNKNOWN`을 nonterminal로 유지하고 KIS 주문조회를 terminal authority로 사용
|
||||
- legacy 주문 metadata 미확인 시 현재 날짜를 추정하지 않고 poll·terminal 전이·cancel·ledger 처리를 차단하는 fail-closed 경로 구현
|
||||
- authoritative open evidence가 있는 `REJECTED`/`UNKNOWN`에만 bounded backoff retry를 허용하고 blind retry 차단
|
||||
- 무체결·부분체결·전량체결 경합, ledger exactly-once, alarm dedupe 및 restart 재발행 회귀 테스트 보강
|
||||
- PostgreSQL migration 및 in-memory/PostgreSQL persistence parity, 데이터 모델·interface contract 갱신
|
||||
|
||||
### 검증 결과
|
||||
|
||||
집중·전체 테스트와 독립 read-only QC를 모두 통과했으며, 신규 차단 finding은 없습니다.
|
||||
|
||||
- 최종 HEAD: `843df1d6f1a642282039a57d0de4ca85204d6bee`
|
||||
- 팀장 focused suite: `114 passed, 1 skipped`
|
||||
- 독립 QC focused suite: `148 passed, 1 skipped`
|
||||
- 전체 suite: `1,040 passed, 51 skipped`
|
||||
- Ruff: `All checks passed!`
|
||||
- `proofline validate`: exit `0`
|
||||
- `git diff --check` 및 commit/worktree immutability 확인: 통과
|
||||
- 독립 QC: **PASS**, `mutation=false`, 신규 finding 없음
|
||||
- legacy adversarial probe: broker poll·cancel·slot transition·ledger 기록 모두 0, pending 유지 및 sanitized CRITICAL alarm 확인
|
||||
|
||||
### 비차단 후속 권고
|
||||
|
||||
- `TREX_POSTGRESQL_TEST_DSN`이 준비되면 PostgreSQL migration/repository integration 41건을 실제 DB에서 재실행
|
||||
- `TREX_RABBITMQ_TEST_URL`이 준비되면 RabbitMQ integration 9건을 재실행
|
||||
- 안전한 KIS sandbox credential이 준비되면 sandbox integration 1건을 재실행
|
||||
|
||||
> 현재 상태는 **격리 브랜치의 로컬 구현·검증·커밋 완료**입니다. push·PR·main 반영·배포·릴리스 완료를 의미하지 않습니다.
|
||||
|
||||
<!-- END ORIGINAL COMMENT BODY -->
|
||||
@@ -0,0 +1,70 @@
|
||||
# 구현 진행 상황 evidence
|
||||
|
||||
이 파일은 ProofLine canonical lifecycle artifact가 아닌 GitHub issue comment 원문 evidence입니다.
|
||||
|
||||
- Source URL: https://github.com/fourblocks2020/trex-core/issues/58#issuecomment-5290005799
|
||||
- Comment ID: `5290005799`
|
||||
- Author: `genichin`
|
||||
- Created at: `2026-08-14T05:55:12Z`
|
||||
- Updated at: `2026-08-14T06:38:38Z`
|
||||
- Original body SHA-256: `471c6b3b5a70a1632e6e1d4076014a2cf967ffb45bba8ff3bffe122fee39e037`
|
||||
- Captured from GitHub API: `2026-08-14`
|
||||
|
||||
## Original Comment Body
|
||||
|
||||
<!-- BEGIN ORIGINAL COMMENT BODY -->
|
||||
## 구현 진행 상황
|
||||
|
||||
**상태:** 🟢 구현·검증 완료
|
||||
|
||||
### 구현 계획 및 작업 목록
|
||||
|
||||
- [x] **1. 구현 기준선과 영향 범위 확인**
|
||||
- 승인된 #58 본문을 변경 범위의 기준으로 고정
|
||||
- timeout 계산, 주문 제출정보 영속화, KIS 주문조회, 취소상태, alarm 및 migration 경로 확인
|
||||
- [x] **2. 기본 timeout 및 입력 검증 구현**
|
||||
- timeout 생략 또는 `null`이면 매수·매도 모두 3,600초 적용
|
||||
- 전략 payload와 인스턴스 설정은 `1..3600`초만 허용
|
||||
- 범위 밖 값은 clamp하지 않고 주문 제출 전에 명시적으로 거부
|
||||
- [x] **3. 원주문 시각·날짜 영속화와 재시작 복구 구현**
|
||||
- timeout 기준점과 KIS 주문 조회일을 일반 `updated_at`과 분리
|
||||
- 날짜가 넘어가거나 daemon이 재시작돼도 원주문 날짜로 상태 조회
|
||||
- [x] **4. 장마감 취소 실패 후 fail-closed reconciliation 구현**
|
||||
- 취소 API의 `REQUESTED/REJECTED/UNKNOWN`을 terminal 상태로 오인하지 않음
|
||||
- terminal broker evidence 전까지 슬롯 재사용 금지
|
||||
- 불명확 상태에서 CRITICAL alarm 발생
|
||||
- 주문조회로 open 상태를 확인한 경우에만 bounded retry를 허용하고 blind retry 금지
|
||||
- [x] **5. 자동취소·부분체결·경합 처리 회귀 보강**
|
||||
- 무체결 자동취소
|
||||
- 부분체결 후 잔량취소와 ledger exactly-once
|
||||
- 취소와 전량체결 경합
|
||||
- 날짜 rollover 및 daemon 재시작
|
||||
- 주문 row 미가시성
|
||||
- [x] **6. 검증 및 독립 리뷰**
|
||||
- 관련 집중 테스트
|
||||
- 전체 테스트와 lint/정적 검사
|
||||
- ProofLine repository validation
|
||||
- 구현자와 분리된 독립 코드 리뷰
|
||||
|
||||
### 진행 원칙
|
||||
|
||||
- 체크박스는 해당 작업이 실제 완료되고 관련 검증이 통과한 뒤에만 갱신합니다.
|
||||
- 진행 기록은 이 댓글을 계속 수정하여 누적합니다.
|
||||
- 승인된 구현 계획의 제품 의미나 범위를 변경해야 하면 구현을 중지하고 사용자 승인을 요청합니다.
|
||||
- 실제 주문, 운영계좌 변경, 배포, release, main push 또는 PR 생성은 이번 작업에 포함하지 않습니다.
|
||||
|
||||
### 진행 기록
|
||||
|
||||
- 구현 시작: #58의 확정된 Discovery 범위와 OQ 결정 4건을 기준선으로 채택했습니다.
|
||||
- 기준선/영향 분석 완료: 기본값 `None`, `updated_at` 기반 timeout, KIS 현재일 조회, cancel-state retry 차단 및 신규 persistence migration 필요성을 확인했습니다.
|
||||
- 격리 작업공간 생성: `feat/issue-58-order-timeout-reconciliation` (`482b00e` 기준).
|
||||
- 변경 전 기준선 검증 완료: 전체 테스트 `995 passed, 51 skipped`, Ruff/ProofLine/diff check 통과.
|
||||
- 1차 구현 인스턴스가 핵심 코드·migration·synthetic test를 작성하고 focused suite `95 passed`를 확인했습니다. 전체 검증·커밋 전 실행 한도에 도달하여 동일 worktree에서 후속 인스턴스로 인계했습니다.
|
||||
- 후속 검수·보완 완료: strict integer 입력, legacy migration fail-closed, 원주문일 rollover, cancel alarm 중복 억제 및 open-evidence 기반 bounded retry를 반영했고 focused suite `141 passed`를 확인했습니다.
|
||||
- 회귀 보강 완료: wire/config 경계, migration, 재시작, KIS rollover/row 미가시성, 자동취소 및 기존 partial/full-fill·ledger 경로를 포함한 focused suite `154 passed`.
|
||||
- 구현 commit 생성: `147873cb5d5280a99872aeca0e9b44942b41b3df`. 팀장 재검증에서 핵심 focused `60 passed`, 전체 `1,038 passed, 51 skipped`, Ruff/ProofLine/diff/show check 통과. 환경 의존 integration 51건은 당시 설정 부재로 skip됐습니다. 1차 독립 QC는 FAIL로 반려됐습니다: legacy row의 현재 날짜 fallback으로 인한 terminal 오인 위험과 `REQUESTED` 후 terminal 미확정 CRITICAL 누락을 보완 중입니다. 최종 검증은 미완료입니다.
|
||||
- QC 보완 commit `843df1d6f1a642282039a57d0de4ca85204d6bee` 생성: legacy metadata 없는 주문의 현재일 조회·terminal 오인을 차단하고, `REQUESTED` 후 terminal unresolved CRITICAL/dedupe를 추가했습니다. post-fix 팀장 검증은 focused `114 passed, 1 skipped`, 전체 `1,040 passed, 51 skipped`, Ruff/ProofLine/diff/show check 통과. 독립 재-QC 진행 중입니다.
|
||||
- 독립 재-QC 최종 **PASS** (`mutation=false`): focused `148 passed, 1 skipped`, 전체 `1,040 passed, 51 skipped`, Ruff/ProofLine/diff check 통과. legacy adversarial probe에서 broker poll/cancel/transition/ledger가 모두 0이고 pending 및 sanitized CRITICAL alarm을 확인했습니다. skip은 PostgreSQL 41건(DSN 없음), RabbitMQ 9건(URL 없음), KIS sandbox 1건(credential 없음)입니다. 신규 finding은 없습니다.
|
||||
- 최종 HEAD `843df1d6f1a642282039a57d0de4ca85204d6bee`, worktree clean. push/PR/main 반영/실제 주문/배포는 수행하지 않았습니다.
|
||||
|
||||
<!-- END ORIGINAL COMMENT BODY -->
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
id: "line-0003"
|
||||
---
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: "req-0003"
|
||||
status: approved
|
||||
discovery: "dcy-0003"
|
||||
criteria:
|
||||
create:
|
||||
- "ac-0009"
|
||||
- "ac-0010"
|
||||
- "ac-0011"
|
||||
- "ac-0012"
|
||||
- "ac-0013"
|
||||
update: []
|
||||
retire: []
|
||||
satisfy: []
|
||||
---
|
||||
|
||||
# 주문 기본 timeout 상한과 장마감 자동취소·부분체결 reconciliation
|
||||
|
||||
## Objective
|
||||
|
||||
모든 매수·매도 주문에 최대 3,600초의 실행 timeout을 적용하고, timeout 이후에도 KIS의 authoritative 주문 상태가 확인될 때까지 슬롯을 fail-closed로 보호한다. 원주문 시각·주문일을 영속화하여 날짜 rollover와 daemon 재시작 뒤에도 동일 주문을 재조정하며, 장마감 자동취소와 무체결·부분체결·전량체결 경합을 슬롯 및 ledger에 멱등하게 반영한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- 매수·매도 intent의 timeout 생략 또는 `null`, 인스턴스 기본값 override, 전략·설정 입력 경계와 주문 제출 전 거부를 정의한다.
|
||||
- effective timeout, immutable 주문 제출 시각과 KST 원주문 날짜를 슬롯 저장소에 영속화하고 in-memory/PostgreSQL 구현 및 migration의 동작을 일치시킨다.
|
||||
- timeout을 슬롯 terminal 전이가 아닌 취소/reconciliation 시작 조건으로 사용하고, 원주문 날짜를 사용하는 KIS 주문조회를 terminal authority로 유지한다.
|
||||
- 취소 `REQUESTED`, `REJECTED`, `UNKNOWN`과 주문 row 미가시성을 nonterminal로 처리하며, open 주문 evidence가 있을 때만 제한된 취소 재시도를 허용한다.
|
||||
- 미확정 주문은 pending 상태로 유지하고 sanitized CRITICAL alarm을 중복 폭주 없이 발행하며, daemon 재시작 뒤 reconciliation을 계속한다.
|
||||
- KIS 장마감 자동취소의 무체결·부분체결·전량체결 경합을 broker 잔고와 deterministic execution identity에 따라 슬롯 및 ledger에 반영한다.
|
||||
- repository-local synthetic KIS fixture와 application·adapter·persistence 회귀 테스트로 매수·매도, 날짜 rollover, 재시작, visibility grace, retry와 멱등성을 검증한다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- timeout은 `1..3600`의 strict integer만 허용하며 범위 밖 또는 다른 타입을 clamp하거나 묵시적으로 변환하지 않는다.
|
||||
- KIS cancel 응답이나 로컬 시간 경과만으로 체결·취소·보유량 0 또는 슬롯 재사용 가능 상태를 추론하지 않는다.
|
||||
- legacy pending row에 authoritative 원주문 metadata가 없으면 현재 날짜를 추정하지 않고 네트워크 조회·terminal 전이·cancel·ledger 기록을 차단한다.
|
||||
- account, token, credential, connection string, raw KIS payload와 exception 본문 등 민감하거나 비허용인 값을 alarm·로그·fixture·evidence에 포함하지 않는다.
|
||||
- portfolio-hub, strategy daemon 및 다른 TREX 서비스의 wire/HTTP 계약을 변경하지 않는다.
|
||||
|
||||
### Current Implementation Assessment
|
||||
|
||||
- 이 문서는 이미 구현된 GitHub issue #58을 현재 ProofLine schema에 사후 명세화한 것이다. 이는 과거 구현이 ProofLine delivery chronology를 따랐다는 뜻이 아니다.
|
||||
- 현재 구현 기준은 local commit `843df1d6f1a642282039a57d0de4ca85204d6bee`이며, issue evidence에는 전체 `1,040 passed, 51 skipped`, 독립 read-only QC `PASS`가 기록되어 있다.
|
||||
- 환경 의존 PostgreSQL 41건, RabbitMQ 9건, KIS sandbox 1건은 설정·credential 부재로 skip되었다. 이는 active AC의 규범적 상태와 별개인 비차단 검증 공백이다.
|
||||
- 이 REQ의 `approved`와 AC의 `active`는 현재 specification authority를 나타내며 push, PR, main 반영, deployment, release 또는 formal lifecycle closure를 의미하지 않는다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- KIS 거래소의 주문 유효기간·장마감 자동취소 정책 변경
|
||||
- 전략의 매수·매도 조건, 가격·slippage·주문수량 정책 변경
|
||||
- portfolio-hub ledger schema 또는 다른 TREX 서비스 계약 변경
|
||||
- 운영계좌 실제 주문·취소, 운영 데이터 수정 또는 기존 pending 슬롯의 자동 사후 정리
|
||||
- issue #45의 KIS account snapshot fallback 및 장 시작 REST 장애 복구
|
||||
- terminal broker evidence 없는 유예시간 기반 강제 슬롯 해제
|
||||
- timeout 범위 밖 값을 clamp하는 호환 동작
|
||||
- push, PR, main 반영, 배포, release 또는 lifecycle closure
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
id: "dcy-0004"
|
||||
status: confirmed
|
||||
---
|
||||
# requester-versioned·runtime-locked image publication
|
||||
|
||||
## Problem
|
||||
|
||||
현재 `.github/workflows/publish-image.yml`은 `main`과 `v*` push 모두에서 GHCR publication을 실행한다. 이 때문에 문서·governance-only `main` commit도 새 registry artifact를 만들며, publication을 요청자가 선택한 release version과 분리해서 통제할 수 없다.
|
||||
|
||||
repository verification은 `uv sync --extra dev --locked`를 사용하지만 현재 `Dockerfile`은 `pyproject.toml`만 복사한 뒤 `pip install .`을 실행한다. 따라서 CI 검증 환경과 built image의 runtime dependency graph가 동일한 repository-owned `uv.lock` resolution을 사용한다는 보장이 없다.
|
||||
|
||||
이 Discovery는 기존의 single build, built-image smoke, image ID, archive SHA-256, canary scan, OCI source revision, rebuild 없는 publish와 multi-tag same-digest 계약을 유지하면서 publication authority를 strict requester-selected version tag로 제한하고 runtime dependency identity를 lockfile에 결속한다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- GitHub issue #57 `https://github.com/fourblocks2020/trex-core/issues/57`은 requester-versioned·runtime-locked publication 변경과 완료 조건을 기록한 원본 제안이다.
|
||||
- 요청자는 모든 OQ에서 정확히 하나의 선택지를 선택한 뒤 이슈 본문 갱신과 Discovery 승인을 명시적으로 지시했다.
|
||||
- 승인 결정에 따라 `vX.Y.Z` Git tag는 container publication version authority이며 `pyproject.toml`의 `[project].version`과 독립적이다. 두 값을 일치시키는 검증이나 package version 변경은 요구하지 않는다.
|
||||
- 승인 결정에 따라 별도 비게시 CI를 추가하지 않는다. full Ruff와 full pytest는 strict version-tag publication gate에서 실행하고, 일반 `main`/branch push에는 이 Discovery가 별도 CI workflow를 추가하지 않는다.
|
||||
- 조사 기준 source revision은 `1a2b50072238ea72186d99b3ec951fc9511100aa`이며 조사 시 `main`과 `origin/main`은 동기화되어 있고 worktree는 clean이었다.
|
||||
- `.github/workflows/publish-image.yml`은 현재 `main`과 `v*` push를 모두 trigger로 사용하고, exact `github.sha` checkout 후 `uv sync --extra dev --locked`, full Ruff, full pytest, single Docker build, built-image smoke, image ID 기록, archive export·canary scan·SHA-256 기록과 immutable artifact handoff를 수행한다. 별도 publish job만 `packages: write`를 가지며 tested artifact를 재빌드 없이 게시한다.
|
||||
- `Dockerfile`은 현재 `pyproject.toml`과 `trex_core/`만 copy한 뒤 `pip install --no-cache-dir .`을 실행하므로 `uv.lock`을 runtime dependency installation input으로 사용하지 않는다.
|
||||
- `scripts/image_release.py::release_tags()`는 현재 `refs/heads/main`을 허용하고 `v`로 시작하는 넓은 tag 형식을 허용한다. publish helper는 archive checksum, tested/loaded image ID, OCI revision, registry credential 경계, 두 tag의 same-digest와 성공 evidence 생성을 검증하지만 strict `vX.Y.Z` event만 허용하지는 않는다.
|
||||
- `tests/release/test_image_contracts.py`는 현재 `main`/`v*` trigger와 Dockerfile의 기존 COPY allowlist를 고정하고 있어 승인된 trigger·runtime lock 계약에 맞춰 변경해야 한다.
|
||||
- `docs/operations.md`는 source-derived tag를 stable selector로, registry digest를 immutable deployment/rollback identity로 설명하고 runtime config·secret 외부 주입 및 reset 분리 경계를 보존한다. version tag 생성·push가 별도 사용자 승인 외부 쓰기라는 절차는 아직 설명하지 않는다.
|
||||
- 기존 active AC 중 AC-0004는 `main` 또는 `v*` event의 verification-gated publication, AC-0006은 source tag·release tag·digest 추적성, AC-0008은 Docker COPY allowlist와 secret/config 비포함을 소유한다. AC-0005의 tested-artifact identity와 AC-0007의 built-container smoke는 이번 변경에서도 의미를 유지한다.
|
||||
- 비교 기준 `trex-mega-swing-v2` revision `88e90caea7e4ab740ed785fc1f57aed8b6f2da5f`의 workflow는 strict `vX.Y.Z` tag-only trigger, event/ref/commit fail-closed validation, exact tagged commit checkout, locked verify, single build와 tested-artifact publication을 사용한다. workflow run `31468042611`은 `v0.1.0` tag에서 성공했다.
|
||||
- 기준 revision에서 `proofline validate`는 exit `0`, `uv run ruff check .`은 `All checks passed!`, `uv run pytest -q`는 `1040 passed, 51 skipped`를 반환했다. skip은 환경 의존 integration 경계이며 현재 repository baseline이 승인된 변경을 이미 구현했다는 의미는 아니다.
|
||||
|
||||
## Scope
|
||||
|
||||
- AC admission은 기존 동작의 권한·식별자·build input을 변경하는 `update`와 새로운 runtime lock 동작을 정의하는 `create`다.
|
||||
- `update`: AC-0004의 publication event를 일반 `main`/`v*`에서 strict version-tag-only로 변경하고 malformed/non-version event가 registry credential·write 전에 실패하도록 한다.
|
||||
- `update`: AC-0006에 strict requester-selected `vX.Y.Z` version tag, source-derived tag와 version tag의 same-digest, version을 포함한 publication evidence를 반영한다.
|
||||
- `update`: AC-0008의 좁은 Docker build input allowlist에 repository-owned `uv.lock`과 locked installation에 필요한 도구만 추가하면서 기존 config·credential·canary 비포함 경계를 보존한다.
|
||||
- `create`: repository verification과 runtime image가 동일한 repository-owned lock resolution을 사용하고 lock mismatch 또는 unlocked runtime install을 거부하는 독립적인 장기 AC를 추가한다.
|
||||
- AC-0005의 tested-artifact identity와 AC-0007의 built-container package·entrypoint smoke는 변경 없이 유지한다.
|
||||
- GHCR publication workflow의 유일한 trigger는 requester가 exact source commit에 생성·push한 strict `vX.Y.Z` tag다. 각 숫자 구성요소는 `0` 또는 leading zero가 없는 non-negative decimal integer여야 하며 prerelease/build suffix, malformed tag, branch ref와 non-push event는 허용하지 않는다.
|
||||
- workflow는 event·ref·40자리 lowercase source commit을 registry credential 사용 전에 fail-closed로 검증하고, exact tagged commit을 checkout했음을 다시 확인한다.
|
||||
- version tag는 container publication identity이며 `pyproject.toml`의 `[project].version`과 독립적으로 취급한다. package version 일치 검사, 자동 version 증분과 package metadata 변경을 수행하지 않는다.
|
||||
- 일반 `main`과 branch push는 registry login, `docker push`, `packages: write` 또는 image publication을 수행하지 않는다. 요청자 결정에 따라 이번 Scope는 일반 `main`/branch push를 위한 별도 비게시 CI workflow를 추가하지 않는다.
|
||||
- 허용된 version-tag event는 exact source checkout 후 pinned `uv`와 repository-owned `uv.lock`으로 full Ruff와 full pytest를 실행하고, 성공한 경우에만 이미지를 한 번 빌드한다.
|
||||
- Docker build는 `pyproject.toml`, `uv.lock`과 allowlisted package source로 runtime environment를 `uv sync --locked --no-dev --no-editable` 또는 의미상 동등한 locked 방식으로 설치한다. lock mismatch, missing lockfile 또는 lock을 우회한 runtime dependency install은 build/publication 전에 실패한다.
|
||||
- 동일한 tested image에 `:<source-commit-first-12>`와 `:vX.Y.Z`를 부여하고 두 tag가 동일한 registry digest를 가리키는지 확인한다. immutable deployment·rollback identity는 `@sha256:<digest>`다.
|
||||
- 기존 `scripts/image_release.py`의 archive checksum, tested/loaded image ID, OCI revision, canary surface scan, no-rebuild publication, registry credential 경계와 multi-tag same-digest 검증을 유지한다.
|
||||
- `latest`를 생성하지 않으며 성공 evidence에는 source revision, immutable image reference, digest와 selected version tag를 함께 기록한다.
|
||||
- workflow contract와 helper tests는 일반 branch/main non-publication, strict event validation, exact checkout, lock mismatch, locked runtime build, same-digest tags, no-`latest`, no-rebuild 및 기존 failure boundaries를 검증한다.
|
||||
- operator documentation은 version 선택과 Git tag 생성·push가 구현과 분리된 사용자 승인 외부 쓰기이고, tag push가 verify/publication을 시작한다는 점을 설명한다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 이 Discovery 또는 후속 구현 중 concrete version 선택, version tag 생성·push, 최초·후속 GHCR package write, production deployment 또는 운영 서버 pull/restart
|
||||
- 일반 `main`/branch push를 위한 별도 lint/test CI workflow 신설 또는 기존 publication workflow 외 CI 정책 변경
|
||||
- `pyproject.toml` package version 변경, version tag와 package version의 일치 강제 또는 자동 version 증분
|
||||
- 기존 published tag·digest 삭제, 이동 또는 registry retention 변경
|
||||
- live credential, 실제 account·database·RabbitMQ·Redis·KIS·portfolio-hub 연결, database migration/reset 또는 운영 데이터 변경
|
||||
- image signing, SBOM, provenance attestation, multi-architecture build와 moving `latest`
|
||||
- 다른 TREX repository의 workflow, image 또는 release policy 변경
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
### Risks
|
||||
|
||||
- trigger 변경 후 일반 `main` push는 새 runtime image뿐 아니라 현재 publication workflow가 제공하던 full Ruff/pytest도 실행하지 않는다. 이는 요청자가 선택한 정책이며, branch protection 또는 지속적 검증이 필요해지면 registry 권한이 없는 별도 CI 변경으로 다룬다.
|
||||
- version tag와 package version을 독립적으로 두면 image `:vX.Y.Z`와 installed Python package metadata version이 다를 수 있다. container publication version authority는 Git tag임을 문서화하고, package version synchronization이 필요해지면 별도 변경으로 다룬다.
|
||||
- GitHub trigger glob만으로는 malformed `v*.*.*`를 모두 배제할 수 없다. workflow 내부 helper가 strict ref/event/commit을 registry credential 전에 재검증해야 한다.
|
||||
- Docker build가 lockfile을 copy만 하고 실제 runtime install에 소비하지 않으면 CI lock PASS만으로 runtime reproducibility를 주장할 수 없다. build contract와 tests가 locked runtime command 및 mismatch failure를 직접 검증한다.
|
||||
- version tag와 source-derived tag를 별도 build에서 만들면 같은 release identity가 서로 다른 image를 가리킬 수 있다. 단일 tested artifact를 재빌드 없이 두 tag로 push하고 registry digest equality를 확인한다.
|
||||
- 기존 commit-derived selector와 immutable digest는 보존해야 하며 migration 과정에서 과거 tag를 삭제하거나 이동하지 않는다.
|
||||
- tag 생성·push는 즉시 publication workflow와 registry write를 유발할 수 있다. operator 문서가 이를 별도 사용자 승인 외부 쓰기로 명시하고 구현·테스트 중 실제 tag push를 금지한다.
|
||||
|
||||
### Approval State
|
||||
|
||||
- Discovery status: `confirmed`
|
||||
- 승인 근거: 요청자가 이슈의 모든 OQ에서 하나의 선택지를 명시적으로 선택한 뒤 이슈 본문 업데이트와 승인을 지시했다.
|
||||
- 채택 결정: version tag와 package version은 독립적으로 취급하며, 일반 `main`/branch push용 별도 비게시 CI는 추가하지 않는다.
|
||||
- 추가로 필요한 제품 결정: 없음
|
||||
- 이 승인은 Discovery 범위 확인이며 Requirement/AC 승인, 구현, tag 생성·push, GHCR publication, delivery, release 또는 deployment 완료를 의미하지 않는다.
|
||||
@@ -0,0 +1,59 @@
|
||||
# 구현 완료 evidence
|
||||
|
||||
이 파일은 ProofLine canonical lifecycle artifact가 아닌 GitHub issue comment 원문 evidence입니다. 기록된 구현·테스트 결과는 로컬 기능 브랜치의 사후 증거이며 ProofLine delivery closure, publication, release 또는 deployment 완료를 의미하지 않습니다.
|
||||
|
||||
- Source URL: https://github.com/fourblocks2020/trex-core/issues/57#issuecomment-5290864375
|
||||
- Comment ID: `5290864375`
|
||||
- Node ID: `IC_kwDOSvywKc8AAAABO1wu9w`
|
||||
- Author: `genichin`
|
||||
- Created at: `2026-08-14T07:47:03Z`
|
||||
- Updated at: `2026-08-14T07:47:03Z`
|
||||
- Original body SHA-256: `2a57a0b702957cb534cc9aa35e08618e76e38d9da5d1926e8055f84973783b51`
|
||||
- Captured from GitHub API: `2026-08-14T16:51:15+09:00`
|
||||
|
||||
## Original Comment Body
|
||||
|
||||
<!-- BEGIN ORIGINAL COMMENT BODY -->
|
||||
## 구현 완료
|
||||
|
||||
승인된 #57 범위의 requester-versioned·runtime-locked image publication 구현과 독립 검증을 완료했습니다.
|
||||
|
||||
### 구현 내용
|
||||
|
||||
- GHCR publication trigger를 strict `vX.Y.Z` tag push로 제한하고 일반 `main`/branch push, non-push event, malformed·leading-zero·prerelease/build tag를 fail-closed 처리
|
||||
- exact `${GITHUB_SHA}` checkout 후 실제 `git rev-parse HEAD`를 다시 비교하여 source identity 검증
|
||||
- Git tag version을 `pyproject.toml` package version과 독립적인 container publication authority로 유지
|
||||
- Docker runtime dependency 설치가 repository-owned `uv.lock`을 실제로 소비하도록 `uv sync --locked --no-dev --no-editable` 적용
|
||||
- 기존 full Ruff·full pytest publication gate와 single build, built-image smoke, tested image ID, archive SHA-256, canary scan, OCI revision, no-rebuild publish 계약 보존
|
||||
- source-derived tag와 version tag를 같은 tested image에 부여하고 동일 registry digest를 가리키는지 검증
|
||||
- 성공 output·summary에 immutable image reference, digest, source commit과 selected version tag를 함께 기록하고 `latest` 생성 금지
|
||||
- operator documentation에 version tag 생성·push가 구현과 분리된 별도 사용자 승인 외부 쓰기임을 명시
|
||||
- 초기 후보의 checkout 재검증·version evidence 누락을 별도 후속 커밋에서 보완하여 이력을 보존
|
||||
|
||||
### 검증 결과
|
||||
|
||||
Release 집중·전체 회귀, 정적 검사, ProofLine validation, Docker build/smoke와 독립 read-only 리뷰를 모두 통과했으며 승인 차단 finding은 없습니다.
|
||||
|
||||
- 최종 HEAD: `e3fa8243d926efd0b81463dbfc5403cf6e4889f9`
|
||||
- 최종 tree: `ea05e23139965abed1f6a454c4ca2b5d356f8ab9`
|
||||
- Release 집중 테스트: `75 passed`
|
||||
- 전체 테스트: `1065 passed, 51 skipped`
|
||||
- 전체 Ruff: `All checks passed!`
|
||||
- `proofline validate`: exit `0`
|
||||
- `git diff --check`: PASS
|
||||
- checkout mismatch fail-closed 실동작: PASS
|
||||
- Docker build, built-image smoke, image ID, archive export·checksum, canary scan: PASS
|
||||
- image user `10001:10001`, `trex-core-daemon` entrypoint와 OCI source revision 확인
|
||||
- 독립 read-only 리뷰: **PASS**, `mutation=false`, 승인 차단 finding 없음
|
||||
- 기능 브랜치 `feat/issue-57-versioned-locked-publication`과 worktree: clean
|
||||
|
||||
### 비차단 후속 권고
|
||||
|
||||
- `TREX_POSTGRESQL_TEST_DSN`이 준비되면 PostgreSQL integration 41건을 실제 DB에서 재실행
|
||||
- `TREX_RABBITMQ_TEST_URL`이 준비되면 RabbitMQ integration 9건을 재실행
|
||||
- 안전한 KIS sandbox credentials가 준비되면 sandbox integration 1건을 재실행
|
||||
- 로컬에 `actionlint`를 마련하거나 향후 GitHub workflow 실행에서 Actions-level YAML·shell 해석을 추가 확인
|
||||
- version tag 생성·push와 GHCR publication은 별도 사용자 승인 후 수행하고, 게시 결과의 source/version tag가 동일 digest를 가리키는지 실제 registry evidence로 확인
|
||||
|
||||
> 현재 상태는 **격리 기능 브랜치의 로컬 구현·검증·커밋 완료**입니다. push·PR·main 반영·version tag 생성·GHCR 게시·배포·릴리스 완료를 의미하지 않습니다.
|
||||
<!-- END ORIGINAL COMMENT BODY -->
|
||||
@@ -0,0 +1,63 @@
|
||||
# 구현 진행 상황 evidence
|
||||
|
||||
이 파일은 ProofLine canonical lifecycle artifact가 아닌 GitHub issue comment 원문 evidence입니다. 기록된 구현·테스트 결과는 로컬 기능 브랜치의 사후 증거이며 ProofLine delivery closure, publication, release 또는 deployment 완료를 의미하지 않습니다.
|
||||
|
||||
- Source URL: https://github.com/fourblocks2020/trex-core/issues/57#issuecomment-5290665935
|
||||
- Comment ID: `5290665935`
|
||||
- Node ID: `IC_kwDOSvywKc8AAAABO1knzw`
|
||||
- Author: `genichin`
|
||||
- Created at: `2026-08-14T07:22:00Z`
|
||||
- Updated at: `2026-08-14T07:41:51Z`
|
||||
- Original body SHA-256: `c9631a1402a51de699676a4d8791c206e9b6f4e197d134e16027cdc179f96c5f`
|
||||
- Captured from GitHub API: `2026-08-14T16:51:15+09:00`
|
||||
|
||||
## Original Comment Body
|
||||
|
||||
<!-- BEGIN ORIGINAL COMMENT BODY -->
|
||||
## 구현 진행 상황
|
||||
|
||||
- **대상:** [Discovery] requester-versioned·runtime-locked image publication
|
||||
- **브랜치:** `feat/issue-57-versioned-locked-publication`
|
||||
- **기준:** `main` (`1a2b50072238ea72186d99b3ec951fc9511100aa`)
|
||||
- **상태:** 🟢 로컬 구현·검증 완료 — 독립 리뷰 PASS
|
||||
- **최종 업데이트:** 2026-08-14 16:41:50 KST
|
||||
|
||||
### 구현 계획 및 작업 목록
|
||||
|
||||
- [x] **1. 기존 게시 안전장치와 변경 영향 범위 확인**
|
||||
- tag trigger, 검증 순서, tested-image handoff, digest·canary·secret 경계와 기존 회귀 테스트를 기준선으로 고정합니다.
|
||||
- [x] **2. 요청자 버전 태그 전용 게시 흐름 구현**
|
||||
- strict `vX.Y.Z` tag push만 허용하고 일반 `main`/branch push와 malformed event는 registry write 전에 차단합니다.
|
||||
- [x] **3. 컨테이너 runtime dependency lock 적용**
|
||||
- repository의 `uv.lock`을 실제 runtime 설치에 사용하고 lock mismatch·unlocked 설치를 fail-closed 처리합니다.
|
||||
- [x] **4. 이미지 식별자·게시 근거와 운영 문서 갱신**
|
||||
- source tag와 version tag의 same-digest, no-`latest`, source/version/digest evidence 및 tag push 승인 경계를 반영합니다.
|
||||
- [x] **5. 회귀 테스트와 전체 검증 수행**
|
||||
- release contract 집중 테스트, 전체 Ruff, 전체 pytest, ProofLine validation과 가능한 local image build/smoke를 실제 실행합니다.
|
||||
- [x] **6. 독립 검토와 최종 구현 근거 정리**
|
||||
- 구현자와 분리된 read-only 검토를 받고, 반려 시 보완·재검증한 뒤 최종 commit과 검증 결과를 기록합니다.
|
||||
|
||||
### 진행 기록
|
||||
|
||||
- 승인된 #57 Discovery 본문과 OQ 결정(A: package version 독립, B: 일반 main/branch용 별도 CI 미추가)을 구현 범위의 기준선으로 고정했습니다.
|
||||
- 격리 worktree와 기능 브랜치를 `1a2b50072238ea72186d99b3ec951fc9511100aa`에서 생성했습니다.
|
||||
- 기준선 확인 완료: 기존 workflow의 single build→smoke→image ID→archive checksum/canary scan→tested artifact handoff→no-rebuild publish와 publish-job-only `packages: write` 경계를 확인했습니다.
|
||||
- AC 영향 범위 확인: AC-0004·AC-0006·AC-0008 update, runtime lock AC create, AC-0005·AC-0007 유지 경계로 고정했습니다.
|
||||
- 변경 전 기준선 검증: `proofline validate` PASS, 전체 Ruff PASS, 전체 pytest `1040 passed, 51 skipped`; 기준 branch와 worktree는 clean입니다.
|
||||
- version tag 생성·push, GHCR publication, PR, main 반영, release와 deployment는 수행하지 않습니다.
|
||||
- 초기 구현 commit `cb8fcee226ab71f522fe1bbef35d60197947850c` 생성: strict tag-only trigger와 runtime `uv.lock` 설치, 기존 release safety contract 및 운영 문서 변경을 포함합니다.
|
||||
- 초기 후보 검증: release 집중 `71 passed`, 전체 `1061 passed, 51 skipped`, Ruff·ProofLine·diff check PASS. skip은 PostgreSQL 41건, RabbitMQ 9건, KIS sandbox 1건입니다.
|
||||
- 팀장 diff 검토에서 승인 계약 누락 2건을 확인해 최종 완료를 보류했습니다: (1) checkout 후 실제 `HEAD == GITHUB_SHA` 재검증 없음, (2) 성공 output/summary에 selected version tag evidence 없음.
|
||||
- 위 2건은 계획 변경이 아니라 승인된 #57의 결함 보완이며, 초기 후보 위에 별도 수정 commit을 만들고 전체 검증·독립 리뷰를 다시 수행합니다.
|
||||
- 보완 commit `e3fa8243d926efd0b81463dbfc5403cf6e4889f9` 생성: checkout HEAD 재검증과 selected version tag output/summary evidence를 추가했고 초기 커밋은 보존했습니다.
|
||||
- 팀장 최종 후보 재검증: release 집중 `75 passed`, 전체 `1065 passed, 51 skipped`, Ruff·ProofLine·diff check PASS, checkout mismatch fail-closed 실동작 확인.
|
||||
- Docker 재검증: built-image smoke, image ID, archive export/checksum, canary scan PASS; non-root `10001:10001`, daemon entrypoint와 OCI revision을 확인했습니다.
|
||||
- 구현·검증 항목 2~5를 완료했으며, 최종 후보 `e3fa824`에 고정한 독립 read-only 리뷰를 진행합니다.
|
||||
- 독립 read-only 리뷰 최종 **PASS** (`mutation=false`, target `e3fa8243d926efd0b81463dbfc5403cf6e4889f9`): release `75 passed`, 전체 `1065 passed, 51 skipped`, Ruff·ProofLine·diff check PASS, 승인 차단 finding 없음.
|
||||
- skip 분류: PostgreSQL integration 41건(DSN 없음), RabbitMQ integration 9건(URL 없음), KIS sandbox 1건(credentials 없음).
|
||||
- 독립 리뷰의 비차단 참고: 새 review environment는 `uv sync --extra dev --locked` 후 검증했으며, candidate Docker build/registry write는 리뷰에서 수행하지 않았습니다. Docker contract는 구현자와 팀장이 별도로 실제 build/smoke/scan으로 확인했습니다.
|
||||
- 최종 기능 브랜치 HEAD와 tree는 각각 `e3fa8243d926efd0b81463dbfc5403cf6e4889f9`, `ea05e23139965abed1f6a454c4ca2b5d356f8ab9`; worktree clean. 임시 detached review worktree는 검토 후 정리했습니다.
|
||||
- 현재 완료 경계는 격리 기능 브랜치의 로컬 구현·검증·커밋입니다. push, PR, main 반영, tag 생성·push, GHCR publication, release와 deployment는 수행하지 않았습니다.
|
||||
|
||||
> 각 작업이 실제로 완료되고 검증될 때마다 이 댓글의 체크박스와 진행 기록을 갱신한다. 체크 표시는 구현 완료 또는 해당 검증의 실제 통과를 의미하며, 배포·릴리스 완료를 의미하지 않는다.
|
||||
<!-- END ORIGINAL COMMENT BODY -->
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
id: "line-0004"
|
||||
---
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: "req-0004"
|
||||
status: approved
|
||||
discovery: "dcy-0004"
|
||||
criteria:
|
||||
create:
|
||||
- "ac-0014"
|
||||
update:
|
||||
- "ac-0004"
|
||||
- "ac-0006"
|
||||
- "ac-0008"
|
||||
retire: []
|
||||
satisfy: []
|
||||
---
|
||||
|
||||
# requester-versioned·runtime-locked image publication
|
||||
|
||||
## Objective
|
||||
|
||||
GHCR image publication authority를 요청자가 exact source commit에 생성·push하는 strict `vX.Y.Z` Git tag로 제한하고, repository verification과 container runtime installation이 동일한 repository-owned `uv.lock` resolution을 사용하게 한다. 기존의 검증 선행, single build, built-image smoke, tested artifact identity, canary·secret 방어, no-rebuild publication과 multi-tag same-digest 계약은 유지한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- GHCR publication workflow의 유일한 trigger를 strict `vX.Y.Z` tag push로 제한한다. 숫자 구성요소는 `0` 또는 leading zero 없는 non-negative decimal integer이며 prerelease/build suffix, malformed tag, branch ref와 non-push event를 거부한다.
|
||||
- event, ref와 40자리 lowercase source commit을 registry credential 또는 write 전에 fail-closed로 검증하고, exact `${GITHUB_SHA}` checkout 뒤 실제 `HEAD`를 다시 비교한다.
|
||||
- strict version-tag event에서 pinned `uv`와 repository-owned `uv.lock`으로 full Ruff와 full pytest를 실행하고, 성공한 경우에만 이미지를 한 번 빌드한다.
|
||||
- 일반 `main`과 branch push에서는 registry login, `docker push`, `packages: write`와 image publication을 수행하지 않으며, 요청자 결정에 따라 별도 비게시 CI workflow를 추가하지 않는다.
|
||||
- Docker runtime environment는 `pyproject.toml`, `uv.lock`과 allowlisted package source만으로 `uv sync --locked --no-dev --no-editable` 또는 의미상 동등한 locked install을 수행한다. missing/mismatched lockfile 또는 unlocked install은 build/publication 전에 실패한다.
|
||||
- single built image에 source commit 앞 12자리 tag와 selected `vX.Y.Z` tag를 부여하고 두 push 결과가 동일 digest인지 확인한다. `latest`는 생성하지 않는다.
|
||||
- archive checksum, tested/loaded image ID, OCI revision, built-image package·CLI·migration smoke, canary surface scan, artifact handoff와 publish-job-only registry credential 경계를 유지한다.
|
||||
- push 성공과 digest 검증 뒤에만 immutable image reference, digest, 전체 source commit과 selected version tag를 output·summary evidence로 기록한다. push 후 오류를 registry write 미발생으로 표현하지 않는다.
|
||||
- operator documentation은 container publication version authority가 Git tag이고 package metadata version과 독립임을 설명하며, version 선택과 tag 생성·push가 구현·검증과 분리된 requester-approved external write임을 명시한다.
|
||||
- workflow/parser/helper/Docker contract tests는 strict trigger, exact checkout, lock mismatch, locked runtime install, same-digest/no-`latest`, no-rebuild와 기존 실패 경계를 독립적으로 검증한다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Git tag version과 `pyproject.toml`의 `[project].version`을 일치시키거나 자동 증분하지 않는다.
|
||||
- 기존 commit-derived selector와 immutable digest를 보존하고 기존 published tag·digest를 삭제하거나 이동하지 않는다.
|
||||
- repository 전체를 Docker build input으로 copy하지 않으며 runtime config, `.env`, credential, private key, 실제 connection string, ProofLine artifact와 synthetic canary를 image에 포함하지 않는다.
|
||||
- package write permission과 registry credential은 검증 job이 아닌 gated publish job에만 부여한다.
|
||||
- 실제 account·database·RabbitMQ·Redis·KIS·portfolio-hub 연결, 운영 migration/reset과 다른 TREX repository 계약을 변경하지 않는다.
|
||||
- Normative Design Documents: 없음. 이 REQ와 active AC가 normative specification을 소유한다.
|
||||
|
||||
### Current Implementation Assessment
|
||||
|
||||
- 이 문서는 이미 로컬 기능 브랜치에서 구현된 GitHub issue #57을 현재 ProofLine schema에 사후 명세화한 것이다. 과거 구현이 ProofLine delivery chronology를 따랐다는 뜻이 아니다.
|
||||
- 구현은 `feat/issue-57-versioned-locked-publication`의 local commit `e3fa8243d926efd0b81463dbfc5403cf6e4889f9`(tree `ea05e23139965abed1f6a454c4ca2b5d356f8ab9`)에 있으며, baseline `1a2b50072238ea72186d99b3ec951fc9511100aa` 위 두 개의 append-only commit으로 기록되었다. 현재 local `main`과 `origin/main`에는 아직 반영되지 않았다.
|
||||
- issue comment evidence에는 release 집중 `75 passed`, 전체 `1065 passed, 51 skipped`, Ruff·ProofLine·diff check, checkout mismatch fail-closed, Docker build·smoke·image ID·archive checksum·canary scan과 독립 read-only review `PASS`가 기록되어 있다.
|
||||
- 환경 의존 PostgreSQL 41건, RabbitMQ 9건, KIS sandbox 1건은 설정·credential 부재로 skip되었다. 로컬 `actionlint`도 실행 파일 부재로 수행되지 않았으며 향후 hosted workflow 실행 또는 별도 도구로 확인할 비차단 검증 공백이다.
|
||||
- version tag 생성·push, GHCR write, PR, main 반영, runtime rollout, deployment와 release는 수행되지 않았다. 따라서 live registry same-digest와 hosted Actions 실행 결과는 현재 `unverified` evidence다.
|
||||
- 이 REQ의 `approved`와 AC의 `active`는 현재 specification authority를 나타내며 구현 통합, publication, delivery closure, release 또는 deployment 완료를 의미하지 않는다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- concrete version 선택, version tag 생성·push, 최초·후속 GHCR package write, production deployment, 운영 서버 pull/restart와 post-deployment verification
|
||||
- 일반 `main`/branch push용 별도 lint/test CI workflow 또는 branch protection 정책 신설
|
||||
- package version 변경, Git tag와 package version 일치 강제 또는 자동 version 증분
|
||||
- 기존 published tag·digest의 삭제·이동 또는 registry retention 변경
|
||||
- image signing, SBOM, provenance attestation, multi-architecture build와 moving `latest`
|
||||
- live credential, 운영 account·database·broker·KIS 연결, database migration/reset 또는 운영 데이터 변경
|
||||
- 다른 TREX repository의 workflow, image, wire/HTTP 계약 또는 release policy 변경
|
||||
- ProofLine delivery/release lifecycle의 소급 완료 주장
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: "dcy-0005"
|
||||
status: confirmed
|
||||
---
|
||||
|
||||
# broker 실행 실패의 기록·상태 수렴 경계
|
||||
|
||||
## Problem
|
||||
|
||||
`trex-core`의 broker execution 경계는 명시적인 주문 거부, timeout·연결 단절·응답 누락처럼 결과가 불명확한 submission, 취소·부분체결·재시작을 서로 다른 authority로 처리해야 한다. 기존 계약이 이 경계를 충분히 명시하지 않으면 core가 broker failure를 retryability로 분류하거나 새 주문 attempt를 자동 생성하여 중복 주문을 만들 수 있고, strategy/TVR이 KIS 실패와 broker retry 판단을 소유하는 잘못된 결합이 생길 수 있다.
|
||||
|
||||
주문 venue도 같은 execution lifecycle에서 의미가 혼재될 위험이 있었다. 누락 venue의 기본값, 명시 `KRX`/`NXT`/`SOR`, 폐기된 `AUTO`, 원주문 routing venue와 실제 KRX/NXT 체결 venue를 구분하지 않으면 wall-clock routing, 취소 시 원주문 venue 손실, ledger/outbox에 실제 거래소 대신 `SOR` 기록, 기존 persisted `AUTO` 및 historical `SOR` row의 잘못된 해석이 발생할 수 있다.
|
||||
|
||||
기대 상태는 core가 broker-authoritative evidence로 상태를 수렴하되 자동 새 주문을 만들지 않고, strategy/TVR에는 core slot snapshot만 노출하며, 원주문 venue와 실제 체결 venue를 각각 durable하게 보존하는 것이다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- 원본 승인 문서: GitHub issue #60 `https://github.com/fourblocks2020/trex-core/issues/60`의 승인된 본문은 명시적 sell rejection, unknown outcome reconciliation, TVR wire 경계, SOR 기본값, AUTO 제거와 자동 retry 금지를 정의한다.
|
||||
- 연계 승인 계약: GitHub issue #61은 venue admission부터 broker request/ack/cancel과 ledger/outbox까지 explicit venue 보존 경계를 정의한다.
|
||||
- 구현 진행 원본: issue #60 comment `https://github.com/fourblocks2020/trex-core/issues/60#issuecomment-5336722059`은 기준 revision, 계획, 구현·검증 진행, 두 차례의 독립 QC blocker와 교정 기록을 포함한다.
|
||||
- 구현 완료 원본: issue #60 comment `https://github.com/fourblocks2020/trex-core/issues/60#issuecomment-5337338182`은 구현 내용, 검증 결과, 로컬 commit 및 push·PR·배포·릴리스·실주문 경계를 기록한다.
|
||||
- 구현 revision은 `15aeae6930f8c3e6ef9cf659f41278b099a78459`, `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`이며 현재 local `main` HEAD는 `359ea098ff717e7f04cf4206dcbc64ad17b47755`다.
|
||||
- `trex_core/application/execution.py`는 명시적 sell 미수락·미체결의 `pending_sell → holding`, 결과 불명확 submission의 durable dispatch sentinel·lookup/reconciliation, fill의 actual venue 확인과 ledger enqueue 경계를 구현한다.
|
||||
- `trex_core/application/broker_port.py`는 original submission/routing venue를 가진 `OrderAck`와 nullable broker-authoritative `FillStatus.execution_venue`를 분리한다.
|
||||
- `trex_core/adapters/broker/kis.py`는 주문 요청의 `KRX`/`NXT`/`SOR`를 보존하고 `ORD_EXG_GB` 및 명시 exchange-id allowlist로 실제 KRX/NXT만 파싱하며 `SOR` 단독·unknown을 추론하지 않는다.
|
||||
- `trex_core/adapters/persistence/sql/023_remove_auto_order_venue.sql`은 persisted `AUTO → SOR` 변환과 이후 AUTO 저장 차단을 수행한다.
|
||||
- `trex_core/adapters/persistence/sql/024_add_ledger_order_venue.sql`은 original `order_venue`와 nullable actual `venue`를 분리하고 historical SOR actual을 NULL로 보존하며 replay-safe NULL-only backfill과 DB constraint를 적용한다.
|
||||
- `trex_core/adapters/persistence/postgresql.py`와 `trex_core/application/ledger_port.py`는 outbox에서 `order_venue`와 actual `venue`를 독립적으로 저장·복원한다.
|
||||
- 구현자 검증은 focused unit `77 passed`, 전체 `1074 passed, 52 skipped`, disposable PostgreSQL 16 `24 passed`, Ruff `All checks passed!`였다.
|
||||
- 최종 독립 read-only QC는 `359ea098ff717e7f04cf4206dcbc64ad17b47755`에서 blocker/critical/major/minor finding 없이 PASS했다. 실제 PostgreSQL 16에서 historical SOR→`order_venue=SOR`+actual NULL, 정상 actual NXT/order KRX의 replay 보존, actual SOR·AUTO·NULL order 거부를 확인했고 worktree는 clean이었다.
|
||||
- 현재 `proofline validate`는 exit 0이다.
|
||||
|
||||
## Scope
|
||||
|
||||
- AC admission은 기존 active AC-0011의 timeout/cancel reconciliation 및 AC-0013의 terminal cancel·partial fill 상태 수렴과 인접하지만, 명시적 submission rejection, strategy wire ownership, venue admission/migration 및 order/execution venue 분리를 독립적으로 검증 가능한 장기 계약으로 추가하는 `create`다. 기존 AC의 의미나 status는 변경하지 않는다.
|
||||
- KIS 조회로 sell 주문의 미수락·미체결이 명시적으로 확인되면 새 broker attempt 없이 실패 evidence를 기록하고 dispatch를 정리하며 broker-authoritative 보유수량으로 `pending_sell → holding` 전이한다.
|
||||
- timeout, 연결 단절, 응답 누락 등 submission 결과가 불명확하면 durable sentinel을 유지하고 새 주문을 생성하지 않으며 broker lookup과 reconciliation을 먼저 수행한다.
|
||||
- core는 broker failure의 retryability, retry schedule, retry budget을 판정하거나 자동 새 broker attempt를 생성하지 않는다. 이후 strategy가 독립적으로 생성한 새 intent는 이 자동 retry 금지와 구분한다.
|
||||
- strategy/TVR에는 KIS failure, raw broker error, broker attempt, retryability를 노출하지 않는다. strategy/TVR은 core가 수렴한 slot snapshot만 소비한다.
|
||||
- intent venue 누락은 `SOR`로 정규화하고 명시 `KRX`/`NXT`/`SOR`는 보존한다. `AUTO`는 admission과 persistence에서 거부하고 기존 persisted AUTO는 SOR로 전환하며 wall-clock venue routing을 수행하지 않는다.
|
||||
- original `order_venue` KRX/NXT/SOR는 submit/ack/slot/restart/cancel에 보존한다. broker-authoritative actual `execution_venue`는 KRX/NXT 또는 미확정 NULL로 별도 취급하고 fill·ledger/outbox에 기록한다.
|
||||
- KIS actual venue는 검증된 `ORD_EXG_GB`와 명시 exchange-id allowlist에서만 KRX/NXT로 변환한다. `SOR` 단독, 누락 또는 미지원 값은 KRX/NXT로 추론하지 않는다.
|
||||
- historical outbox의 SOR는 original order venue로 보존하고 actual venue는 NULL로 전환한다. migration 재실행은 신규 정상 order/execution venue 조합을 오염시키지 않아야 하며 DB는 actual SOR와 invalid order venue를 거부한다.
|
||||
- 관련 focused·전체 회귀, PostgreSQL migration·round-trip, lint와 독립 QC evidence를 구현 상태 평가에 포함한다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- strategy/TVR의 KIS failure 수신, retryability 판단, broker attempt 관리 또는 자동 retry 기능
|
||||
- TVR 세션·portfolio `markets` 판단 구현과 KRX/NXT session policy 자체
|
||||
- 실제 KIS 주문·취소, production credential, live RabbitMQ·database·portfolio-hub 연결
|
||||
- push, PR, 원격 main 반영, image publication, 운영 migration, deployment, GitHub Release 또는 실주문
|
||||
- ProofLine delivery/release lifecycle이 과거 구현 당시 완료되었다는 소급 주장
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
### Risks
|
||||
|
||||
- KIS 응답의 실제 venue 필드가 누락되거나 미지원 코드이면 SOR 주문의 actual venue가 NULL로 남아 ledger enqueue가 fail-closed 될 수 있다. 임의 시간 추론보다 안전하며 운영 관측과 authoritative reconciliation이 필요하다.
|
||||
- migration runner는 history table 없이 SQL을 재실행하므로 migration 024의 NULL-only backfill과 replay-safe 불변식을 이후 변경에서도 유지해야 한다.
|
||||
- 환경 의존 KIS·RabbitMQ 검증은 skip되었으므로 실제 운영 외부 시스템 상호작용은 별도 승인 환경에서 확인해야 한다.
|
||||
|
||||
### Approval State
|
||||
|
||||
- Discovery status: `confirmed`
|
||||
- 승인 근거: 요청자 genichin이 승인 상태인 issue #60 본문을 최대한 보존하여 템플릿 기반 Discovery를 승인된 상태로 정리하라고 명시했다.
|
||||
- 추가로 필요한 제품 결정: 없음
|
||||
- 이 확인은 사후 specification 정리이며 구현·테스트·독립 QC의 실제 evidence는 별도 보존한다. push, PR, 배포, release 또는 실주문 승인을 뜻하지 않는다.
|
||||
@@ -0,0 +1,49 @@
|
||||
# GitHub issue #60 comment evidence
|
||||
|
||||
- Source: https://github.com/fourblocks2020/trex-core/issues/60#issuecomment-5337338182
|
||||
- Author: `genichin`
|
||||
- Created At: `2026-08-19T03:57:52Z`
|
||||
- Evidence Role: repository-local snapshot of a non-canonical implementation comment
|
||||
- Canonical Authority: GitHub source comment; this snapshot does not create ProofLine lifecycle status
|
||||
|
||||
---
|
||||
|
||||
## 구현 완료
|
||||
|
||||
### 구현 내용
|
||||
|
||||
- 명시적 주문 미수락·미체결 sell 실패를 자동 재주문 없이 `pending_sell → holding`으로 수렴시켰다.
|
||||
- submission outcome unknown은 durable sentinel과 broker lookup/reconciliation을 우선하고 중복 제출하지 않는다.
|
||||
- strategy RPC에서 KIS failure·broker attempt·retryability 전달을 제거했다.
|
||||
- venue 누락은 SOR, 명시 KRX/NXT/SOR는 보존하고 AUTO 및 wall-clock routing을 제거했다.
|
||||
- 기존 persisted AUTO를 SOR로 전환하는 migration을 추가했다.
|
||||
- SOR 원주문 venue와 실제 체결 venue를 분리했다.
|
||||
- slot·재시작·취소: `order_venue` KRX/NXT/SOR
|
||||
- fill·ledger/outbox actual venue: KRX/NXT, 복원 불가능한 historical SOR는 NULL
|
||||
- KIS `ORD_EXG_GB` 및 명시 exchange-id allowlist로 실제 KRX/NXT를 파싱하며 SOR/unknown은 추론하지 않는다.
|
||||
- migration replay가 정상 `(actual=NXT, order=KRX)` 행을 오염시키지 않도록 NULL-only backfill과 DB constraint를 적용했다.
|
||||
|
||||
### 실제 검증 결과
|
||||
|
||||
- 구현자 전체 회귀: `1074 passed, 52 skipped`
|
||||
- 구현자 focused unit: `77 passed`
|
||||
- 구현자 disposable PostgreSQL 16: `24 passed`
|
||||
- Ruff: `All checks passed!`
|
||||
- 독립 QC 전체 pytest + PostgreSQL 16 검증: PASS
|
||||
- 독립 QC finding: blocker/critical/major/minor 모두 없음
|
||||
- 독립 QC worktree: clean
|
||||
|
||||
### 로컬 커밋
|
||||
|
||||
- `15aeae6930f8c3e6ef9cf659f41278b099a78459` — broker 실패 수렴·AUTO 제거
|
||||
- `7366cadf03fc6002bfd01ecf0cba7c24486bb618` — SOR 원주문/실제 체결 venue 분리
|
||||
- `359ea098ff717e7f04cf4206dcbc64ad17b47755` — actual venue DB constraint 및 replay-safe migration 보강
|
||||
|
||||
### 비차단 후속 권고
|
||||
|
||||
- migration runner는 history table 없이 SQL 전체를 재실행하므로 현재 migration의 replay-safe 불변식을 계속 유지한다.
|
||||
- 실제 운영 KIS 응답·RabbitMQ·자격증명 게이트 검증은 별도 승인된 환경에서 수행한다.
|
||||
|
||||
### 범위 경계
|
||||
|
||||
현재 완료된 것은 **로컬 `main`의 구현·테스트·PostgreSQL 검증·커밋과 독립 QC PASS**다. push, PR, 원격 main 반영, 이미지 게시, 운영 배포, migration 운영 실행, GitHub Release, 실주문은 수행하지 않았으며 완료를 의미하지 않는다.
|
||||
@@ -0,0 +1,55 @@
|
||||
# GitHub issue #60 comment evidence
|
||||
|
||||
- Source: https://github.com/fourblocks2020/trex-core/issues/60#issuecomment-5336722059
|
||||
- Author: `genichin`
|
||||
- Created At: `2026-08-19T02:20:28Z`
|
||||
- Evidence Role: repository-local snapshot of a non-canonical implementation comment
|
||||
- Canonical Authority: GitHub source comment; this snapshot does not create ProofLine lifecycle status
|
||||
|
||||
---
|
||||
|
||||
## 구현 진행 상황
|
||||
|
||||
- **대상:** [Discovery] broker 실행 실패의 기록·상태 수렴 경계
|
||||
- **브랜치:** `main`
|
||||
- **기준:** `main` (`be98965f2c5d1bad933802282bd7284642203cb8`)
|
||||
- **상태:** 로컬 main 구현·검증·커밋 및 독립 QC PASS — 미푸시, 미배포
|
||||
- **최종 업데이트:** 2026-08-19 12:57:49 KST
|
||||
|
||||
### 구현 계획 및 작업 목록
|
||||
|
||||
- [x] 명시적으로 주문이 거부되고 미체결임이 확인된 매도 주문을 새 주문 없이 보유 상태로 복구한다.
|
||||
- 주문 배정 정보를 정리하고 기존 보유수량을 유지하는 집중 검증을 통과했다.
|
||||
- [x] 주문 결과가 불명확한 경우 중복 주문을 만들지 않고 조회·대조 절차를 우선한다.
|
||||
- 기존 결과불명 sentinel·조회 경로와 재시작 중복 제출 방지 회귀를 확인했다.
|
||||
- [x] 전략 서비스에 KIS 실패·재시도 판단 정보를 전달하지 않고, core가 자동 주문 재시도를 추가하지 않았음을 검증한다.
|
||||
- sell 응답의 `retryable` 필드를 제거하고 strategy RPC 응답에 retryability가 없음을 검증했다.
|
||||
- [x] venue 누락 시 SOR 기본값, 명시 venue 보존, AUTO 제거·기존 데이터 전환 계약과의 교차 회귀를 검증한다.
|
||||
- 원주문 `order_venue`와 실제 `execution_venue`를 분리하고, KIS allowlist 파싱·outbox 이중 보존·실제 PostgreSQL migration 검증을 완료했다.
|
||||
- [x] 관련 집중 테스트와 전체 회귀 테스트를 실행하고 결과를 기록한다.
|
||||
- [x] 변경사항을 검토하고 로컬 main에 커밋한다.
|
||||
- commits: `15aeae6930f8c3e6ef9cf659f41278b099a78459`, blocker fixes `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`; push·PR·배포·릴리스·실주문은 수행하지 않았다.
|
||||
|
||||
### 진행 기록
|
||||
|
||||
- 2026-08-19 11:29:51 KST — 승인된 이슈 본문과 `main` 기준 SHA를 확인하고 별도 worktree·기능 브랜치 없이 구현을 시작했다.
|
||||
- 2026-08-19 11:29:51 KST — sell 명시적 거부 후 `pending_sell → holding`, dispatch 정리, 후속 자동 재주문 부재를 검증했다.
|
||||
- 2026-08-19 11:29:51 KST — 결과불명 주문은 durable sentinel과 broker lookup/reconciliation 경로를 유지하며 재제출하지 않는 회귀를 확인했다.
|
||||
- 2026-08-19 11:29:51 KST — venue 누락→SOR, explicit KRX/NXT/SOR 보존, AUTO admission 거부, wall-clock routing 제거, persisted AUTO→SOR migration을 구현했다.
|
||||
- 2026-08-19 11:29:51 KST — 집중 테스트 `127 passed`; 전체 회귀 `1062 passed, 51 skipped`; ruff `All checks passed!`.
|
||||
- 2026-08-19 11:29:51 KST — disposable PostgreSQL 16 실제 엔진 검증 `23 passed`; 검증 컨테이너를 제거했다.
|
||||
- 2026-08-19 11:29:51 KST — 로컬 main commit `15aeae6930f8c3e6ef9cf659f41278b099a78459` 생성 후 집중 테스트 `127 passed`, ruff 통과, 작업트리 clean을 재확인했다.
|
||||
|
||||
- 2026-08-19 11:59:05 KST — 독립 QC 결과 FAIL. KIS adapter가 SOR 요청값을 ack/slot/ledger에 그대로 보존하고 일별 체결조회 row의 실제 KRX/NXT venue를 파싱하지 않아 AC 6을 충족하지 못한다. 수정·독립 재검증 전에는 delivery 검증 완료로 판정하지 않는다.
|
||||
|
||||
- 2026-08-19 12:47:57 KST — 승인된 해결책을 적용했다. Slot/취소에는 원주문 KRX/NXT/SOR를 유지하고, KIS fill의 `ORD_EXG_GB` 및 명시 exchange-id allowlist에서 실제 KRX/NXT를 `FillStatus.execution_venue`로 전달한다. outbox는 `order_venue`와 실제 `venue`를 분리 저장하며 SOR 단독·미지원 코드는 실제 거래소로 추론하지 않는다.
|
||||
- 2026-08-19 12:47:57 KST — 전체 회귀 `1074 passed, 51 skipped`, disposable PostgreSQL 16 검증 `23 passed`, ruff 통과, migration 024 및 outbox round-trip을 확인했다.
|
||||
|
||||
- 2026-08-19 12:48:36 KST — blocker 수정 commit `7366cadf03fc6002bfd01ecf0cba7c24486bb618`을 로컬 main에 생성하고 worktree clean을 확인했다. 독립 재QC를 시작했다.
|
||||
|
||||
- 2026-08-19 12:54:15 KST — 독립 재QC에서 migration 024의 historical SOR actual 값과 replay-safe backfill 결함을 blocker로 확인했다. `order_venue IS NULL` one-time backfill, historical SOR actual→NULL, actual venue KRX/NXT/NULL DB constraint를 적용했다. 신규 actual NXT + order KRX 행이 replay 후 보존됨을 검증했다.
|
||||
- 2026-08-19 12:54:15 KST — 수정 commit `359ea098ff717e7f04cf4206dcbc64ad17b47755`; 전체 `1074 passed, 52 skipped`, PostgreSQL 16 `24 passed`, ruff 통과, worktree clean. 독립 재QC를 다시 요청한다.
|
||||
|
||||
- 2026-08-19 12:57:49 KST — 독립 재QC PASS. blocker/critical/major/minor finding 없음. 독립 PG16에서 historical SOR→order SOR+actual NULL, 정상 NXT/KRX replay 보존, actual SOR·AUTO·NULL order 거부를 확인했다. HEAD `359ea098ff717e7f04cf4206dcbc64ad17b47755`, worktree clean.
|
||||
|
||||
> 각 작업이 실제로 완료되고 검증될 때마다 이 댓글의 체크박스와 진행 기록을 갱신한다. 체크 표시는 구현 완료 또는 해당 검증의 실제 통과를 의미하며, 배포·릴리스 완료를 의미하지 않는다.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
id: "line-0005"
|
||||
---
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
id: "req-0005"
|
||||
status: approved
|
||||
discovery: "dcy-0005"
|
||||
criteria:
|
||||
create:
|
||||
- "ac-0015"
|
||||
- "ac-0016"
|
||||
- "ac-0017"
|
||||
- "ac-0018"
|
||||
- "ac-0019"
|
||||
update: []
|
||||
retire: []
|
||||
satisfy: []
|
||||
---
|
||||
|
||||
# broker 실행 실패의 기록·상태 수렴 경계
|
||||
|
||||
## Objective
|
||||
|
||||
`trex-core`가 broker-authoritative evidence를 기준으로 주문 실패·불명확 결과·부분체결·취소 상태를 중복 주문 없이 수렴하게 한다. core가 자동 broker retry를 생성하거나 strategy/TVR에 KIS 실패 판단을 넘기지 않도록 ownership을 제한하고, original order venue와 actual execution venue를 durable하게 분리하여 submit부터 restart·cancel·ledger/outbox까지 의미를 보존한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- 명시적으로 미수락·미체결이 확인된 sell 주문은 실패 evidence를 기록하고 dispatch를 정리하며 새 broker attempt 없이 broker-authoritative 보유수량으로 `pending_sell → holding` 전이한다.
|
||||
- submission timeout·연결 단절·응답 누락 등 결과 불명확 상태는 durable sentinel로 보존하고 신규 주문 없이 original order lookup과 reconciliation을 우선한다.
|
||||
- core는 broker failure의 retryability, retry schedule, retry budget 또는 자동 새 broker attempt를 산출하지 않는다. 이후 별도 strategy intent는 자동 retry와 구분한다.
|
||||
- strategy/TVR 응답·이벤트에는 KIS failure, raw broker error, broker attempt와 retryability를 노출하지 않는다. strategy/TVR은 core slot snapshot만 소비한다.
|
||||
- intent venue 누락은 `SOR`로 정규화하고 explicit `KRX`/`NXT`/`SOR`를 그대로 보존한다. `AUTO`는 admission·broker routing·persistence에서 허용하지 않고 persisted AUTO는 SOR로 전환하며 wall-clock routing을 제거한다.
|
||||
- original `order_venue`는 submit, ack, slot, restart와 cancel에 KRX/NXT/SOR로 보존한다. actual `execution_venue`는 broker-authoritative KRX/NXT 또는 미확정 NULL이며 fill, ledger와 outbox에 별도로 기록한다.
|
||||
- KIS actual venue는 `ORD_EXG_GB`의 승인된 mapping과 명시 exchange-id allowlist만 사용한다. `SOR`, 누락, 미지원 값과 wall-clock time으로 actual KRX/NXT를 추론하지 않는다.
|
||||
- historical outbox SOR는 `order_venue=SOR`, actual `venue=NULL`로 보존한다. migration은 NULL-only backfill로 반복 적용이 안전해야 하고 정상 `(actual=NXT, order=KRX)` 같은 조합을 오염시키지 않아야 한다.
|
||||
- PostgreSQL은 order venue를 KRX/NXT/SOR non-null로 제한하고 actual venue를 KRX/NXT/NULL로 제한한다.
|
||||
- focused·전체 회귀, actual PostgreSQL migration·round-trip, lint와 독립 read-only QC를 verification evidence로 유지한다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- broker evidence가 없는 actual venue, 체결, 미수락 또는 terminal state를 추론하지 않는다.
|
||||
- explicit order venue와 original order date·broker order identity를 restart/cancel 중 변경하지 않는다.
|
||||
- strategy/TVR에 KIS failure나 retry ownership을 추가하지 않는다.
|
||||
- live credential·token·connection string·raw sensitive payload를 문서나 외부 wire에 기록하지 않는다.
|
||||
- existing active AC-0011의 timeout/cancel reconciliation 및 AC-0013의 terminal cancellation·partial fill 계약을 완화하거나 대체하지 않는다.
|
||||
- Normative Design Documents: 없음. 이 REQ와 active AC가 normative specification을 소유한다.
|
||||
|
||||
### Current Implementation Assessment
|
||||
|
||||
- 이 specification은 승인된 GitHub issue #60과 연결된 #61을 이미 local `main`에서 구현·검증한 결과를 사후 정리한다. 과거 구현이 ProofLine delivery chronology를 따랐다는 뜻은 아니다.
|
||||
- 구현 revision은 `15aeae6930f8c3e6ef9cf659f41278b099a78459`, `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`다.
|
||||
- 현재 구현은 명시적 sell rejection 수렴, unknown submission sentinel·lookup, strategy wire retryability 제거, SOR default·explicit venue 보존·AUTO 제거, KIS actual venue parser와 order/execution venue 분리, replay-safe migration·DB constraint를 포함한다.
|
||||
- 구현자 검증은 focused `77 passed`, 전체 `1074 passed, 52 skipped`, disposable PostgreSQL 16 `24 passed`, Ruff PASS다.
|
||||
- 최종 독립 read-only QC는 actual PostgreSQL 16 probe를 포함하여 blocker/critical/major/minor finding 없이 PASS했으며 HEAD `359ea098ff717e7f04cf4206dcbc64ad17b47755`와 clean worktree를 확인했다.
|
||||
- 환경 의존 KIS·RabbitMQ 검증은 skip되었고 실제 외부 system interaction은 수행하지 않았다.
|
||||
- `approved` REQ와 `active` AC는 current specification authority 및 현재 implementation assessment를 나타낸다. push, PR, 원격 main 반영, deployment, release 또는 운영 주문 완료를 뜻하지 않는다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- strategy/TVR의 KIS failure 수신, retryability 계산, broker retry schedule·budget 또는 자동 재주문
|
||||
- TVR session·portfolio `markets` gate와 거래 세션 정책 구현
|
||||
- live KIS 주문·취소, 운영 broker/account/database/RabbitMQ/portfolio-hub 연결
|
||||
- push, PR, remote main merge, image publication, production migration, deployment, GitHub Release 또는 실주문
|
||||
- ProofLine delivery·release lifecycle closure의 소급 주장
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
id: "dcy-0006"
|
||||
status: confirmed
|
||||
---
|
||||
|
||||
# 명시 venue 기반 주문 실행 계약
|
||||
|
||||
## Problem
|
||||
|
||||
`trex-core`의 주문 venue 계약에는 과거 `AUTO`와 기본 `KRX` 동작이 혼재했다. buy/sell intent에서 venue가 누락될 때 core가 wall-clock으로 KRX/NXT를 선택하거나 내부·저장 계층에서 AUTO를 허용하면 caller가 요청한 routing provenance를 잃고 세션 판단이 execution core로 새어 들어간다. 기존 persisted AUTO row도 현재 계약과 충돌한다.
|
||||
|
||||
SOR 주문에서는 원주문 routing venue와 실제 체결 거래소가 서로 다른 의미다. 이를 하나의 venue 값으로 취급하면 submit acknowledgement 뒤 slot venue를 실제 KRX/NXT로 덮어쓰거나, 반대로 fill·ledger/outbox에 실제 거래소 대신 SOR를 기록할 수 있다. 부분체결 잔량 취소와 daemon 재시작은 원주문 SOR를 필요로 하고, actual venue는 broker-authoritative KRX/NXT evidence가 있을 때만 확정되어야 한다.
|
||||
|
||||
기대 상태는 venue 누락/null intent를 SOR로 정규화하고 explicit KRX/NXT/SOR를 변경 없이 보존하며 AUTO와 wall-clock routing을 제거하는 것이다. original order venue와 actual execution venue는 submit·ack·cancel·fill polling·restart·ledger/outbox 전체에서 별도 의미로 durable하게 보존되어야 한다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- 원본 승인 문서: GitHub issue #61 `https://github.com/fourblocks2020/trex-core/issues/61`은 SOR default, explicit venue preservation, AUTO removal, persisted AUTO→SOR migration, no wall-clock routing과 execution venue lifecycle을 승인했다.
|
||||
- 승인 결정은 venue 누락의 SOR default, explicit KRX/NXT/SOR 유지, AUTO의 public wire·internal model·persistence 제거, persisted AUTO의 SOR 정규화, SOR ack·조회·취소 계약을 구현 검증으로 증명하고 실패 시 AUTO routing으로 회귀하지 않는다는 다섯 항목이다.
|
||||
- 구현 진행 원본: issue #61 comment `https://github.com/fourblocks2020/trex-core/issues/61#issuecomment-5337421562`는 baseline `4975d7166fa70e045096503e85c26d740ae92c55`, 한국어 계획, stale PostgreSQL DSN 실패 분류, 수정·검증·독립 QC와 최종 PASS를 기록한다.
|
||||
- 구현 완료 원본: issue #61 comment `https://github.com/fourblocks2020/trex-core/issues/61#issuecomment-5337469578`은 구현 내용, 실제 검증 결과, local commits와 외부 단계 경계를 기록한다.
|
||||
- `trex_core/application/messaging_handlers.py`는 buy/sell wire의 누락 또는 explicit null venue를 `SOR`로 정규화하고 invalid venue를 admission 전에 거부한다.
|
||||
- `trex_core/application/intents.py`는 intent venue 기본값과 validation을 소유하고 slot에 original order venue를 전달한다.
|
||||
- `trex_core/adapters/broker/kis.py`는 KRX/NXT/SOR allowlist를 시간과 독립적으로 보존하여 `EXCG_ID_DVSN_CD`로 제출하고, actual execution venue는 검증된 `ORD_EXG_GB`와 explicit exchange-id allowlist에서만 KRX/NXT로 파싱한다.
|
||||
- `trex_core/application/execution.py`는 slot의 original venue를 submit·restart·cancel에 사용하고 `FillStatus.execution_venue`를 ledger actual venue로 전달한다. SOR actual이 확인되지 않으면 추론하지 않고 fail-closed 한다.
|
||||
- migration 023은 persisted AUTO slot/outbox row를 SOR로 전환하고 AUTO 저장을 차단한다. migration 024는 outbox `order_venue`와 nullable actual `venue`를 분리하고 historical SOR actual을 NULL로 보존하며 NULL-only replay-safe backfill과 constraint를 적용한다.
|
||||
- 구현 기반 revision은 `15aeae6930f8c3e6ef9cf659f41278b099a78459`, `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`다. issue #61 정합 수정은 `64e4e32f9d7abf7082588b3c4c9a8c0d272762f6`, explicit null regression은 `43f6fea7ac805f27fba35586af27f0090a672222`다.
|
||||
- 구현자 검증은 비-DB focused `107 passed`, omitted/null focused `8 passed`, disposable PostgreSQL 16 `24 passed`, 전체 `1074 passed, 52 skipped`, Ruff·ProofLine validate·diff check PASS다.
|
||||
- 최초 combined focused의 `107 passed, 1 failed, 5 errors`는 종료된 이전 QC PostgreSQL DSN의 connection refused였으며 새 disposable PostgreSQL 16 semantic suite `24 passed`로 재검증되었다.
|
||||
- 최종 독립 read-only 재QC는 omitted/null SOR normalization, broker request와 ack 이후 slot routing provenance를 확인하고 blocker 0, major 0, minor 0으로 PASS했다. HEAD `43f6fea7ac805f27fba35586af27f0090a672222`, worktree clean이었다.
|
||||
- 현재 active AC-0018은 SOR default, explicit venue preservation, AUTO removal과 no wall-clock routing을 이미 소유하고, active AC-0019는 original order venue와 actual execution venue의 durable split·migration contract를 이미 소유한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- AC admission은 중복 criterion을 새로 만들지 않고 기존 active AC의 의미를 변경하지 않는 `satisfy`다.
|
||||
- `satisfy`: AC-0018에 issue #61의 venue 누락/null→SOR, explicit KRX/NXT/SOR 보존, AUTO 제거·migration과 no wall-clock routing 구현 evidence를 결속한다.
|
||||
- `satisfy`: AC-0019에 issue #61의 ack·cancel·fill polling·restart·ledger/outbox order/execution venue 분리와 fail-closed actual venue 구현 evidence를 결속한다.
|
||||
- buy/sell intent의 venue 누락과 explicit null은 SOR로 정규화하고 KIS request에 `EXCG_ID_DVSN_CD="SOR"`를 명시한다.
|
||||
- caller가 explicit KRX/NXT/SOR를 제공하면 admission, slot, broker request, acknowledgement, restart와 cancel에서 변경 없이 보존한다.
|
||||
- AUTO는 AMQP intent, domain admission, runtime routing, broker adapter input과 persistence에서 허용·생성·저장하지 않는다. 기존 persisted AUTO는 SOR로 idempotent하게 변환한다.
|
||||
- core는 wall-clock time으로 KRX/NXT를 선택하거나 SOR를 변환하지 않는다.
|
||||
- original `order_venue`는 KRX/NXT/SOR이며 submit·ack·slot·restart·cancel에 사용한다. actual `execution_venue`는 broker-authoritative KRX/NXT 또는 미확정 NULL이며 fill·ledger/outbox에 별도로 기록한다.
|
||||
- SOR actual venue는 검증된 KIS code에서만 KRX/NXT로 확정한다. 누락·SOR 단독·미지원 값과 시간으로 actual venue를 추론하지 않는다.
|
||||
- historical SOR actual은 order SOR와 actual NULL로 보존한다. migration replay는 정상 `(order=KRX, actual=NXT)` 같은 신규 조합을 오염시키지 않고 DB는 AUTO·invalid order와 actual SOR를 거부한다.
|
||||
- repository current contract 문서와 KIS adapter 주석을 SOR normalization에 맞추고 omitted와 explicit null을 독립 regression으로 검증한다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TVR의 session·portfolio `markets` 전략 판단 또는 KRX/NXT session policy 변경
|
||||
- SOR 주문을 실제 운영 KIS 계좌로 제출하거나 취소하는 작업
|
||||
- live credential·account·database·RabbitMQ·portfolio-hub 연결
|
||||
- push, PR, 원격 main 반영, image publication, production migration, deployment, release, issue closure 또는 실주문
|
||||
- ProofLine delivery/release lifecycle closure의 소급 주장
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
### Risks
|
||||
|
||||
- 실제 KIS 운영 응답의 actual exchange field 변형은 fixture 기반 parser 검증 범위를 벗어날 수 있다. 미지원 값은 추론하지 않고 NULL/fail-closed로 유지하며 운영 검증은 별도 승인 환경에서 수행한다.
|
||||
- migration runner는 history table 없이 SQL을 재실행하므로 AUTO→SOR 및 order/execution split migration의 semantic replay-safe 불변식을 향후 변경에서도 유지해야 한다.
|
||||
- cancel adapter의 내부 backward-compatible default와 intent의 public SOR default는 서로 다른 경계다. execution path는 항상 stored original venue를 명시적으로 전달해야 하며 문서가 이를 혼동하지 않아야 한다.
|
||||
|
||||
### Approval State
|
||||
|
||||
- Discovery status: `confirmed`
|
||||
- 승인 근거: 요청자 genichin이 승인 상태인 issue #61 본문을 최대한 포함하여 템플릿 기반 Discovery와 REQ·AC를 승인된 상태로 사후 정리하라고 명시했다.
|
||||
- Open Questions: 없음
|
||||
- 이 확인은 current specification과 implementation evidence의 사후 정리다. push, PR, deployment, release, production migration, issue closure 또는 실주문 승인을 의미하지 않는다.
|
||||
@@ -0,0 +1,49 @@
|
||||
# GitHub issue #61 comment evidence
|
||||
|
||||
- Source: https://github.com/fourblocks2020/trex-core/issues/61#issuecomment-5337469578
|
||||
- Author: `genichin`
|
||||
- Created At: `2026-08-19T04:17:48Z`
|
||||
- Evidence Role: repository-local snapshot of a non-canonical implementation comment
|
||||
- Canonical Authority: GitHub source comment; this snapshot does not create ProofLine lifecycle status
|
||||
|
||||
---
|
||||
|
||||
## 구현 완료
|
||||
|
||||
### 구현 내용
|
||||
|
||||
- venue가 누락되거나 `null`인 buy/sell intent를 `SOR`로 정규화하고 KIS 요청에 `EXCG_ID_DVSN_CD="SOR"`를 명시한다.
|
||||
- explicit `KRX`/`NXT`/`SOR`를 submit·ack·slot·restart·cancel 전 과정에서 원주문 venue로 보존한다.
|
||||
- `AUTO`를 admission에서 거부하고 wall-clock KRX/NXT routing을 제거했다.
|
||||
- persisted `AUTO`를 `SOR`로 전환하고 이후 invalid venue 저장을 차단한다.
|
||||
- 원주문 `order_venue`와 broker-authoritative actual execution venue를 분리한다. 취소·재시작은 원주문 venue를, ledger/outbox actual venue는 KRX/NXT 또는 미확정 NULL을 사용한다.
|
||||
- historical SOR actual은 임의 추론하지 않고 NULL로 보존하며 migration replay가 정상 order/execution venue 조합을 오염시키지 않는다.
|
||||
- current SOR 계약과 충돌하던 interface 문서와 KIS adapter 주석을 정정했다.
|
||||
- omitted와 explicit `venue: null`을 각각 검증하는 regression을 추가했다.
|
||||
|
||||
### 실제 검증 결과
|
||||
|
||||
- 구현자 비-DB focused: `107 passed`
|
||||
- 구현자 explicit omitted/null focused: `8 passed`
|
||||
- 구현자 disposable PostgreSQL 16: `24 passed`
|
||||
- 구현자 전체 회귀: `1074 passed, 52 skipped`
|
||||
- Ruff·ProofLine validate·diff check: PASS
|
||||
- 최종 독립 재QC: PASS — blocker 0 / major 0 / minor 0
|
||||
- 최종 worktree: clean
|
||||
|
||||
최초 combined focused 실행의 `1 failed, 5 errors`는 종료된 이전 QC PostgreSQL DSN의 connection refused였으며, 새 disposable PostgreSQL 16에서 semantic migration suite를 재실행해 `24 passed`를 확인했다.
|
||||
|
||||
### 로컬 커밋
|
||||
|
||||
- 기능 구현 기반: `15aeae6930f8c3e6ef9cf659f41278b099a78459`, `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`
|
||||
- 계약 문서 정합: `64e4e32f9d7abf7082588b3c4c9a8c0d272762f6`
|
||||
- explicit null regression: `43f6fea7ac805f27fba35586af27f0090a672222`
|
||||
|
||||
### 비차단 후속 권고
|
||||
|
||||
- 실제 KIS 운영계좌 SOR 주문과 외부 인프라 검증은 별도 승인된 환경에서 수행한다.
|
||||
- migration runner의 replay-safe 불변식을 향후 migration 변경에서도 유지한다.
|
||||
|
||||
### 범위 경계
|
||||
|
||||
현재 완료된 것은 **로컬 `main` 구현·테스트·PostgreSQL 검증·커밋과 독립 QC PASS**다. push, PR, 원격 main 반영, image 게시, 운영 migration, 배포, GitHub Release, issue 종료와 실주문은 수행하지 않았다.
|
||||
@@ -0,0 +1,51 @@
|
||||
# GitHub issue #61 comment evidence
|
||||
|
||||
- Source: https://github.com/fourblocks2020/trex-core/issues/61#issuecomment-5337421562
|
||||
- Author: `genichin`
|
||||
- Created At: `2026-08-19T04:10:59Z`
|
||||
- Evidence Role: repository-local snapshot of a non-canonical implementation comment
|
||||
- Canonical Authority: GitHub source comment; this snapshot does not create ProofLine lifecycle status
|
||||
|
||||
---
|
||||
|
||||
## 구현 진행 상황
|
||||
|
||||
- **대상:** [Discovery] 명시 venue 기반 주문 실행 계약
|
||||
- **브랜치:** `main`
|
||||
- **기준:** `main` (`4975d7166fa70e045096503e85c26d740ae92c55`)
|
||||
- **상태:** 로컬 main 구현·검증·커밋 및 독립 QC PASS — 미푸시, 미배포
|
||||
- **최종 업데이트:** 2026-08-19 13:17:45 KST
|
||||
|
||||
### 구현 계획 및 작업 목록
|
||||
|
||||
- [x] venue가 없는 매수·매도 주문을 SOR 주문으로 제출한다.
|
||||
- 누락과 `null` 모두 같은 기본값을 사용하고 KIS 요청에 SOR를 명시한다.
|
||||
- [x] 사용자가 지정한 KRX·NXT·SOR venue를 주문 전 과정에서 그대로 보존한다.
|
||||
- 주문 접수부터 KIS 제출, 재시작 후 조회와 취소까지 원주문 venue가 바뀌지 않아야 한다.
|
||||
- [x] AUTO venue를 입력·실행·저장 경계에서 제거한다.
|
||||
- AUTO 요청을 입구에서 거부하고 시간대에 따라 KRX/NXT를 선택하는 동작도 제거한다.
|
||||
- [x] 기존 저장 데이터의 AUTO venue를 SOR로 안전하게 전환한다.
|
||||
- 실제 PostgreSQL에서 반복 적용과 저장 제약을 함께 검증한다.
|
||||
- [x] SOR 원주문 venue와 실제 체결된 KRX/NXT venue를 구분해 보존한다.
|
||||
- 취소·재시작은 원주문 SOR를 사용하고 체결 원장에는 확인된 실제 거래소만 기록한다.
|
||||
- [x] 문서·설정·테스트에 폐기된 runtime AUTO 계약이 남아 있지 않은지 점검한다.
|
||||
- [x] 관련 집중 테스트, 전체 회귀 테스트, PostgreSQL migration 검증과 코드 품질 검사를 실행한다.
|
||||
- 검증 실패가 승인 범위 안의 결함이면 수정하고, 계획의 의미나 범위를 바꿔야 하면 즉시 중지하고 재승인을 요청한다.
|
||||
|
||||
### 진행 기록
|
||||
|
||||
- 2026-08-19 13:10:40 KST — 승인된 이슈 본문, `main` branch와 clean worktree, 기준 SHA `4975d7166fa70e045096503e85c26d740ae92c55`를 확인했다. 별도 worktree나 기능 branch를 만들지 않고 구현·검증을 시작한다.
|
||||
|
||||
- 2026-08-19 13:12:28 KST — 기존 local main 구현에서 누락/null→SOR, explicit KRX/NXT/SOR 보존, AUTO admission 거부와 wall-clock routing 제거, order/execution venue 분리를 확인했다. 관련 비-DB 집중 테스트 `107 passed`.
|
||||
- 2026-08-19 13:12:28 KST — 최초 focused 실행은 종료된 이전 QC의 stale PostgreSQL DSN(`127.0.0.1:32778`) 때문에 `107 passed, 1 failed, 5 errors`였으며 코드 실패가 아니었다. 새 disposable PostgreSQL 16에서 migration·outbox·repository `24 passed`를 확인하고 컨테이너를 제거했다.
|
||||
- 2026-08-19 13:12:28 KST — current 계약과 충돌하던 interface 문서의 과거 KRX-default 설명 및 KIS adapter 주석을 SOR intent normalization 기준으로 수정했다.
|
||||
|
||||
- 2026-08-19 13:13:08 KST — repository 전체 AUTO 검색에서 잔존 항목은 admission 거부·migration 변환 검증과 현재 문서의 폐기 설명뿐이며 runtime AUTO 생성·허용 계약은 확인되지 않았다.
|
||||
- 2026-08-19 13:13:08 KST — 전체 회귀 `1074 passed, 52 skipped`, Ruff·ProofLine validate·diff check PASS. 로컬 main commit `64e4e32f9d7abf7082588b3c4c9a8c0d272762f6`을 생성하고 worktree clean을 확인했다. push·PR·배포·릴리스·실주문은 수행하지 않았다.
|
||||
|
||||
- 2026-08-19 13:16:22 KST — 독립 read-only QC PASS: blocker 0, major 0, minor 1. 기능 결함은 없었고 explicit wire `venue: null`을 독립적으로 이름 붙인 테스트가 없다는 minor 검증 공백을 확인했다.
|
||||
- 2026-08-19 13:16:22 KST — 승인 계획의 누락/null→SOR 검증 범위 안에서 omitted/null parameterized regression을 추가했다. focused `8 passed`, Ruff·diff check PASS, commit `43f6fea7ac805f27fba35586af27f0090a672222`, worktree clean. 독립 재QC를 요청한다.
|
||||
|
||||
- 2026-08-19 13:17:45 KST — 독립 재QC PASS: blocker 0, major 0, minor 0. omitted/null 두 케이스의 SOR normalization, broker request, ack 후 slot routing provenance를 독립 확인했다. 최종 HEAD `43f6fea7ac805f27fba35586af27f0090a672222`, worktree clean.
|
||||
|
||||
> 각 작업이 실제로 완료되고 검증될 때마다 이 댓글의 체크박스와 진행 기록을 갱신한다. 체크 표시는 구현 완료 또는 해당 검증의 실제 통과를 의미하며, 배포·릴리스 완료를 의미하지 않는다.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
id: "line-0006"
|
||||
---
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: "req-0006"
|
||||
status: approved
|
||||
discovery: "dcy-0006"
|
||||
criteria:
|
||||
create: []
|
||||
update: []
|
||||
retire: []
|
||||
satisfy:
|
||||
- "ac-0018"
|
||||
- "ac-0019"
|
||||
---
|
||||
|
||||
# 명시 venue 기반 주문 실행 계약
|
||||
|
||||
## Objective
|
||||
|
||||
buy/sell intent의 venue 누락과 explicit null을 SOR로 정규화하고 caller가 명시한 KRX/NXT/SOR를 original order venue로 보존한다. AUTO와 wall-clock venue routing을 runtime·persistence 계약에서 제거하며 기존 AUTO row를 SOR로 전환한다. SOR 원주문 venue와 broker-authoritative actual execution venue를 분리해 submit·ack·cancel·fill polling·restart·ledger/outbox 전 과정에서 routing provenance와 실제 체결 거래소를 각각 정확하게 보존한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- buy/sell intent의 venue가 누락되거나 explicit null이면 `SOR`를 사용하고 KIS submit request에 `EXCG_ID_DVSN_CD="SOR"`를 명시한다.
|
||||
- caller가 `KRX`, `NXT`, `SOR`를 명시하면 admission, slot persistence, KIS submit, acknowledgement, restart lookup와 cancel에서 변경하지 않는다.
|
||||
- `AUTO` intent는 slot transition과 broker submission 전에 `invalid_venue`로 거부한다. runtime source·설정·문서는 AUTO를 생성하거나 허용하지 않고, negative tests와 migration history만 폐기 계약을 언급할 수 있다.
|
||||
- core는 wall-clock time 또는 session 추정으로 KRX/NXT를 선택하지 않는다.
|
||||
- existing persisted AUTO slot/outbox row를 SOR로 idempotent하게 전환하고 이후 invalid venue 저장을 DB constraint로 차단한다.
|
||||
- original `order_venue`는 KRX/NXT/SOR이며 submit·ack·slot·restart·cancel routing authority다.
|
||||
- actual `execution_venue`는 broker-authoritative KRX/NXT 또는 미확정 NULL이다. KIS `ORD_EXG_GB`와 explicit exchange-id allowlist에서만 결정하여 fill·ledger/outbox에 별도로 기록한다.
|
||||
- SOR 단독, 누락, unknown code 또는 time으로 actual venue를 추론하지 않는다. SOR 주문에서 actual이 확인되지 않으면 거짓 ledger actual을 만들지 않는다.
|
||||
- historical outbox의 SOR는 `order_venue=SOR`, actual `venue=NULL`로 보존한다. NULL-only backfill로 migration replay가 정상 order/execution 조합을 재해석하지 않게 한다.
|
||||
- omitted와 explicit null wire input, KRX/NXT/SOR preservation, AUTO rejection, KIS payload, partial fill·cancel·restart, outbox round-trip 및 migration semantics를 repository tests와 실제 PostgreSQL에서 검증한다.
|
||||
- 기존 active AC-0018과 AC-0019를 이 Requirement의 구현 evidence에 `satisfy`로 결속한다. 이후 requester가 명시 승인한 AC revision은 해당 criterion의 최신 canonical scope다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- actual venue, terminal execution state 또는 broker acceptance를 wall-clock이나 default로 추론하지 않는다.
|
||||
- cancel·restart는 stored original venue와 original order identity를 사용한다.
|
||||
- KIS raw payload, credential, token 또는 connection string을 public wire·document evidence에 기록하지 않는다.
|
||||
- 이 satisfy binding 자체만으로 active AC-0018과 AC-0019의 본문·status를 변경하지 않는다. requester가 명시 승인한 revision은 예외다.
|
||||
- TVR session/markets 판단과 strategy policy는 변경하지 않는다.
|
||||
- Normative Design Documents: 없음. 이 REQ와 만족 대상으로 결속한 active AC-0018·AC-0019가 normative specification을 소유한다.
|
||||
|
||||
### Current Implementation Assessment
|
||||
|
||||
- 이 Requirement는 승인된 GitHub issue #61의 이미 완료된 local implementation을 현재 ProofLine schema에 사후 명세화한다. 과거 구현이 ProofLine delivery chronology를 따랐다는 뜻은 아니다.
|
||||
- AC-0018 구현 상태: **satisfied by current local implementation evidence**. 누락/null intent→SOR, explicit KRX/NXT/SOR preservation, AUTO admission rejection, no wall-clock routing, persisted AUTO→SOR와 constraint가 구현·검증되었다.
|
||||
- AC-0019 구현 상태: **satisfied by current local implementation evidence**. original order venue와 actual execution venue split, KIS fail-closed parser, cancel/restart routing provenance, ledger/outbox split, historical SOR actual NULL과 replay-safe migration이 구현·검증되었다.
|
||||
- 구현 기반 commits는 `15aeae6930f8c3e6ef9cf659f41278b099a78459`, `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`다.
|
||||
- issue #61 계약 문서 정합 commit은 `64e4e32f9d7abf7082588b3c4c9a8c0d272762f6`, explicit null regression commit은 `43f6fea7ac805f27fba35586af27f0090a672222`다.
|
||||
- 구현자 검증: 비-DB focused `107 passed`, omitted/null focused `8 passed`, disposable PostgreSQL 16 `24 passed`, 전체 `1074 passed, 52 skipped`, Ruff·ProofLine validate·diff check PASS.
|
||||
- 최초 combined focused의 `1 failed, 5 errors`는 stale PostgreSQL DSN connection refused로 분류했고 새 disposable PostgreSQL 16 semantic suite `24 passed`로 재검증했다.
|
||||
- 최종 독립 재QC는 omitted/null SOR normalization, broker request, ack 후 slot routing provenance를 검증하여 blocker 0, major 0, minor 0으로 PASS했다. 최종 implementation HEAD는 `43f6fea7ac805f27fba35586af27f0090a672222`, worktree clean이었다.
|
||||
- 실제 KIS 운영계좌 SOR 주문, 외부 RabbitMQ·database·portfolio-hub, push·PR·remote main·production migration·deployment·release는 수행하지 않았다.
|
||||
- REQ `approved`, AC `active` 및 `satisfy` binding은 current specification authority와 repository-local implementation assessment를 나타낸다. delivery closure, production validation, release 또는 deployment 완료를 의미하지 않는다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- TVR session·portfolio markets gate, KRX/NXT session policy 또는 strategy intent 생성 판단 변경
|
||||
- actual venue가 불명확할 때 KRX/NXT를 추정하거나 AUTO routing을 복원하는 fallback
|
||||
- 실제 KIS 운영 주문·취소, live credential 또는 production broker/account 검증
|
||||
- push, PR, remote main integration, image publication, production migration, deployment, GitHub Release 또는 issue closure
|
||||
- 기존 active AC-0018·AC-0019의 의미 변경, 신규 중복 AC 생성 또는 ProofLine lifecycle closure 소급 주장
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
id: "dcy-0007"
|
||||
status: confirmed
|
||||
---
|
||||
|
||||
# Reconciliation 수량 불일치 알림
|
||||
|
||||
## Problem
|
||||
|
||||
`trex-core`는 broker와 core slot의 보유수량이 다른 B-case, portfolio-hub와 broker의 종목별 보유수량 합계가 다른 C-case를 reconciliation warning으로 기록한다. 그러나 두 mismatch 경로는 `AlarmEmitter.publish_alarm()`을 호출하지 않아 `trex.alerts` event가 만들어지지 않고, `trex-notifier`가 운영자에게 Telegram 알림을 전달할 수 없다. 현재 scheduler가 같은 mismatch를 반복 탐지하므로 단순 publish 추가는 scheduler tick마다 중복 메시지를 만들 수 있으며, 수량이 바뀌거나 mismatch가 해소된 사실도 별도 lifecycle event로 구분되지 않는다.
|
||||
|
||||
기대 상태는 B/C mismatch의 최초 탐지와 material quantity change를 정제된 `trex.alerts` event로 알리고, 기존 warning 해소 권한에 따라 mismatch가 실제로 해소되면 recovery/resolved event를 정확히 한 번 알리는 것이다. 알림 상태나 publish 실패가 reconciliation, warning persistence·clear, slot lifecycle 또는 broker/portfolio-hub 수량을 변경하거나 중단해서는 안 된다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- 원본 요청: GitHub issue #64 `https://github.com/fourblocks2020/trex-core/issues/64`는 B/C mismatch의 최초 탐지·material change·해소 알림, unchanged warning 억제, 정제된 instance key·symbol·case code·비교 수량, best-effort publish와 B/C·변경·해소 테스트를 요구한다. issue는 2026-08-23 생성되었고 조사 시점에 comment 없이 open 상태였다.
|
||||
- 조사 기준 `trex-core` revision은 `24a64b55d17263ade8d59f30af50d2a0e5220eae`이며 working tree는 Line 시작 전 clean이었다. issue #64 또는 reconciliation alarm을 참조하는 open/all PR 검색 결과는 없었다.
|
||||
- `trex_core/application/reconciliation.py:100-199`의 `reconcile_core_holdings()`는 `alarm_emitter`를 받지만 B-case에서는 structured WARNING log와 `warn_store.set_warned(case_code="B")`만 수행한다. 같은 함수의 A-case broker-flat 경로만 `core_holding_broker_flat` alarm을 발행한다.
|
||||
- `trex_core/application/reconciliation.py:202-280`의 `reconcile_positions()`는 portfolio-hub `asset_quantity`를 symbol별로 합산해 broker 수량과 비교하고 C-case warning을 저장하지만 `alarm_emitter` parameter나 publish 호출이 없다.
|
||||
- `trex_core/application/snapshot_scheduler.py:306-334`는 C-case `reconcile_positions()`에 emitter를 전달하지 않고 B-case `reconcile_core_holdings()`에만 emitter를 전달한다. 두 reconciliation 호출의 예외는 scheduler를 중단하지 않도록 각각 swallow·log된다.
|
||||
- `trex_core/application/alarm_emitter.py:35-93`은 allowlist된 caller fields로 `trex.alerts` envelope를 만들고 `TransportUnavailableError`와 그 밖의 exception을 swallow하는 best-effort 경계를 이미 제공한다.
|
||||
- `trex_core/application/portfolio.py:356-446`, `trex_core/adapters/portfolio/reconcile_warning_store.py:14-98`, migration `016_create_slot_reconcile_warning.sql`은 active warning을 slot별 durable row로 저장하고 동일 slot의 case/reason을 upsert한다. 현재 contract에는 alert fingerprint, last-notified quantity, resolved event 또는 publish 상태가 없고 `clear_warned()`는 row를 삭제한다.
|
||||
- active AC-0023은 `instance_key + symbol` 기준 C-case 합계 비교, 전략 RPC 제외, intent 차단과 authoritative 합계 재검증 후 C-case warning clear를 소유한다. active AC-0021은 별도 `ledger_skip`의 deduplicated unresolved/resolved alert lifecycle을 소유하지만 reconciliation mismatch는 범위에 포함하지 않는다. active AC-0012의 dedupe는 미확정 cancel alarm에 한정된다.
|
||||
- `portfolio-hub` canonical source revision `5d6a0ad013900668930fe947f342e1ad1824edaa`의 `app/schemas/v1.py:302-338`은 `PortfolioItem.asset_quantity`와 portfolio list response를 정의하고, `app/api/v1_portfolios.py:315-363`은 service token의 daemon ownership으로 portfolio 목록을 반환한다. 이는 C-case가 소비하는 portfolio별 수량 source와 instance 경계를 확인한다. 해당 checkout에서 `uv run python -m pytest -o addopts='' -q tests/test_v1_portfolios_list.py tests/test_v1_portfolios_daemon_assignment.py`는 `37 passed, 3 warnings`였다.
|
||||
- 현재 baseline focused 검증 `uv run pytest -q tests/application/test_reconcile_warning_blocking.py tests/application/test_alarm_emitter.py`는 `58 passed`였다. 기존 tests는 B/C warning·logging과 generic alarm envelope를 각각 검증하지만 B/C alarm, unchanged suppression, quantity change와 resolved event를 결합해 검증하지 않는다.
|
||||
- Line scaffold preflight 전에 upstream AC-0022의 추가 `## Scope note` heading 때문에 ProofLine 0.9.1 validation이 실패했다. 문구를 바꾸지 않고 heading만 inline scope note로 정규화한 뒤 `proofline validate`와 `proofline line init --dry-run`이 통과했다. 이는 새 capability와 무관한 `housekeeping`이다.
|
||||
|
||||
## Scope
|
||||
|
||||
- AC admission은 reconciliation mismatch의 운영자-visible notification lifecycle을 독립적으로 검증할 장기 active criterion을 `create`하는 후보이며 next allocator 기준 예상 ID는 AC-0024다. AC-0023의 blocking·clear authority나 AC-0021의 ledger-skip alert 의미를 확장 해석해 `satisfy`하지 않는다.
|
||||
- B-case core-slot↔broker quantity mismatch와 C-case portfolio-hub symbol aggregate↔broker mismatch의 새 탐지에서 `AlarmEmitter`를 통해 sanitized `trex.alerts` event를 발행한다.
|
||||
- alert dedupe identity는 B-case에서 slot별, C-case에서 `instance_key + symbol`별이다. 동일한 active mismatch의 반복 scheduler tick은 Telegram message로 이어질 event를 재발행하지 않는다. 동일 identity의 case별 비교 quantity tuple이 바뀌면 material change event를 한 번 발행한다.
|
||||
- recovery/resolved event는 기존 durable warning clear 경로인 B-case operator `sync`, C-case operator `verify`의 symbol aggregate 재검증에서만 발행한다. scheduler의 수량 일치 관찰은 durable warning을 자동 clear하거나 recovery를 발행하지 않는다.
|
||||
- unchanged suppression은 process restart 뒤에도 유지하는 durable dedupe다. event fingerprint는 alarm publish가 성공한 뒤에만 기록하며 publish 실패 시 reconciliation을 중단하지 않고 다음 detection에서 다시 시도할 수 있어야 한다.
|
||||
- event에는 최소 `instance_key`, `symbol`, `case_code`, broker quantity와 case별 비교 quantity를 포함하되 account number/hash, credential, token, DSN, raw broker/portfolio-hub payload와 exception 본문은 포함하지 않는다.
|
||||
- alarm publish 실패는 best-effort로 swallow되어 detection, warning persistence·clear, scheduler 진행과 slot 상태에 영향을 주지 않는다.
|
||||
- repository-local tests에서 B/C 최초 탐지, unchanged suppression, 비교 수량 변경, resolution, publish failure, 민감 필드 부재를 독립적으로 검증한다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- B/C mismatch 판정식, `case_code` 의미, AC-0023의 C-case 전략 RPC 제외·intent 차단과 기존 warning clear authority 변경
|
||||
- broker, core slot 또는 portfolio-hub 수량의 자동 보정과 operator `sync`/`verify` 승인 절차 변경
|
||||
- A-case broker-flat 및 기존 alarm inventory의 의미 변경
|
||||
- `trex-notifier` 자체의 Telegram routing·message rendering 구현, RabbitMQ topology 변경, production credential·배포·실계좌 검증
|
||||
- push, PR, merge, release, issue closure와 production migration
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
- slot별 warning row를 그대로 event identity로 사용하면 C-case의 동일 `instance_key + symbol`에 속한 여러 portfolio/slot이 Telegram 알림을 중복 생성할 수 있다. 반대로 symbol 단일 identity는 B-case에서 여러 slot의 독립 수량 차이를 잃을 수 있다.
|
||||
- 현재 warning upsert는 `updated_at`과 reason을 매 tick 갱신하고 clear 시 row를 삭제하므로, 단순 timestamp 비교로는 unchanged suppression이나 restart-safe resolved event를 증명할 수 없다.
|
||||
- event fingerprint를 alarm publish 성공 뒤에만 기록해야 하므로 publish 성공 판정과 durable dedupe write 사이의 failure window에서 duplicate 가능성이 있다. 이 경계는 reconciliation transaction과 결합하지 않으면서 retry 가능한 at-least-once 성질을 보존하도록 구현·검증해야 한다.
|
||||
- C-case 해소는 AC-0023에 따라 symbol aggregate 재검증과 C-case-only clear가 필요하다. scheduler의 process-local `SnapshotStore.warning` clear를 durable recovery authority로 오인하면 premature recovery event가 발생할 수 있다.
|
||||
|
||||
### Confirmed Decisions
|
||||
|
||||
- B-case dedupe identity는 slot별이고 C-case dedupe identity는 `instance_key + symbol`별이다.
|
||||
- recovery/resolved authority는 기존 durable clear 경로(B=`sync`, C=`verify`)로 제한한다.
|
||||
- material change는 동일 identity의 case별 비교 quantity tuple 변경이다.
|
||||
- unchanged suppression은 restart-safe durable dedupe이며 event fingerprint는 publish 성공 뒤에만 기록한다. 실패한 publish는 business path를 중단하지 않고 다음 detection에서 재시도한다.
|
||||
- 요청자 genichin이 2026-08-25 위 결정을 명시하고 dcy-0007 승인을 요청했다. Open Questions는 모두 canonical Scope와 Risks에 반영되어 해소되었다.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
id: "line-0007"
|
||||
---
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: "req-0007"
|
||||
status: approved
|
||||
discovery: "dcy-0007"
|
||||
criteria:
|
||||
create:
|
||||
- "ac-0024"
|
||||
update: []
|
||||
retire: []
|
||||
satisfy: []
|
||||
---
|
||||
|
||||
# Reconciliation 수량 불일치 알림
|
||||
|
||||
## Objective
|
||||
|
||||
`trex-core`가 B-case(core slot↔broker)와 C-case(portfolio-hub symbol aggregate↔broker) 수량 불일치의 최초 탐지, material quantity change와 authoritative resolution을 중복 폭주 없이 `trex.alerts`에 발행하게 한다. 알림 lifecycle은 process restart 뒤에도 이어지고 publish 실패는 다음 detection에서 재시도할 수 있어야 하며, reconciliation·warning·slot·broker·portfolio-hub의 기존 동작과 권한을 중단하거나 변경하지 않는다.
|
||||
|
||||
## Scope
|
||||
|
||||
- B-case mismatch는 stable slot identity별로 추적하고, C-case mismatch는 `instance_key + symbol`별로 추적한다. C-case의 동일 symbol에 여러 portfolio/slot이 있어도 하나의 aggregate mismatch lifecycle로 취급한다.
|
||||
- B-case 비교 quantity tuple은 core slot quantity와 broker quantity이고, C-case 비교 quantity tuple은 같은 instance·symbol의 portfolio-hub `asset_quantity` 합계와 broker quantity다.
|
||||
- 이전에 성공적으로 알린 fingerprint가 없는 active mismatch는 detected event를 발행한다. 같은 identity와 같은 quantity tuple의 scheduler 반복 탐지는 event를 재발행하지 않는다.
|
||||
- active mismatch의 case별 quantity tuple이 바뀌면 changed event를 한 번 발행하고 성공한 새 fingerprint를 durable하게 보존한다.
|
||||
- B-case recovery/resolved event는 operator `sync`가 broker-authoritative quantity로 warning을 clear하는 경로에서만 발행한다. C-case recovery/resolved event는 operator `verify`가 `instance_key + symbol` aggregate 일치를 재검증하고 C-case warning을 clear하는 경로에서만 발행한다.
|
||||
- scheduler가 일치 quantity를 관찰하거나 process-local warning을 clear하는 것만으로 durable warning을 자동 clear하거나 recovery event를 발행하지 않는다.
|
||||
- detected·changed·resolved event는 lifecycle state를 구분하고 `instance_key`, `symbol`, `case_code`, broker quantity 및 case별 비교 quantity를 포함한다. B-case identity에는 stable slot identifier를 포함할 수 있으나 account number/hash, credential, token, DSN, raw external payload 또는 exception 본문은 포함하지 않는다.
|
||||
- successful publish 뒤에만 해당 lifecycle fingerprint를 durable하게 기록한다. detected/changed publish가 실패하면 다음 동일 detection에서 재시도한다. resolved publish가 실패해도 warning clear는 진행하며, notification state가 writable이면 pending resolved transition을 보존하여 후속 retry opportunity에서 재시도한다.
|
||||
- 기존 `AlarmEmitter`와 `trex.alerts` envelope·routing contract를 재사용하고 새 event가 `trex-notifier`의 기존 alarm 소비 경계에 도달하게 한다.
|
||||
- B/C 최초 탐지, C-case 다중 portfolio 단일 event, unchanged suppression, quantity change, restart, B sync resolution, C verify resolution, publish failure retry와 sensitive field 부재를 repository-local automated tests로 검증한다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- AC-0023의 C-case `instance_key + symbol` aggregate 판정, strategy RPC 제외, intent 차단, C-case-only clear와 operator authority를 완화하거나 대체하지 않는다.
|
||||
- B/C warning의 `case_code`, mismatch 판정식, slot lifecycle 및 broker/portfolio-hub quantity는 알림을 위해 변경하지 않는다.
|
||||
- notification state는 restart-safe하고 replay-safe한 persistence로 관리해야 하며 기존 migration runner의 반복 적용을 견뎌야 한다.
|
||||
- publish success와 durable fingerprint write 사이의 failure window에서는 at-least-once delivery에 따른 duplicate가 가능하지만, fingerprint를 publish 시도 전에 기록하여 event를 영구 유실시키는 방식은 허용하지 않는다.
|
||||
- alarm publish 및 notification-state persistence failure는 reconciliation, warning set/clear, operator sync/verify와 scheduler를 abort시키지 않는 best-effort 경계여야 한다. resolved publish가 실패하고 notification state가 writable이면 pending transition을 보존하지만, notification-state persistence 자체가 실패하면 authoritative warning clear를 되돌리거나 지연하지 않으며 해당 event 유실 가능성을 sanitized log로 남긴다.
|
||||
- 기존 alarm consumer와 wire compatibility를 유지하며 RabbitMQ topology 또는 `trex-notifier` message rendering 변경을 요구하지 않는다.
|
||||
- credential, account 식별정보, token, DSN 및 raw KIS/portfolio-hub payload를 test fixture, event, log 또는 evidence에 기록하지 않는다.
|
||||
- Normative Design Documents: 없음. 이 REQ와 AC-0024가 normative specification을 소유한다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- B/C mismatch 판정, warning persistence·blocking·clear policy 또는 AC-0023 수정
|
||||
- scheduler 관찰에 의한 automatic warning clear 또는 recovery 발행
|
||||
- broker/core/portfolio-hub quantity 자동 보정과 operator `sync`/`verify` 승인 절차 변경
|
||||
- A-case broker-flat 및 기존 alarm inventory의 semantics 변경
|
||||
- `trex-notifier`의 Telegram routing·rendering 구현, RabbitMQ topology 변경과 production delivery 검증
|
||||
- live credential·account·database·RabbitMQ·portfolio-hub 연결, production migration, deployment, release 또는 issue closure
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
id: "dcy-0008"
|
||||
status: confirmed
|
||||
---
|
||||
|
||||
# Reconcile-warned strategy intent rejection alerts
|
||||
|
||||
## Problem
|
||||
|
||||
활성 reconciliation warning이 있는 slot에 외부 strategy가 신규 buy 또는 sell intent를 제출하면 `IntentService`는 `reconcile_warned` 사유로 안전하게 거부하지만, 현재는 RPC 응답과 `intent_log` 외에 운영자에게 즉시 전달되는 신호가 없다. Strategy가 마지막 slots RPC 이후 발생한 warning을 알지 못한 채 intent를 제출하는 race에서도 거부 자체는 정상 동작하지만, 운영자는 반복되는 거부와 전략 정체의 원인을 별도 조사 전까지 인지하기 어렵다.
|
||||
|
||||
기대 상태는 durable intent idempotency와 기존 reconciliation 차단 권한을 유지하면서, 새 `intent_id`가 최초로 `reconcile_warned` 최종 거부된 시점에만 allowlist 기반 `trex.alerts` 경고를 best-effort로 한 건 발행하는 것이다. 동일 `intent_id`의 retry는 저장된 결정을 그대로 반환하되 추가 경고를 만들지 않아야 하며, 알림 실패가 intent 거부·응답·저장 또는 다른 business path를 바꾸어서는 안 된다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- GitHub issue [#74](https://github.com/fourblocks2020/trex-core/issues/74)는 실운영에서 warning 활성 뒤 strategy intent가 거부됐지만 즉시 알림이 없어 원인 파악이 지연된 사례를 기록하고, 최초 `reconcile_warned` 거부 알림·중복 억제·민감정보 제외·non-blocking 동작을 요구한다. 확인 시 issue는 open이고 후속 comment나 연결된 PR은 없었다.
|
||||
- 현재 `trex_core/application/intents.py`의 `submit_buy()`와 `submit_sell()`은 `IntentStore.try_insert()`를 dedup gate로 호출한 뒤 admission 결과를 `finalize()`하고 반환한다. Duplicate `intent_id`는 `_dedup()`에서 기존 결정을 반환하므로 새 admission을 실행하지 않는다.
|
||||
- 같은 파일의 buy·sell warning gate는 `warn_store.is_warned(slot_id)`가 참이면 `IntentDecision(status="rejected", reason="reconcile_warned")`을 반환한다. `IntentService` constructor에는 `alarm_emitter` 의존성이 없고, 이 최종 거부를 `trex.alerts`에 발행하는 호출도 없다.
|
||||
- `trex_core/runtime/daemon.py`는 하나의 `AlarmEmitter`를 생성해 ledger, kill-switch, scheduler와 execution service에 주입하지만 `IntentService`에는 주입하지 않는다. 따라서 production intent path에는 재사용 가능한 publisher가 존재하지만 wiring이 빠져 있다.
|
||||
- `trex_core/application/alarm_emitter.py`의 `publish_alarm()`은 `severity`로 `critical | warn | info`를 사용하고 publish exception을 삼켜 호출자의 business path를 중단하지 않는 best-effort 계약이다. `docs/interface-contract.md`도 warning wire literal을 `warn`으로 정의한다.
|
||||
- `trex_core/adapters/persistence/postgresql.py`의 `PostgreSQLIntentStore`는 `intent_id` unique insert를 durable dedup gate로 사용하고 final `status`, `slot_id`, `reason`을 저장한다. 별도 notification state 없이도 “최초 처리된 intent만 발행 후보”라는 경계는 이 기존 gate로 정할 수 있다.
|
||||
- `tests/application/test_reconcile_warning_blocking.py`와 `tests/application/test_slots_handler_quantity_mismatch.py`는 buy/sell `reconcile_warned` 거부와 last-RPC race를 검증하지만 alarm 발행을 단언하지 않는다. `tests/application/test_messaging_intent_consumer.py`는 duplicate `intent_id`가 저장된 동일 결정을 반환하는 idempotency를 검증한다.
|
||||
- 현재 `main` revision `2e314232e3073381b6fc9ee268ae8e12fbcc8749`에서 관련 baseline인 `tests/application/test_reconcile_warning_blocking.py`, `tests/application/test_slots_handler_quantity_mismatch.py`, `tests/application/test_messaging_intent_consumer.py`, `tests/application/test_alarm_emitter.py`를 실행한 결과 `84 passed`였다.
|
||||
- Git history에서 `c193388`은 external strategy buy/sell에 warning gate를 의도적으로 추가하면서 safety-sell·kill-switch·watchdog가 `IntentService`를 우회하도록 정했다. 이번 Line은 그 차단 정책이나 내부 안전 경로를 변경하지 않고, 이미 확정된 외부 intent 거부의 운영 가시성만 추가한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- AC admission은 새 `AC-0025` **create**로 분류한다. 가장 가까운 `AC-0023`은 C-case strategy RPC 제외·intent blocking·warning clear 권한을 소유하고, `AC-0024`는 B/C mismatch 자체의 detected/changed/resolved lifecycle을 소유한다. 이번 동작은 “외부 strategy intent가 실제로 차단된 최초 사건”이라는 별도 운영 신호이며 독립 PASS/FAIL과 장기 active 가치가 있으므로 기존 AC의 update 또는 satisfy로 흡수하지 않는다. `update`, `retire`, `satisfy`, `release evidence` 대상은 없다.
|
||||
- `IntentService`의 buy·sell 처리에서 새 `intent_id`의 최종 decision이 `status="rejected"` 및 `reason="reconcile_warned"`로 확정되고 `IntentStore.finalize()`가 성공한 뒤에만 `AlarmEmitter.publish_alarm()`을 한 번 호출한다.
|
||||
- Alarm은 기존 wire 계약에 맞춰 `severity="warn"`, code `strategy_intent_rejected`를 사용한다. Fields는 최소 allowlist인 `instance_key`, `intent_id`, `intent_type`, `reason`, `broker_submitted=false`를 포함하고, 요청 또는 canonical slot에서 안전하게 확인 가능한 경우에만 `slot_id`, `portfolio_id`, `symbol`, normalized `strategy_id`를 추가한다.
|
||||
- Buy의 canonical `slot_id` 입력 또는 legacy `portfolio_id` mapping과 sell의 `slot_id` 입력 모두에서 가능한 slot context를 수집하되, context 조회 실패나 optional field 부재는 원래 `reconcile_warned` decision을 바꾸거나 알림을 위해 새로운 mutation을 유발하지 않는다.
|
||||
- 동일 `intent_id`의 duplicate/retry는 기존 durable `intent_log` 결정을 반환하고 alarm을 재발행하지 않는다. 최초 처리 중 관찰되는 `processing`/`duplicate_in_flight` 응답도 별도 alarm 대상이 아니다.
|
||||
- Alarm publish 성공 여부와 무관하게 원래 거부 decision과 RPC 응답은 유지된다. Publisher 부재와 transport failure는 non-blocking이며 별도 durable outbox, notification-state row 또는 retry loop를 만들지 않는다.
|
||||
- Alarm title·fields·rendered log에는 credential, token, account number/hash, DSN, raw strategy payload, raw broker/provider payload와 raw exception body를 포함하지 않는다.
|
||||
- Production daemon은 이미 생성하는 동일 `AlarmEmitter`를 `IntentService`에 주입한다. Optional dependency를 사용하는 직접 application test와 non-daemon construction은 기존 동작을 유지한다.
|
||||
- Verification은 buy와 sell의 최초 경고 각 한 건, 같은 `intent_id` retry의 추가 경고 0건, 다른 `intent_id`의 독립 경고, admitted 및 다른 rejection reason의 무발행, publish failure의 non-blocking, payload allowlist·`broker_submitted=false`, production wiring과 기존 warning blocking/idempotency regression을 포함한다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `AC-0023`의 B/C warning 판정, slots RPC 제외, buy/sell 차단, fail-closed 및 authoritative clear semantics 변경.
|
||||
- `AC-0024`의 mismatch detected/changed/resolved alarm과 `reconcile_notification_state` 변경 또는 통합.
|
||||
- Strategy retry·backoff·pause·circuit-breaker 정책, strategy 또는 TVR wire schema 변경, 새로운 broker order submit/cancel 동작.
|
||||
- Safety-sell, kill-switch, watchdog 등 `IntentService`를 우회하는 내부 안전 경로의 차단 또는 알림.
|
||||
- 다른 rejection reason에 대한 범용 intent rejection alarm, 알림 집계·rate limit·severity escalation.
|
||||
- Alarm delivery를 위한 durable outbox, 별도 notification table, 재시작 후 재발행 또는 publish retry 보장. `finalize()` 성공과 alarm publish 사이 process crash로 인한 알림 유실 가능성은 이 Line에서 해소하지 않는다.
|
||||
- `trex-notifier`의 message formatting·routing·delivery channel 변경과 운영 배포 자체.
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
### Risks
|
||||
|
||||
- `IntentStore.finalize()` 뒤 best-effort publish를 수행하므로 process가 그 사이 종료되거나 transport가 실패하면 durable decision은 남지만 alarm은 유실된다. Issue가 outbox/retry를 제외했으므로 이 Line은 business path 독립성을 우선하며, delivery 보장이 필요해지면 별도 Requirement로 다룬다.
|
||||
- Buy는 canonical `slot_id`와 legacy `portfolio_id` 두 입력을 허용하고 sell은 `sell_strategy_id`를 사용한다. Context 정규화가 admission logic을 복제하면 drift 위험이 있으므로, 알림은 decision에 영향을 주지 않는 helper와 allowlist output으로 제한하고 양쪽 입력 형태를 테스트해야 한다.
|
||||
- 기존 `AlarmEmitter`는 여러 subsystem이 공유한다. Intent-specific 실패 처리나 logging 변경이 공용 emitter 계약을 확장하지 않도록 production wiring과 publisher failure test를 분리해야 한다.
|
||||
- Rejection payload에 raw request 또는 `extra` dict를 복사하면 민감정보가 유출될 수 있다. 허용된 scalar field만 명시적으로 구성하고 forbidden key/value sentinel을 rendered payload와 log에서 검사해야 한다.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- Issue #74의 “경고(WARNING)”는 현재 interface contract의 wire literal `severity="warn"`을 의미한다.
|
||||
- “최초”의 canonical identity는 기존 `IntentStore.try_insert()`가 소유하는 `intent_id`이며, process-local set이나 새로운 persistence를 추가하지 않는다.
|
||||
- `strategy_intent_rejected`는 issue가 제시한 이 기능의 canonical alarm code로 사용하며, notifier-side inventory 등록이 별도로 필요하더라도 core의 event contract를 변경하지 않는다.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
id: "line-0008"
|
||||
---
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: "req-0008"
|
||||
status: approved
|
||||
discovery: "dcy-0008"
|
||||
criteria:
|
||||
create:
|
||||
- "ac-0025"
|
||||
update: []
|
||||
retire: []
|
||||
satisfy: []
|
||||
---
|
||||
|
||||
# Reconcile-warned strategy intent rejection alerts
|
||||
|
||||
## Objective
|
||||
|
||||
`trex-core`가 활성 reconciliation warning 때문에 외부 strategy의 신규 buy 또는 sell intent를 `reconcile_warned`로 최종 거부하면, 해당 `intent_id`의 최초 처리에서만 원인을 식별할 수 있는 sanitized warning event를 `trex.alerts`에 한 건 발행하게 한다. 기존 durable intent idempotency와 warning 차단 권한을 유지하고, duplicate intent 및 무관한 decision의 알림을 억제하며, 알림 실패가 intent decision·저장·응답 또는 broker 동작에 영향을 주지 않도록 한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- `IntentService`가 새 `intent_id`를 처리하여 buy 또는 sell decision을 `status="rejected"`, `reason="reconcile_warned"`로 결정하고 `IntentStore.finalize()`가 성공한 뒤에만 warning event를 발행한다.
|
||||
- 동일 `intent_id`의 duplicate/retry는 기존 `IntentStore.try_insert()`와 저장된 final decision을 canonical dedup authority로 사용한다. Duplicate path, 처리 중인 intent 및 process-local cache는 새 event를 만들지 않는다.
|
||||
- 서로 다른 `intent_id`가 같은 warned slot을 대상으로 각각 거부되면 각 intent는 독립적인 최초 거부 event 한 건을 만든다.
|
||||
- Event는 기존 `AlarmEmitter`와 `trex.alerts` envelope·routing contract를 재사용하고 `severity="warn"`, `code="strategy_intent_rejected"`를 사용한다.
|
||||
- Event fields의 필수 allowlist는 `instance_key`, `intent_id`, `intent_type`, `reason="reconcile_warned"`, `broker_submitted=false`다. Canonical slot 또는 intent에서 안전하게 확인된 값이 있을 때만 `slot_id`, `portfolio_id`, `symbol`, `strategy_id`를 추가하며, unavailable optional field는 추정하거나 raw payload에서 복사하지 않고 생략한다.
|
||||
- Buy의 `strategy_id`와 sell의 `sell_strategy_id`는 event의 공통 `strategy_id` field로 정규화한다. Buy의 canonical `slot_id` 또는 legacy `portfolio_id` 입력과 sell의 `slot_id` 입력 모두에서 같은 allowlist 계약을 적용한다.
|
||||
- Optional context 조회 실패, publisher 부재 및 alarm transport failure는 원래 final decision과 RPC response를 변경하거나 intent 처리를 실패시키지 않는다. 알림을 위해 slot, warning, intent, broker, ledger 또는 portfolio-hub 상태를 mutation하지 않는다.
|
||||
- Production daemon은 이미 생성하는 동일 `AlarmEmitter`를 `IntentService`에 연결한다. 직접 application construction과 non-daemon entry point는 optional publisher 경계에서 기존 동작과 호환된다.
|
||||
- Buy·sell 최초 거부, duplicate 억제, 서로 다른 intent의 독립 발행, 다른 decision의 무발행, publish failure, optional context, payload 보안과 production wiring을 repository-local automated tests로 검증한다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- AC-0023이 소유하는 B/C warning 판정, strategy RPC 제외, buy/sell `reconcile_warned` 차단, warn-store fail-closed 및 authoritative clear semantics를 완화·대체·확장하지 않는다.
|
||||
- AC-0024의 mismatch detected/changed/resolved lifecycle, fingerprint, `reconcile_notification_state`와 operator `sync`/`verify` recovery authority를 변경하거나 재사용하지 않는다.
|
||||
- Alarm은 `IntentStore.finalize()`보다 먼저 발행해서는 안 된다. Finalize가 실패하거나 final decision이 확정되지 않은 경우 알림을 성공한 거부로 보고해서는 안 된다.
|
||||
- Publisher return value나 failure는 저장된 rejection을 rollback하거나 RPC status/reason을 바꾸지 않는다. 별도 durable outbox, notification-state persistence 또는 retry loop를 추가하지 않는다.
|
||||
- Event title, fields와 이 경로가 남기는 rendered log에는 account number/hash, credential, token, password, DSN, raw strategy `extra`, raw request, raw KIS/broker/portfolio-hub payload 또는 raw exception body가 없어야 한다.
|
||||
- `broker_submitted=false`는 거부가 broker submission 전에 확정됐음을 나타내며, 이 기능이 broker submit/cancel 호출을 새로 만들거나 기존 호출 순서를 바꾸어서는 안 된다.
|
||||
- Safety-sell, kill-switch, watchdog처럼 `IntentService`를 우회하는 내부 안전 경로는 계속 reconciliation warning gate와 이 strategy rejection alarm의 대상이 아니다.
|
||||
- 기존 intent RPC response schema, intent idempotency, slot transition과 alarm wire compatibility를 유지하며 RabbitMQ topology 또는 notifier rendering 변경을 요구하지 않는다.
|
||||
- Normative Design Documents: 없음. 이 REQ와 AC-0025가 normative specification을 소유한다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 다른 rejection reason의 범용 strategy intent alarm, reason별 severity 정책, rate limit·aggregation·escalation
|
||||
- Strategy retry·backoff·pause·circuit-breaker 정책과 strategy/TVR wire schema 변경
|
||||
- B/C mismatch 탐지·해소 알림과 strategy intent 거부 알림의 lifecycle 또는 persistence 통합
|
||||
- Alarm의 exactly-once delivery, process crash 복구, durable outbox, publish retry와 재시작 후 재발행
|
||||
- Warning 자동 해제, quantity 자동 보정, broker order submit/cancel, ledger write 또는 portfolio-hub write
|
||||
- Safety-sell·kill-switch·watchdog 내부 경로의 신규 알림
|
||||
- `trex-notifier` message formatting·delivery channel, RabbitMQ topology, production deployment, release 또는 issue closure
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
id: "dcy-0009"
|
||||
status: confirmed
|
||||
---
|
||||
|
||||
# Persistent strategy-instance state lifecycle and identity
|
||||
|
||||
## Problem
|
||||
|
||||
`slot_strategy_state`는 trex-core가 내용을 해석하지 않는 opaque 전략 상태이지만, 현재 저장·조회·삭제 계약은 그 상태의 lifecycle과 소유권을 core의 slot occupancy 및 `strategy_id`에 결합한다. 슬롯이 어떤 경로로든 `INACTIVE`가 되면 core가 해당 슬롯의 모든 전략 상태를 삭제하고, 저장된 `occupancy_seq`가 현재 슬롯과 다르면 조회에서 숨긴다. 이 계약은 TSL peak처럼 occupancy마다 새로 시작하는 상태에는 맞지만, TVR-v2 reserve처럼 close와 재점유를 넘어 같은 전략 실행 instance가 이어서 사용해야 하는 상태를 표현하지 못한다. Opaque `data`의 의미와 만료 시점을 모르는 core가 모든 전략에 동일한 삭제 정책을 강제한다는 점도 책임 경계와 맞지 않는다.
|
||||
|
||||
현재 row와 RPC namespace는 `(slot_id, strategy_id)`를 사용한다. 여기서 `strategy_id`는 `tvr_v2` 같은 전략 종류를 나타내므로, 같은 종류의 서로 다른 설정·실행 instance가 같은 슬롯을 사용하면 하나의 row와 version counter를 공유한다. 한 instance의 상태가 다른 instance에 상속되거나 서로의 CAS가 충돌할 수 있다.
|
||||
|
||||
`strategy_instance_id`는 state namespace이면서 전략 실행 instance의 안정적인 identity다. 현재 `slots.req`는 한 슬롯에서 조회된 모든 strategy-state namespace를 map으로 반환하지만, 일반 strategy가 필요한 것은 자기 `strategy_instance_id`에 해당하는 state뿐이다. 다른 strategy instance의 state를 함께 노출하면 consumer가 잘못된 entry를 선택할 여지가 있고 wire가 불필요하게 전체 namespace를 공개한다. `strategy_instance_id`는 매 요청에서 임의로 선택하는 값이 아니라 strategy runtime의 필수·불변 non-empty string 설정이어야 한다. 누락·비문자열·빈 값처럼 잘못 설정된 instance가 실행되면 state namespace 오염보다 잘못된 slot·queue·주문 경계에서 전략이 동작하는 문제가 더 크므로, 그런 runtime은 worker나 decision cycle을 시작할 수 없어야 한다.
|
||||
|
||||
기대 상태는 lifecycle과 identity 변경을 함께 다루는 것이다. Strategy state는 slot occupancy에서 완전히 분리되어 close와 재점유를 넘어 보존되고, 하나의 slot-scoped `data` map 안에서 안정적인 `strategy_instance_id`별 entry와 monotonic version을 가져야 한다. State persistence와 write RPC에서 `occupancy_seq`를 제거하고 동시 write는 요청 instance entry의 version CAS만으로 조정한다. 하나의 core `instance_key`에는 여러 strategy instance가 연결될 수 있으며 core가 그 목록을 별도 registry로 저장하지 않는다. 각 strategy runtime은 필수 설정으로 확정한 자기 `strategy_instance_id`만 `slots.req`와 state write에 사용하고, core는 전체 map이 아니라 요청 ID와 정확히 일치하는 entry 하나만 slots 응답에 넣어야 한다. 잘못된 설정이면 strategy는 실행 전에 fail-closed해야 한다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- GitHub issue [#77](https://github.com/fourblocks2020/trex-core/issues/77)은 close 시 자동 삭제와 read-side occupancy filtering을 제거하고 state 의미·session 만료·갱신 판단을 전략에 돌리도록 요구한다. 본문에는 이전 설계의 `expected_occupancy_seq` CAS 유지가 남아 있다.
|
||||
- GitHub issue [#78](https://github.com/fourblocks2020/trex-core/issues/78)은 그 뒤 확정된 persistence/write contract로서 state namespace와 RPC의 소유 키를 안정적인 `strategy_instance_id`로 바꾸고, 저장 요청을 `{slot_id, strategy_instance_id, expected_version, data}`로 정의한다. 사용자는 #78이 `occupancy_seq`에 관한 마지막 결정이며 이 계약이 #77의 `expected_occupancy_seq` 유지 문구보다 우선한다고 확인했다. 또한 slot persistence는 `data[strategy_instance_id] = {version, data, updated_at}` 형태의 slot-scoped map으로 저장한다고 확인했다. 따라서 이 draft는 strategy-state persistence·application model·write RPC·CAS에서 `occupancy_seq`를 제거하고 map entry별 version CAS만 유지한다.
|
||||
- GitHub issue [#79](https://github.com/fourblocks2020/trex-core/issues/79)는 `slots.req` response에 관한 후속 최종 계약이다. 저장소에는 여러 `strategy_instance_id` entry가 함께 존재하지만 일반 전략 응답에는 요청의 `strategy_instance_id`와 정확히 일치하는 entry 하나만 `{version, data, updated_at}` 단일 object로 반환하고 다른 instance의 ID와 state를 포함하지 않는다. 사용자는 응답 필터 기준이 `strategy_id` 집합이 아니라 요청의 정확한 `strategy_instance_id`라고 확인했다. 이 Discovery에서는 core-side instance 목록 registry나 caller 인증을 두지 않으며, `strategy_instance_id`를 strategy runtime의 필수·불변 설정으로 사용한다.
|
||||
|
||||
- 현재 `main` revision `ddf7d6710bed9d0b8b91a3ce9c96ad17e129bd78`의 `trex_core/adapters/persistence/sql/015_create_slot_strategy_state.sql`은 `PRIMARY KEY (slot_id, strategy_id)`를 선언하고 `occupancy_seq`, `version`, opaque `data`를 저장한다.
|
||||
- `trex_core/application/strategy_state.py`의 `StrategyStateEntry`, `StrategyStateStore`, `InMemoryStrategyStateStore`는 state를 `(slot_id, strategy_id)`로 식별한다. `get_for_slots()`는 저장된 `occupancy_seq`가 현재 값과 다르면 row를 숨기고, `delete_all(slot_id)`는 해당 slot의 모든 namespace를 삭제한다. `_cas()`는 `expected_occupancy_seq == current_occupancy_seq`와 expected/stored version 일치를 독립적으로 검사한다.
|
||||
- `trex_core/adapters/persistence/postgresql.py`의 `PgStrategyStateStore`도 `(slot_id, strategy_id)`로 `SELECT ... FOR UPDATE` 및 upsert를 수행한다. Version은 기존 row가 없으면 0에서 시작하고 성공한 write마다 1 증가한다.
|
||||
- `trex_core/domain/slot/service.py`의 `SlotService.transit_slot()`은 모든 `to_state is INACTIVE` 전이에서 `strategy_state_store.delete_all(slot_id)`을 호출한다. Strategy state 삭제는 slot update 및 transition-log append와 같은 transition 경로 안에서 실행된다.
|
||||
- `trex_core/application/messaging_handlers.py`의 `make_slots_rpc_handler()`는 현재 occupancy와 일치하는 모든 state를 읽어 `strategy_state[strategy_id]` map 전체를 슬롯 row에 넣는다. `make_strategy_state_set_handler()`는 payload의 `strategy_id`를 필수로 받아 그대로 저장소에 전달하며 caller와 namespace의 소유 관계를 검사하지 않는다.
|
||||
- 실제 write 호출은 strategy daemon이 AMQP direct-reply-to RPC로 instance별 `trex.slots.strategy_state.set[.{instance_key}]` queue에 요청하는 방식이다. 현재 TSL client는 `{slot_id, strategy_id, expected_occupancy_seq, expected_version, data}`를 보내며 `data`를 opaque JSON string으로 직렬화한다. 현재 구현의 `correlation_id`는 reply matching에만 사용되고 `expected_occupancy_seq`와 `expected_version` CAS가 각각 occupancy 전환과 version 충돌을 검사한다. 이 중 occupancy 결합은 #78의 최종 payload로 대체할 현재 baseline이다.
|
||||
- `trex_core/runtime/daemon.py`는 `slots.req`와 `strategy_state.set` consumer를 `config.instance_key`별 queue에 연결하지만 두 handler에 strategy-instance registry를 주입하지 않는다. `trex_core/runtime/config.py`와 repository 전체를 `strategy_instance_id`로 검색한 결과 정의나 사용처는 0건이고, per-instance TOML에는 `instance_key`만 있다.
|
||||
- `tests/domain/slot/test_occupancy_seq_and_close_delete.py`는 모든 close 경로의 자동 삭제와 재점유 후 빈 상태를 기대한다. `tests/application/test_slots_handler_strategy_state.py`는 재점유 시 이전 상태가 노출되지 않는 현재 계약을, `tests/application/test_strategy_state_set_handler.py`와 `tests/application/test_strategy_state_store.py`는 occupancy mismatch 거부와 `(slot_id, strategy_id)`별 version CAS를 검증한다.
|
||||
- 위 네 focused test와 PostgreSQL 관련 test를 현재 revision에서 `strategy_state or occupancy_seq or close_delete`로 실행한 결과 `33 passed, 1 skipped, 9 deselected`였다. 이는 issue가 바꾸려는 현재 lifecycle·key·visibility baseline이 실행 가능한 상태임을 확인한다.
|
||||
- Git history의 `e0d4e7130400bbfe576d5a6fc94b87dcc26f0734`는 issue #26과 legacy `dcy-028`의 “strategy state lifecycle은 slot occupancy와 일치한다”는 전제 아래 close-delete, occupancy-filtered read, `(slot_id, strategy_id)` key와 occupancy/version CAS를 함께 도입했다. 현재 `docs/runtime-flows.md` 349~359행과 `docs/interface-contract.md` 527~541행도 같은 C-1/C-2/C-3 계약을 설명한다.
|
||||
- 현재 ProofLine active criteria 전체에서 `strategy_state`, `occupancy_seq`, `delete_all`을 검색한 결과 직접 소유하는 AC는 없었다. 가장 가까운 `AC-0023`은 C-case reconciliation warning에 따른 strategy-facing `slots.req` 제외와 intent blocking을 소유하며 state persistence와 namespace는 다루지 않는다.
|
||||
|
||||
## Scope
|
||||
|
||||
- 이번 Line은 strategy state의 lifecycle을 slot occupancy에서 분리하고, state namespace의 identity를 `strategy_instance_id`로 전환하는 변경을 함께 다룬다.
|
||||
- 이 변경의 Acceptance Criteria는 version-independent 동작을 검증하는 서로 독립적인 신규 AC 세 건으로 분리한다.
|
||||
- close·재점유를 넘어 strategy state와 version을 삭제하지 않고 보존하는 lifecycle criterion
|
||||
- 슬롯별 state map에서 `strategy_instance_id`를 각 state entry의 key로 사용하고 같은 `strategy_id`의 instance별 state·version 격리를 보장하는 identity criterion
|
||||
- `strategy_instance_id`를 전략용 request-scoped identity로 사용하여 `spy=false`인 `slots.req`가 해당 ID의 state만 반환하고 누락·비문자열·빈 ID 요청을 fail-closed하되, `spy=true`인 operator 조회는 `strategy_instance_id`를 요구하지 않고 watchdog을 갱신하지 않으며 각 슬롯의 전체 strategy-state map을 `strategy_instance_id`별 entry로 반환하는 criterion
|
||||
- 기존 active AC의 `update`, `retire`, `satisfy`, `release evidence` 대상은 없다. 특히 `AC-0023`의 reconciliation warning 판정·slot 제외·intent blocking·clear 권한은 변경하지 않는다.
|
||||
- Legacy Discovery와 REQ는 업데이트 대상이 아니다. 현재 `docs/runtime-flows.md`와 `docs/interface-contract.md`의 strategy-state baseline 문구만 새 AC가 승인된 뒤 approved change로 정합화한다.
|
||||
- 어떤 경로의 `→ INACTIVE` 전이에서도 일반 lifecycle hook이 slot-scoped strategy-state map이나 그 entry를 자동 삭제하지 않게 한다. Map은 inactive와 이후 재점유 상태에서도 유지하며 현재 occupancy와 무관하게 다시 볼 수 있게 한다.
|
||||
- Core는 opaque `data`를 파싱하거나 전략별 session·reserve·peak의 유효성을 판단하지 않는다. State의 session identity와 내용을 이어 쓸지 초기화할지는 전략이 결정한다.
|
||||
- `occupancy_seq`는 slot-scoped strategy-state persistence schema, `StrategyStateEntry`, store protocol, read filtering, `strategy_state.set` request/response와 state CAS에서 제거한다. Slot 자체의 `occupancy_seq`와 증가 규칙은 이 Line에서 변경하지 않지만 strategy-state persistence와 write 승인에는 사용하지 않는다.
|
||||
- State write는 `expected_version`과 slot map의 `data[strategy_instance_id].version`만 비교한다. 해당 entry가 없을 때 `expected_version=0`, 성공 시 그 entry의 version만 기존 값에서 1 증가하며 close·reopen은 version을 초기화하지 않는다. 다른 instance entry와 version은 변경하지 않는다. 같은 slot에서 서로 다른 instance entry를 동시에 갱신해도 각 성공 write가 모두 보존되고 어느 entry도 유실되지 않아야 한다. Slot active-state 검사와 inner opaque data size limit는 유지한다.
|
||||
- 일반 lifecycle에는 strategy-state 삭제 동작을 두지 않는다. 기존의 파괴적 전체 DB reset은 별도 운영 기능으로 유지한다.
|
||||
- Persistence, in-memory model, dataclass, store protocol과 PostgreSQL query/update는 슬롯별로 하나의 state map을 소유하고 `strategy_instance_id`를 각 state entry의 key로 사용하도록 전환한다. 각 entry는 `{version, data, updated_at}`를 가진다. `strategy_instance_id`가 entry의 저장·조회 key이며 별도의 `strategy_id` metadata를 entry에 중복 저장하지 않는다. 여기서 inner `data`만 전략이 소유하는 opaque string이며 outer map과 entry envelope은 core contract다. 아래 JSON은 설명용 응답 예시가 아니라 entry 전체를 그대로 보존하는 canonical persistence shape이다.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"tvr-v2-prod-01": {
|
||||
"version": 8,
|
||||
"data": "{...}",
|
||||
"updated_at": "..."
|
||||
},
|
||||
"new-v1-prod-01": {
|
||||
"version": 7,
|
||||
"data": "{...}",
|
||||
"updated_at": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 같은 `strategy_id`를 사용하는 서로 다른 `strategy_instance_id`는 동일 slot map에서도 독립된 entry와 version을 가져야 한다. 재시작 후 같은 instance ID를 사용하는 owner는 기존 entry를 이어 읽고, 다른 instance ID는 이를 상속하지 않는다.
|
||||
- `strategy_state.set`은 `strategy_instance_id`를 필수 field로 받으며, core는 누락·비문자열·빈 값을 state 저장 없이 non-retryable `bad_request`로 거부한다. 별도의 core-side registry나 `instance_key ↔ strategy_instance_id` 목록 검증은 도입하지 않는다.
|
||||
- 저장 순서는 `slots.req` read → 전략의 opaque state 계산 → `strategy_state.set` write다. 변경 후 write payload는 `{slot_id, strategy_instance_id, expected_version, data}`이며, `expected_version`은 직전 `slots.req`의 자기 `strategy_state.version`을 사용하고 entry가 없을 때만 `0`을 사용한다. Core는 active slot·해당 entry의 version CAS·inner data size limit를 모두 통과한 경우에만 slot map의 `data[strategy_instance_id]`를 create/update하고 증가한 version을 반환한다.
|
||||
- `version_conflict`이면 전략은 추정한 값으로 재전송하지 않고 다음 `slots.req`에서 자기 state를 다시 읽어 재계산한다. `occupancy_changed` reason은 계약에서 제거한다. `slot_inactive`, `state_too_large`, `bad_request:*`는 해당 write를 적용하지 않는다. RPC `correlation_id`는 저장 멱등성 key로 사용하지 않는다.
|
||||
- `spy=false`인 전략용 `slots.req` 요청은 `strategy_instance_id`를 필수 field로 받는다. 누락·비문자열·빈 값이면 core는 RPC-level error나 새 envelope을 만들지 않고 기존 `slots.req` response envelope으로 다음 non-retryable `bad_request`를 반환한다. `reason="bad_request:strategy_instance_id"`가 retry하지 않을 configuration error의 machine-readable 판별값이며, `slots=[]`이므로 어떤 slot이나 `strategy_state`도 노출하지 않는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"slots": [],
|
||||
"available_slots": null,
|
||||
"degraded": false,
|
||||
"reason": "bad_request:strategy_instance_id",
|
||||
"message": "strategy_instance_id must be a non-empty string"
|
||||
}
|
||||
```
|
||||
|
||||
정상 전략 요청에서는 각 slot의 전체 map을 wire에 넣지 않고 `data[request.strategy_instance_id]`만 선택하여 응답의 `strategy_state`에 `{version, data, updated_at}` 단일 object로 넣는다. 해당 key가 없으면 기존 absent-state 의미를 유지해 `strategy_state` field를 생략하며, 다른 strategy instance의 state를 대신 반환해서는 안 된다. 다음 JSON은 전체 `slots.req` envelope이 아니라 `slots[]` 내부 row 예시다. `strategy_instance_id="tvr-v2-prod-01"` 요청에는 해당 state만 포함한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"slot_id": "slot-001",
|
||||
"strategy_state": {
|
||||
"version": 8,
|
||||
"data": "{...}",
|
||||
"updated_at": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 이 single-object response는 canonical wire contract다. 응답의 `strategy_state` 안에는 `strategy_instance_id` key나 다른 instance를 열거하는 collection을 두지 않는다.
|
||||
- 같은 `instance_key`에 여러 strategy instance가 연결되어도 요청 A에는 `data[A]`만, 요청 B에는 `data[B]`만 반환한다. Core는 이를 위해 strategy-instance 목록 registry를 유지하지 않고 요청의 필수 `strategy_instance_id`를 map lookup key로만 사용한다.
|
||||
- `spy=true`인 operator `slots.req`는 `strategy_instance_id`를 요구하지 않고 기존 filter·slot 조회와 watchdog 비갱신 동작을 유지한다. Operator 응답의 각 `slots[]` row에는 해당 슬롯의 전체 strategy-state map을 `strategy_instance_id`별 entry로 구성한 `strategy_state` field를 포함한다. 요청에 `strategy_instance_id`가 있더라도 operator 조회 결과를 특정 entry로 제한하지 않는다. 이 동작은 기존 `slots.req`를 사용하며 별도 operator/diagnostic API를 신설하지 않는다.
|
||||
- 기존 state에 대해 별도의 `strategy_id → strategy_instance_id` mapping, backfill 또는 instance 귀속 정책을 두지 않는다. 변경된 slot-scoped `data[strategy_instance_id]` persistence contract를 그대로 적용하며, 이를 별도 migration 결정 사항으로 다시 열지 않는다.
|
||||
- Verification은 close 후 map 보존, inactive/read visibility, 재점유 후 entry version 연속 증가, state persistence·model·RPC에서 `occupancy_seq` 부재, entry별 version conflict 거부와 다른 entry 무변경, 같은 종류의 두 instance 격리, 같은 slot의 서로 다른 A·B entry에 대한 동시 성공 write가 모두 보존되는 조건, 동일 slot map에 A·B state가 있을 때 전략 요청 A에는 `data[A]`만/전략 요청 B에는 `data[B]`만 반환되고 다른 ID와 전체 map이 비노출되는 조건, 요청 ID에 해당하는 state가 없을 때 `strategy_state` field 생략, 누락·비문자열·빈 `spy=false` 요청 ID가 기존 envelope의 `slots=[]`, `degraded=false`, `reason="bad_request:strategy_instance_id"`로 반환되어 slot·state를 노출하지 않는 조건과 같은 malformed state-write ID의 저장 거부, `strategy_instance_id` 없는 `spy=true` operator CLI의 기존 slot 조회 성공·watchdog 비갱신·각 슬롯의 전체 strategy-state map 반환, InMemory/PostgreSQL parity 및 production daemon wiring을 포함한다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `profit_buffer`, portfolio 공통 모델, slot occupancy state machine 또는 Slot 자체의 `occupancy_seq`와 증가 규칙 변경.
|
||||
- TVR-v2 reserve·cost basis·processed execution 계산, TSL peak/session 해석 또는 어떤 전략의 opaque `data` schema 정의.
|
||||
- Core가 strategy state의 session 만료나 occupancy 귀속을 판단하는 기능.
|
||||
- Core-side strategy-instance registry, `instance_key`별 strategy 목록 저장, caller 인증 또는 AMQP producer 권한 변경.
|
||||
- Operator/diagnostic용 전체 strategy-state 조회 API 신설.
|
||||
- Strategy-state를 audit/event store로 전환하거나 row history, exactly-once write, multi-writer merge를 도입하는 기능.
|
||||
- `AC-0023`·`AC-0024`·`AC-0025`의 reconciliation warning 및 알림 lifecycle 변경.
|
||||
- Strategy consumer 통합. Consumer는 통합 전제 조건으로 runtime 설정에 required non-empty string `strategy_instance_id`를 두고 startup에서 누락·비문자열·빈 값을 검증해야 한다. 검증 실패 시 RPC worker와 decision cycle을 시작하지 않고 configuration error로 종료하며, 시작 후에는 ID를 정규화·변형하거나 요청별로 override하지 않아야 한다. 이 동작과 검증은 각 consumer repository가 소유한다.
|
||||
- TVR-v2 daemon 또는 다른 전략 repository의 구현·배포. 이 Line은 trex-core persistence와 wire contract만 소유한다.
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
### Risks
|
||||
|
||||
- Close-delete와 occupancy-filtered read를 제거하면 slot-scoped map과 그 entry가 장기간 남는다. 같은 `strategy_instance_id`의 반복 write는 새 entry를 늘리지 않고 기존 entry를 갱신한다.
|
||||
- Lifecycle, key/wire와 strategy configuration 변경을 부분적으로 배포하면 기존 consumer와 새 core의 contract가 어긋날 수 있으므로 core와 strategy consumer를 lockstep으로 배포해야 한다.
|
||||
- Consumer가 통합 전제 조건인 `strategy_instance_id` startup 검증과 불변 사용을 구현하지 않으면 잘못된 namespace로 요청할 수 있다. 이 위험의 완화와 contract test는 각 consumer repository가 소유한다.
|
||||
- `slots.req` handler가 storage의 전체 map을 먼저 조립한 뒤 consumer 쪽에서 필터링하면 wire 노출은 해소되지 않는다. Core의 조회 또는 response 조립 단계에서 요청 ID 하나만 선택하고 serialized payload에 다른 ID가 없는지 검증해야 한다.
|
||||
- Strategy-state에서 occupancy fence를 제거하므로 close·reopen 자체는 이전 read에서 계산된 write를 거부하는 근거가 아니다. Core는 state 내용을 해석하지 않으며 `strategy_instance_id` namespace와 version CAS만 보장한다. Occupancy/session 전환에 따라 data를 초기화해야 하는 전략은 opaque state 내부 규칙으로 처리해야 한다.
|
||||
- `SlotService.transit_slot()`의 close-delete hook을 제거할 때 slot transition의 나머지 원자성과 실패 동작이 바뀌지 않아야 한다.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- `strategy_instance_id`는 process PID나 매 restart마다 달라지는 runtime ID가 아니라 배포 재시작 뒤에도 같은 논리적 전략 instance가 재사용하는 안정적인 식별자다.
|
||||
- 하나의 trex-core `instance_key`를 여러 strategy instance가 소비할 수 있으며, 각 strategy instance는 자기 runtime 설정에서 고유하고 안정적인 `strategy_instance_id`를 소유한다.
|
||||
- 하나의 strategy instance가 같은 slot에서 소유하는 current state는 map entry 하나이며, version은 그 entry의 successful write마다 증가하는 CAS counter다. State history나 mergeable multi-writer log는 요구하지 않는다.
|
||||
- Strategy-state row와 wire contract는 slot occupancy identity를 저장하거나 비교하지 않는다.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
id: "line-0009"
|
||||
---
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: "req-0009"
|
||||
status: approved
|
||||
discovery: "dcy-0009"
|
||||
criteria:
|
||||
create:
|
||||
- "ac-0026"
|
||||
- "ac-0027"
|
||||
- "ac-0028"
|
||||
update: []
|
||||
retire: []
|
||||
satisfy: []
|
||||
---
|
||||
|
||||
# Persistent strategy-instance state lifecycle and identity
|
||||
|
||||
## Objective
|
||||
|
||||
`trex-core`의 opaque strategy state를 slot occupancy lifecycle과 `strategy_id` namespace에서 분리한다. 상태를 슬롯별 `strategy_instance_id` entry로 영속화하여 close와 재점유 뒤에도 같은 instance가 기존 state와 version을 이어 쓰게 하고, 서로 다른 instance의 state와 CAS를 격리한다. 전략용 `slots.req`에는 요청한 instance의 state 하나만 제공하고 operator `spy=true` 조회에는 슬롯의 전체 instance map을 제공하되, 각 경로의 identity 검증·watchdog·가시성 계약을 명확히 유지한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- 어떤 경로로 slot이 `INACTIVE`가 되어도 일반 lifecycle hook이 해당 슬롯의 strategy-state map이나 entry를 자동 삭제하지 않으며, inactive 및 재점유 뒤에도 state와 version을 보존한다.
|
||||
- Strategy-state persistence, application model, store protocol, read filtering, write RPC와 CAS에서 `occupancy_seq` 및 `expected_occupancy_seq`를 제거한다. Slot 자체의 `occupancy_seq`와 증가 규칙은 유지한다.
|
||||
- 슬롯별 strategy-state를 `data[strategy_instance_id] = {version, data, updated_at}` 형태의 map으로 저장한다. Entry에는 `strategy_id`를 중복 저장하지 않으며 inner `data`만 core가 해석하지 않는 opaque string이다.
|
||||
- `strategy_state.set` 입력을 `{slot_id, strategy_instance_id, expected_version, data}`로 정의한다. 해당 instance entry의 version만 CAS하고, 서로 다른 instance의 성공한 동시 write를 모두 보존한다.
|
||||
- `spy=false` 전략용 `slots.req`는 required non-empty string `strategy_instance_id`를 받고, 각 slot에서 요청 ID와 정확히 일치하는 entry 하나만 `strategy_state: {version, data, updated_at}`로 반환한다. 해당 entry가 없으면 `strategy_state`를 생략한다.
|
||||
- 누락·비문자열·빈 `strategy_instance_id`를 받은 전략용 `slots.req`는 기존 response envelope에서 `slots=[]`, `available_slots=null`, `degraded=false`, `reason="bad_request:strategy_instance_id"`, `message="strategy_instance_id must be a non-empty string"`을 반환한다. 같은 malformed ID의 state write는 저장 없이 non-retryable `bad_request`로 거부한다.
|
||||
- `spy=true` operator `slots.req`는 `strategy_instance_id`를 요구하지 않고 기존 filter·slot 조회와 watchdog 비갱신 동작을 유지한다. 각 slot row에는 전체 strategy-state map을 `strategy_instance_id`별 entry로 반환하며, 요청에 ID가 있어도 특정 entry로 제한하지 않는다.
|
||||
- Persistence와 RPC 변경을 InMemory/PostgreSQL parity, production daemon wiring 및 repository-local automated tests로 검증한다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- State write는 active slot 검사, inner opaque data size limit 및 해당 entry의 `expected_version` CAS를 모두 통과해야 한다. Entry가 없을 때만 `expected_version=0`이며 성공할 때 해당 entry의 version만 1 증가한다.
|
||||
- `version_conflict`는 write를 적용하지 않으며 다른 entry를 변경하지 않는다. Consumer는 추정 version으로 재전송하지 않고 다음 `slots.req`에서 자기 state를 다시 읽어 재계산한다.
|
||||
- `occupancy_changed`는 strategy-state reject reason에서 제거한다. `slot_inactive`, `state_too_large`, `bad_request:*`는 write를 적용하지 않고 RPC `correlation_id`를 저장 멱등성 key로 사용하지 않는다.
|
||||
- Core는 opaque state의 session 의미, 만료, 초기화 또는 merge를 판단하지 않으며 core-side strategy-instance registry, `instance_key ↔ strategy_instance_id` 목록 및 caller 인증을 도입하지 않는다.
|
||||
- 기존 state에 대한 `strategy_id → strategy_instance_id` mapping, backfill 또는 instance 귀속 정책을 도입하지 않고 신규 persistence contract를 그대로 적용한다.
|
||||
- 같은 `strategy_instance_id`는 재시작 뒤에도 같은 논리적 전략 instance가 사용하는 안정적 identity다. Consumer 통합은 각 consumer repository가 소유하며 required non-empty string 설정, startup fail-closed, 불변 사용 및 요청별 override 금지를 전제한다.
|
||||
- AC-0023의 reconciliation warning 판정·slot 제외·intent blocking·clear 권한과 AC-0022의 strategy-facing broker quantity 계약을 변경하지 않는다.
|
||||
- Legacy Discovery와 REQ는 업데이트하지 않는다. 새 AC 승인 뒤 현재 `docs/runtime-flows.md`와 `docs/interface-contract.md`의 strategy-state baseline 문구만 approved change에 맞게 정합화한다.
|
||||
- Normative Design Documents: 없음. 이 REQ와 AC-0026~AC-0028이 이번 specification의 normative behavior를 소유한다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- `profit_buffer`, portfolio 공통 모델, slot occupancy state machine 또는 Slot 자체의 `occupancy_seq`와 증가 규칙 변경
|
||||
- TVR-v2 reserve·cost basis·processed execution, TSL peak/session 또는 다른 전략의 opaque `data` schema 정의
|
||||
- Core가 strategy state의 session 만료·occupancy 귀속·초기화 여부를 판단하는 기능
|
||||
- Core-side instance registry, strategy 목록 저장, caller 인증, AMQP producer 권한 또는 queue topology 변경
|
||||
- 별도 operator/diagnostic strategy-state API 신설
|
||||
- Strategy-state history, audit/event store, exactly-once write 또는 multi-writer merge
|
||||
- Strategy consumer repository의 구현·배포 및 production rollout
|
||||
- 기존 파괴적 전체 DB reset 제거 또는 변경
|
||||
- Legacy Discovery·REQ 및 AC-0023~AC-0025의 lifecycle·본문 변경
|
||||
@@ -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을 수행하지 않는다.
|
||||
+12
-3
@@ -1,8 +1,11 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ARG SOURCE_COMMIT
|
||||
ARG SOURCE_VERSION
|
||||
LABEL org.opencontainers.image.source="https://github.com/fourblocks2020/trex-core" \
|
||||
org.opencontainers.image.revision="${SOURCE_COMMIT}"
|
||||
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
|
||||
org.opencontainers.image.version="${SOURCE_VERSION}" \
|
||||
org.opencontainers.image.title="trex-core"
|
||||
|
||||
WORKDIR /opt/trex-core
|
||||
|
||||
@@ -13,12 +16,18 @@ RUN test "${#SOURCE_COMMIT}" -eq 40 \
|
||||
esac \
|
||||
|| { echo "SOURCE_COMMIT must be a 40-character lowercase commit" >&2; exit 1; }
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY trex_core/ ./trex_core/
|
||||
|
||||
RUN python -m pip install --no-cache-dir . \
|
||||
RUN test "$(python -c 'import pathlib,tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" = "${SOURCE_VERSION}" \
|
||||
|| { echo "SOURCE_VERSION must match pyproject.toml" >&2; exit 1; }
|
||||
|
||||
RUN python -m pip install --no-cache-dir "uv==0.11.14" \
|
||||
&& uv sync --locked --no-dev --no-editable \
|
||||
&& useradd --create-home --uid 10001 trex
|
||||
|
||||
ENV PATH="/opt/trex-core/.venv/bin:${PATH}"
|
||||
|
||||
USER 10001:10001
|
||||
|
||||
ENTRYPOINT ["trex-core-daemon"]
|
||||
|
||||
+8
-4
@@ -49,9 +49,12 @@
|
||||
- limit_price :: numeric? :: 현재 pending 주문 지정가(side 무관, dcy-012/REQ-002 — 과거 `entry_price`에서 rename). pending 진입(매수/매도 지정가) 시 set, pending 이탈(→holding/→closed) 시 None 으로 clear → 매수 지정가가 매도로 누수되지 않음(issue #10). None 이면 dispatch 시 시장가.
|
||||
- allocated_capital :: numeric? :: 배정 자본
|
||||
- order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들)
|
||||
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 현재 주문 거래소 구분(AUTO/KRX/NXT/SOR). intent 단계의 AUTO는 KIS 제출 시 실제 KRX/NXT로 교체되며 주문번호와 원자 저장한다. pending 주문·취소까지 보존하고 HOLDING 진입 시 다음 자율주문의 AUTO, INACTIVE 진입 시 KRX로 초기화한다.
|
||||
- cancel_state :: TEXT? :: 취소 요청의 즉시 결과(requested/rejected/unknown). 최종 취소 상태가 아니며 같은 주문의 자동 재취소를 막는다.
|
||||
- cancel_requested_at :: TIMESTAMPTZ? :: 취소 요청 결과를 저장한 시각. 주문 종료·dispatch clear 때 함께 제거한다.
|
||||
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 원주문/라우팅 거래소(KRX/NXT/SOR). venue 누락 intent는 SOR로 저장하고, pending 주문·재시작·취소까지 보존한다. 실제 KRX/NXT 체결 거래소는 `FillStatus.execution_venue`에서 별도로 확정해 `ledger_outbox.venue`에 기록하고 원주문은 `ledger_outbox.order_venue`에 보존한다.
|
||||
- cancel_state :: TEXT? :: 취소 요청의 즉시 결과(requested/rejected/unknown). 최종 취소 상태가 아니다. requested는 재시도하지 않고, rejected/unknown만 authoritative open-row 확인과 bounded backoff 뒤 재시도할 수 있다.
|
||||
- cancel_requested_at :: TIMESTAMPTZ? :: 마지막 취소 시도 예약 시각. 주문 종료·dispatch clear 때 함께 제거한다.
|
||||
- order_submitted_at :: TIMESTAMPTZ? :: timeout deadline의 immutable 주문 제출 기준점. poll/cancel bookkeeping 및 daemon 재시작에서 보존하고 terminal dispatch clear 때 제거한다.
|
||||
- order_date :: TEXT? :: 원주문 KST 날짜(`YYYYMMDD`). 날짜 rollover 뒤 KIS 일별주문체결조회가 현재 날짜가 아닌 원주문 날짜를 사용하도록 보존한다.
|
||||
- cancel_attempts :: INTEGER NOT NULL DEFAULT 0 :: 원자적으로 예약된 취소 시도 횟수. 새 dispatch에서 0, terminal dispatch clear에서 0으로 초기화한다.
|
||||
- strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별
|
||||
- portfolio_id :: str? :: 포트폴리오 식별
|
||||
- forced_close_at :: datetime? :: 강제 청산 마감선
|
||||
@@ -150,7 +153,8 @@
|
||||
- side :: TEXT CHECK ('buy','sell')
|
||||
- qty / price / fee / tax :: NUMERIC(18,4)
|
||||
- executed_at :: TIMESTAMPTZ?
|
||||
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 실제 제출 거래소. 기존 행은 KRX로 보존한다.
|
||||
- venue :: TEXT? CHECK (NULL 또는 'KRX'/'NXT') :: broker-authoritative 실제 체결 거래소. 과거 SOR row처럼 실제 거래소를 복원할 수 없으면 NULL이며 임의 추론하지 않는다.
|
||||
- order_venue :: TEXT NOT NULL DEFAULT 'KRX' CHECK ('KRX'/'NXT'/'SOR') :: 원주문/라우팅 venue. 취소·재시작 감사에 사용한다.
|
||||
- status :: TEXT CHECK ('pending','completed','conflict_blocked','delivery_failed') — `delivery_failed` 추가(dcy-015, 마이그레이션 007)
|
||||
- attempts :: INTEGER :: 재시도 누적
|
||||
- last_outcome :: TEXT? CHECK ('completed','duplicate_completed','retryable_failure','conflict_blocked','permanent_failure','dead_lettered')
|
||||
|
||||
+90
-27
@@ -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,22 +63,30 @@
|
||||
|
||||
| 패턴 | 표면 | 요청 속성 | 응답 |
|
||||
|---|---|---|---|
|
||||
| 슬롯 RPC | `trex.slots.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 `{slots[], degraded, reason, message}` (REQ-073/074, BAT-035) |
|
||||
| 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?}`. **rejected sell** 응답은 additive `retryable: bool` 포함(REQ-007 AC-7/issue #27 — core 소유 reason→retryable, 미인식 기본 true, `bad_request:*` false; admitted/processing/buy 는 미포함). 체결/종료는 응답 아닌 이벤트로 |
|
||||
| 슬롯 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)
|
||||
```
|
||||
{ "filter": "all" | "inactive" | "pending" | "holding" | "error", # default "all"
|
||||
"spy": true | false, # default false; true = watchdog 갱신 안 함(운영자 조회)
|
||||
"strategy": str # optional (REQ-116 BAT-055) — exact-match on strategy_name
|
||||
"strategy": str, # optional (REQ-116 BAT-055) — exact-match on strategy_name
|
||||
"strategy_instance_id": str # required non-empty string when spy=false; ignored for spy=true filtering
|
||||
}
|
||||
```
|
||||
- `filter`: 서버 측 슬롯 status 필터. `"inactive"` 는 buyable 슬롯(universe에 바인딩된 매수 가능 슬롯). 미인식 값은 `"all"` 로 처리.
|
||||
- `spy=true`: 응답에 포함된 holding 슬롯의 워치독 `last_seen_at` 을 갱신하지 않는다(운영자 CLI 조회가 전략 생존 신호로 오인되지 않도록 — REQ-034 CHG-01).
|
||||
- `spy=false`: `strategy_instance_id`는 required non-empty string이다. 누락·비문자열·빈 값은 `{slots:[], available_slots:null, degraded:false, reason:"bad_request:strategy_instance_id", message:"strategy_instance_id must be a non-empty string"}`를 반환하며 watchdog을 갱신하지 않는다.
|
||||
- strategy-state visibility: `spy=false`는 exact requested entry 하나를 `strategy_state: {version,data,updated_at}`로만 반환하고 missing entry는 생략한다. `spy=true`는 supplied ID와 무관하게 complete `{strategy_instance_id: {version,data,updated_at}}` map을 반환한다.
|
||||
- `strategy` (REQ-116 additive): 지정 시 `strategy_name == strategy` 인 슬롯 행만 반환. 대소문자 exact-match. 미지정 시 기존과 완전 동일. `filter` 와 AND 조합. 매칭 없으면 `slots=[]`(에러 없음). `available_slots` 는 strategy 필터와 무관하게 인스턴스 전체 기준으로 계산(REQ-073 AC-6 불변).
|
||||
|
||||
코어가 reply-to 큐로 보내는 응답 payload:
|
||||
@@ -88,7 +96,11 @@
|
||||
{ "slot_id": "str (UUID)",
|
||||
"status": "inactive | pending_buy | pending_sell | holding | error",
|
||||
"symbol": "str | null",
|
||||
"qty": "float | null", // slot mirror (REQ-062)
|
||||
"qty": "float | null", // holding: broker authority / unavailable=null
|
||||
"qty_source": "broker | unavailable | null", // non-holding=null
|
||||
"broker_quantity_available": "bool", // non-holding=false
|
||||
"broker_quantity_captured_at": "ISO-8601 | null", // non-holding=null
|
||||
"slot_quantity": "float | null", // core daily_slot mirror (diagnostic)
|
||||
"asset_quantity": "float | null", // hub authority (REQ-062)
|
||||
"avg_price": "float | null",
|
||||
"entry_price": "float | null",
|
||||
@@ -97,19 +109,26 @@
|
||||
"universe_detached": "bool",
|
||||
"cash_balance": "float | null", // hub authority
|
||||
"cash_estimated": "bool", // true = pending ledger outbox delta 반영
|
||||
"warned": "bool", // qty != asset_quantity 시 true (REQ-062)
|
||||
"warned": "bool", // slot_quantity != asset_quantity 시 true
|
||||
"watchdog": { // null if watchdog store unavailable
|
||||
"expired": "bool",
|
||||
"last_seen": "ISO-8601 | null"
|
||||
},
|
||||
"paused": "bool",
|
||||
"reconcile_warned": "bool", // durable B/C warning 또는 unknown case 존재
|
||||
"reconcile_warning_cases": ["B", "C"], // 활성 case code 목록
|
||||
"trade_eligible": "bool", // known blocking/unknown case가 없을 때만 true
|
||||
"strategy_name": "str | null", // REQ-116 additive (BAT-055) — portfolio-hub PortfolioItem 출처
|
||||
"strategy_params": "dict", // REQ-116 additive (BAT-055) — portfolio-hub opaque dict; {} if absent
|
||||
"markets": ["str"] // REQ-116 additive (BAT-055) — portfolio-hub markets list; [] if absent
|
||||
}
|
||||
],
|
||||
"excluded_slots": {
|
||||
"reconcile_quantity_mismatch": "int",
|
||||
"active_reconcile_warning_cases": {"B": "int", "C": "int"}
|
||||
},
|
||||
"degraded": "bool",
|
||||
"reason": "portfolio_hub_unavailable | snapshot_fetch_failed | cash_position_stale | null",
|
||||
"reason": "portfolio_hub_unavailable | snapshot_fetch_failed | cash_position_stale | warn_store_unavailable | null",
|
||||
"message": "str | null" // sanitized — no credentials/raw exceptions
|
||||
}
|
||||
```
|
||||
@@ -119,22 +138,41 @@
|
||||
| 필드 | 권위 소스 | 비고 |
|
||||
|---|---|---|
|
||||
| `slot_id`, `status`, `avg_price`, `entry_price`, `forced_close_at`, `universe_detached` | slot (core) | 슬롯 row 직접 |
|
||||
| `qty` | slot (core) | 슬롯 mirror — broker position |
|
||||
| `qty` | broker account snapshot | holding 슬롯에서만 authoritative. unavailable/stale 시 null |
|
||||
| `qty_source` | 파생(metadata) | holding 슬롯: `broker` \| `unavailable`; non-holding은 null |
|
||||
| `broker_quantity_available` | 파생(metadata) | holding 슬롯 broker qty freshness 통과 여부; non-holding=false |
|
||||
| `broker_quantity_captured_at` | broker account snapshot | holding 슬롯 broker qty capture 시각; non-holding=null |
|
||||
| `slot_quantity` | slot (core) | 기존 daily_slot.qty mirror(진단용) |
|
||||
| `symbol`, `portfolio_id` | slot (core) | 자산 바인딩 |
|
||||
| `asset_quantity` | hub (SnapshotStore) | portfolio-hub 권위값 (REQ-062) |
|
||||
| `cash_balance` | hub (SnapshotStore) | portfolio-hub 권위값; `cash_estimated=true` 시 ledger outbox delta 추정값 |
|
||||
| `cash_estimated` | ledger outbox | pending delta 있을 때 true (REQ-029) |
|
||||
| `warned` | 파생 | `qty != asset_quantity` (REQ-074 AC-2) |
|
||||
| `warned` | 파생 | `slot_quantity != asset_quantity` (REQ-074 AC-2) |
|
||||
| `watchdog` | WatchdogStore | 만료 여부 + last_seen; null=store 미연결 |
|
||||
| `paused` | PauseStore | durable pause overlay (REQ-032) |
|
||||
| `reconcile_warned`, `reconcile_warning_cases` | ReconcileWarningStore | 동일 slot의 durable B/C 원인을 동시에 보존·노출 |
|
||||
| `trade_eligible` | 파생(safety gate) | 활성 B/C 또는 지원하지 않는 warning case가 있으면 false |
|
||||
| `strategy_name` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. portfolio-hub 정본. hub 미제공 시 null |
|
||||
| `strategy_params` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. opaque dict; hub 미제공 시 {} |
|
||||
| `markets` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. list[str]; hub 미제공 시 [] |
|
||||
|
||||
- **slot-origin strategy 필드 없음** (REQ-074 AC-5, REQ-116 additive 재도입 후에도 유지): `strategy_id`, `sell_strategy_id`, `active_strategy_id` 는 응답에 포함되지 않는다. `strategy_name`/`strategy_params`/`markets` 는 PortfolioItem(hub) 출처이므로 AC-5 제외 대상과 구분된다 — REQ-116 Notes 참조.
|
||||
- **Reconcile warning fail-closed**: B/C warning이 활성인 holding slot은 응답에서 제외된다. warning store 조회 실패, empty metadata, unknown case는 `slots=[]`, `degraded=true`, `reason="warn_store_unavailable"`로 처리한다. intent admission도 동일 durable source를 재확인하며 unknown case를 거래 허용 근거로 사용하지 않는다.
|
||||
- **Breaking change (holding `qty`) rollout**: 기존 전략 클라이언트가 `qty=slot mirror`로 가정했다면 `slot_quantity`를 읽도록 전환해야 한다. 권장 경로는 (1) 클라이언트에 `qty_source`/`broker_quantity_available` 체크 추가 → (2) fail-closed (`qty is null`) 처리 배포 → (3) `qty`를 broker 정본으로 사용하도록 cutover 순서다.
|
||||
- `spy=false`(기본): holding 슬롯 응답 후 `WatchdogStore.update(slot_id)` 호출 — 전략 폴링 = 생존 신호.
|
||||
- `spy=true`: watchdog 갱신 건너뜀 — 운영자 CLI(`universe`/`slots`) 가 항상 사용.
|
||||
|
||||
#### `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`.
|
||||
@@ -237,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
|
||||
@@ -396,7 +451,7 @@ resume_kill_switch 요청·응답 (REQ-046):
|
||||
|
||||
### Persistence
|
||||
|
||||
- PostgreSQL: `ledger_outbox(correlation_id PK, asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at, venue, status, attempts, last_outcome, last_error, created_at, updated_at)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트. `venue`는 Core 내부 실제 제출 거래소이며 Portfolio Hub wire에는 아직 보내지 않는다.
|
||||
- PostgreSQL: `ledger_outbox(..., venue, order_venue, ...)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트. `order_venue`는 원주문 KRX/NXT/SOR, `venue`는 broker fill에서 확인한 실제 KRX/NXT 체결 거래소다. Portfolio Hub wire에는 아직 보내지 않는다.
|
||||
|
||||
## `OrderRequest` DTO (BAT-004 / REQ-011, BAT-056 / REQ-117)
|
||||
|
||||
@@ -414,10 +469,10 @@ resume_kill_switch 요청·응답 (REQ-046):
|
||||
|
||||
### Additive Notes (REQ-117)
|
||||
|
||||
- `venue` 필드는 BAT-056에서 additive 추가. 기존 orb/tsl 미지정 호출 → 기본값 `"KRX"` 자동 적용 → 하위 호환.
|
||||
- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않는다. buy/sell intent의 선택적 `venue` 기본은 `AUTO`이고, 명시한 KRX/NXT/SOR는 그대로 슬롯에 보존한다. 허용값 외에는 `invalid_venue`로 admission 전 거부한다.
|
||||
- KIS 어댑터는 `AUTO`를 KST 세션 기준 실제 KRX/NXT로 확정한 뒤 `EXCG_ID_DVSN_CD`로 전송한다. 결정 불가능 구간은 KRX로 대체하지 않고 주문을 거부한다.
|
||||
- `OrderAck.venue`의 실제 제출 거래소는 `broker_order_id`와 함께 슬롯에 원자 저장되며 재시작·취소·Core 원장 outbox에서 재사용한다.
|
||||
- `venue` 필드는 BAT-056에서 additive 추가됐으며, 현재 계약에서 기존 orb/tsl을 포함한 미지정 호출은 `SOR`로 정규화한다.
|
||||
- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않는다. buy/sell intent의 선택적 `venue` 누락은 `SOR`이고, 명시한 KRX/NXT/SOR는 그대로 슬롯에 보존한다. 폐기된 `AUTO`와 그 밖의 값은 `invalid_venue`로 admission 전 거부한다.
|
||||
- KIS 어댑터는 KRX/NXT/SOR를 시간과 무관하게 보존해 `EXCG_ID_DVSN_CD`로 전송한다.
|
||||
- `OrderAck.venue`는 원주문 KRX/NXT/SOR를 `broker_order_id`와 함께 슬롯에 원자 저장하며 재시작·취소에 재사용한다. 실제 체결 거래소는 `FillStatus.execution_venue`의 KRX/NXT로 별도 전달해 outbox `venue`에 기록한다. SOR 또는 미지원 코드는 실제 거래소로 추론하지 않는다.
|
||||
|
||||
## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118)
|
||||
|
||||
@@ -425,8 +480,11 @@ resume_kill_switch 요청·응답 (REQ-046):
|
||||
|
||||
- `get_balance()`는 검증된 `list[BalanceItem]`만 반환한다. KIS의 token/auth/authorization/transport/response/rejection 실패는 빈 목록으로 축소되지 않고 `BrokerBalanceError`를 raise한다. 안정 code allow-list는 `token_issuance_failed`, `authentication_failed`, `authorization_failed`, `transport_failed`, `malformed_response`, `broker_rejected`, `ownership_mismatch`다.
|
||||
- `sync`와 `verify` CLI는 첫 balance read를 완료한 뒤 cache한 결과를 해당 invocation에 전달한다. 이 read 또는 broker credential resolution이 실패하면 exit code `1`이며, `--json` stderr는 `{ "error": {"code": "…", "message": "broker balance unavailable"} }` 형태의 안전한 값만 낸다. upstream payload, credential, account 값은 계약상 출력하지 않는다.
|
||||
- `reconcile_core_holdings()`의 broker-only 결과는 `unmanaged_position`(대상 instance의 managed slot 없음) 또는 `managed_non_holding_divergence`(동일 instance의 non-holding managed slot 존재)다. `find_managed_slot_by_symbol(instance_key, symbol)` 조회는 instance-scoped이며, 이 두 분류 자체는 slot/warning write나 자동 주문·청산·slot 생성을 하지 않는다.
|
||||
- 이 변경은 AMQP 또는 portfolio-hub external wire contract와 DB schema를 바꾸지 않는다.
|
||||
- `reconcile_core_holdings()`의 broker-only 결과는 `unmanaged_position`(대상 instance의 managed slot 없음) 또는 `managed_non_holding_divergence`(동일 instance의 non-holding managed slot 존재)다. `find_managed_slot_by_symbol(instance_key, symbol)` 조회는 instance-scoped이며, 두 분류 모두 slot 생성, 자동 주문·청산을 하지 않는다.
|
||||
- `managed_non_holding_divergence`는 기존 reconcile warning lifecycle을 유지한다. 반면 `unmanaged_position`은 `(instance_key, symbol)` keyed durable state로 추적되며 `quantity`, `first_seen_at`, `last_seen_at`, `quantity_changed_at`, `resolved_at`, lifecycle state와 latest operator evidence/disposition을 저장한다.
|
||||
- `unmanaged_position`의 absence resolution은 explicit authoritative full-balance snapshot reader에서만 허용된다. Ownership/projected broker 또는 marker가 없는 balance reader는 detect/update 입력일 수 있어도 resolved authority는 아니다.
|
||||
- Operator surface는 `trex-core unmanaged list`, `trex-core unmanaged audit <symbol>`, `trex-core unmanaged record <symbol> <action> --evidence-ref ...`이며 audit actor는 CLI의 trusted local identity에서 결정된다. 임의 caller-supplied actor string은 current contract가 아니다.
|
||||
- 이 변경은 AMQP 또는 portfolio-hub external wire contract는 바꾸지 않지만, internal PostgreSQL persistence에는 unmanaged-position state/audit schema를 추가한다.
|
||||
|
||||
### Account snapshot recovery (dcy-041 / GitHub #45)
|
||||
|
||||
@@ -447,8 +505,8 @@ cancel_order(broker_order_id: str, *, side: str, symbol: str, qty: int, venue: s
|
||||
|
||||
- `venue`: 원주문 venue. 취소 시 원주문과 동일한 `EXCG_ID_DVSN_CD`를 사용해야 한다. 기본값 `"KRX"`로 기존 호출자 하위 호환.
|
||||
- `CancelOutcome.state`: `requested`(요청 접수), `rejected`(명시적 거부), `unknown`(응답유실·전송 실패). `requested`도 최종 취소가 아니며 `poll_fill()`의 `cancelled|filled`만 주문의 최종 상태를 확정한다.
|
||||
- Core는 외부 취소 호출 전에 `unknown`을 먼저 영속화하고 응답 뒤 실제 즉시 결과로 교체한다. 따라서 요청과 저장 사이에 프로세스가 종료돼도 자동 timeout 취소는 같은 주문을 재전송하지 않고, 재시작 뒤 체결조회로만 수렴한다.
|
||||
- timeout과 운영자 취소 모두 `cancel_state IS NULL` 비교·설정에 성공한 한 호출만 외부 요청을 보낸다. 이후 반복 요청은 `cancel_already_recorded`로 응답하고 poll만 계속한다.
|
||||
- Core는 외부 취소 호출 전에 `unknown`과 시도 횟수를 원자적으로 영속화하고 응답 뒤 실제 즉시 결과로 교체한다. 따라서 요청과 저장 사이에 프로세스가 종료돼도 재시작 시 먼저 체결조회를 수행한다.
|
||||
- `requested`는 재시도하지 않는다. `rejected|unknown`은 후속 `poll_fill()`이 원주문을 open으로 확인하고 bounded backoff·최대 시도 조건을 만족한 경우에만 재시도한다. row 미가시성·missing·accepted만으로 blind retry하지 않는다. 운영자 반복 요청은 기존 `cancel_already_recorded` 경계를 유지한다.
|
||||
|
||||
### get_current_price
|
||||
|
||||
@@ -461,11 +519,15 @@ get_current_price(symbol: str, market: str = "J") -> Optional[Decimal]
|
||||
### KIS 어댑터 EXCG 명시화 (REQ-118)
|
||||
|
||||
- `submit_order`: body에 `EXCG_ID_DVSN_CD: req.venue` 항상 명시 전송 (이전: 미전송·서버 기본값 의존 → 명시화로 견고성 향상). 실측(2026-07-06): NXT(EXCG=NXT) 주문 rt_cd=0 확인.
|
||||
- `AUTO` 세션: 08:00~08:49:59 NXT, 09:00:30~15:29:59 KRX, 15:30~20:00 NXT. 그 밖의 시간은 `auto_venue_session_closed`로 fail-closed 한다.
|
||||
- venue 누락은 `SOR`로 제출하며, 명시 KRX/NXT/SOR는 그대로 제출한다. core는 wall-clock으로 venue를 선택하지 않는다.
|
||||
- `cancel_order`: body에 `EXCG_ID_DVSN_CD: venue` 명시 전송. 실측(2026-07-06): NXT 취소 rt_cd=0 확인.
|
||||
- `poll_fill`: KIS 신형 일별주문체결조회 실전 `TTTC0081R`, 모의
|
||||
`VTTC0081R`을 사용하고 `EXCG_ID_DVSN_CD=ALL`을 전달한다. 일반 주문번호
|
||||
조회와 결과불명 주문 탐색이 같은 계약으로 KRX·NXT 체결을 확인한다.
|
||||
일반 주문은 영속화된 `order_date`(`YYYYMMDD`)를 조회일로 사용하며,
|
||||
row가 없으면 bounded 제출 직후 visibility grace 이후 `unknown`으로
|
||||
fail-closed 한다. `order_submitted_at`/`order_date`를 알 수 없는 legacy
|
||||
pending row는 mutable `updated_at`로 추정하지 않고 CRITICAL alarm과 pending을 유지한다.
|
||||
|
||||
## `FillStatus` 확장 (BAT-006 / REQ-018)
|
||||
|
||||
@@ -511,21 +573,21 @@ get_current_price(symbol: str, market: str = "J") -> Optional[Decimal]
|
||||
- pika BlockingConnection 은 thread-safe 가 아니므로 consumer/publisher 마다 자기 connection/channel 을 가져야 한다.
|
||||
- 토폴로지 선언은 멱등하지만 attribute(durable, args) 가 기존 큐와 다르면 RabbitMQ 가 PRECONDITION_FAILED 로 거부 → batch 마이그레이션 시 큐 재선언 절차 필요.
|
||||
|
||||
## Interface: `trex.slots.strategy_state.set` RPC (BAT-042 / dcy-028, REQ-080)
|
||||
## Interface: `trex.slots.strategy_state.set` RPC (REQ-0009)
|
||||
|
||||
- Type: external-service (strategy daemon ↔ core write RPC)
|
||||
- Consumers / Producers: 전략 데몬(produce) ↔ core 핸들러(consume). per-instance 큐(`{base}.{instance_key}`), direct reply-to, JSON.
|
||||
- Purpose: 슬롯의 opaque strategy-state 를 occupancy_seq+version CAS 로 write. read 는 `trex.slots.req` 에 piggyback(별도 RPC 아님).
|
||||
- Purpose: 슬롯별 opaque strategy-instance state entry를 version CAS로 write. read는 `trex.slots.req`에 piggyback(별도 RPC 아님).
|
||||
|
||||
### Inputs
|
||||
- `{slot_id, strategy_id, expected_version:int, expected_occupancy_seq:int, data:str}`. `correlation_id = "{slot_id}:{strategy_id}:{expected_version}"`(멱등), delivery persistent. `data` 는 opaque UTF-8(core 무해석), byte size cap(기본 4KB ceiling, 운영 권장 1~2KB).
|
||||
- `{slot_id, strategy_instance_id, expected_version:int, data:str}`. `strategy_instance_id`는 required non-empty string이며 `correlation_id`는 저장 멱등성 key가 아니다. `data`는 opaque UTF-8(core 무해석), byte size cap(기본 4KB ceiling, 운영 권장 1~2KB).
|
||||
|
||||
### Outputs / Outcomes
|
||||
- 성공: `{ok:true, slot_id, strategy_id, version:<new>}`.
|
||||
- 실패 reason 전체 집합: `occupancy_changed`(+`occupancy_seq`) / `version_conflict`(+`version`) / `slot_inactive` / `state_too_large` / `bad_request:<detail>`. 소비자는 미인식 reason 에 fail-closed.
|
||||
- 성공: `{ok:true, slot_id, strategy_instance_id, version:<new>}`.
|
||||
- 실패 reason 전체 집합: `version_conflict`(+`version`) / `slot_inactive` / `state_too_large` / `bad_request:<detail>`. 소비자는 미인식 reason에 fail-closed.
|
||||
|
||||
### Compatibility Rules
|
||||
- read 표면(`trex.slots.req`)의 각 슬롯 행에 additive 로 `occupancy_seq`(int)와, 보유 시 `strategy_state` 맵(`{<strategy_id>:{version,data,updated_at}}`)이 실린다(REQ-073/074 CHG). 기존 소비자는 unknown 필드 무시로 하위호환. `strategy_state` 는 strategy_* reasoning 필드(strategy_id 등, REQ-074 AC-5 제외 대상)와 구분되는 opaque 상태 맵이다.
|
||||
- slot strategy-state는 occupancy와 독립된 slot-scoped `data[strategy_instance_id] = {version,data,updated_at}` map이다. close/reopen은 entry를 삭제하거나 version을 초기화하지 않는다. Entry에는 `strategy_id`나 `occupancy_seq` metadata가 없으며, Slot 자체의 `occupancy_seq` semantics는 별도로 유지된다.
|
||||
|
||||
## Shared Constraints
|
||||
|
||||
@@ -630,7 +692,7 @@ trex-core가 `source="trex-core"`, `severity="critical"`로 발행하는 초기
|
||||
- 2026-06-05 :: BAT-007 — HTTP `/v1` API 인터페이스 제거(adapters/api + fastapi/uvicorn 의존 정리), RabbitMQ messaging 계약 추가(스냅샷 RPC `trex.snapshot.req`, intent RPC `trex.intent.req`, 이벤트 topic `trex.events`, 전략별 durable queue + TTL/max-length, DLQ, publisher confirms, ack-after-commit), `MessagingTransport` Protocol + `TransportHealth` 노출 (REQ-021~026).
|
||||
- 2026-06-06 :: BAT-008 — `trex.snapshot.req` envelope 계약 정의(`assets[]/view/available_slots/degraded/reason/message`, no price field), `trex.pause.req` 제어 RPC 추가, `PortfolioHubReadPort`/`SnapshotStore`/`PauseStore`/`SnapshotScheduler` 인터페이스, `BrokerPort.get_balance()` 확장, `trex-core` CLI 추가 (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후).
|
||||
- 2026-06-10 :: BAT-030 (dcy-020 마감) — buy intent 계약 키 `slot_id` 정본(+`portfolio_id` dual-accept, `trading_date` 제거), 거부 어휘(`unknown_slot`/`already_active`/`capacity_full`/`slot_id_or_portfolio_required`), `slots[]` 5-state + `universe_detached`(trading_date 키 제거), 이벤트 payload trading_date 제거, `available_slots`=admission headroom, SlotService/SlotRepository 자산 중심 계약, 매도 체결 `pending_sell→inactive` (REQ-056~065).
|
||||
- 2026-06-15 :: BAT-043 (issue #27, REQ-007 CHG-20260615-01) — rejected SellIntent 응답에 additive `retryable: bool`(core 소유 reason→retryable 매핑, 미인식 기본 true, `bad_request:*`/terminal false). 손절매 재시도 안전 경로 decoupling. 완전 additive(admitted/processing/buy·기존 필드 불변).
|
||||
- 2026-06-15 :: BAT-043 (issue #27, REQ-007 CHG-20260615-01) — rejected SellIntent 응답에 additive `retryable: bool`을 도입했으나, GitHub #60 승인 계약으로 폐기됐다. 현재 strategy RPC는 retryability·broker attempt·KIS failure를 전달하지 않는다.
|
||||
- 2026-06-15 :: BAT-042 (dcy-028) — slot strategy-state 계약: 신규 write RPC `trex.slots.strategy_state.set`(occupancy_seq+version CAS, reject reason 집합 `occupancy_changed`/`version_conflict`/`slot_inactive`/`state_too_large`/`bad_request`), `trex.slots.req` 행에 additive `occupancy_seq`+`strategy_state` 맵(REQ-073 AC-7/REQ-074 AC-6, opaque UTF-8+size cap, strategy_* reasoning 필드와 구분). close(`→INACTIVE`) 시 state 자동 삭제 (REQ-079~082).
|
||||
- 2026-06-11 :: BAT-035 (dcy-025) — `trex.snapshot.req` → `trex.slots.req` lockstep 교체: 요청 `{filter, spy}`, 응답 `{slots[], degraded, reason, message}`. 슬롯 row 에 hub `asset_quantity`/`cash_balance` + `warned`/`cash_estimated`/`watchdog`/`paused` 병합(REQ-073/074). `trex.snapshot.req` envelope(`assets[]`/`available_slots`/view enum) 제거(REQ-028 Deprecated). strategy 필드 제거(REQ-074 AC-5). CLI `universe`/`slots` → `trex.slots.req`(spy=true). `trex.direct` channel_type `snapshot` → `slots`.
|
||||
- 2026-06-11 :: BAT-036 (dcy-024) — `safety_sell_enabled: bool=True` per-instance TOML 플래그 추가(REQ-048 CHG-01). `false` 시 `trex-safety-sell` worker 미기동 + WARNING 로그(REQ-005 CHG-01).
|
||||
@@ -641,6 +703,7 @@ trex-core가 `source="trex-core"`, `severity="critical"`로 발행하는 초기
|
||||
결과불명 주문 탐색을 KIS 신형 일별주문체결조회
|
||||
(`TTTC0081R`/`VTTC0081R`, `EXCG_ID_DVSN_CD=ALL`)로 단일화했다.
|
||||
- 2026-07-06 :: BAT-056 (dcy-002 FR-6, REQ-117/118) — `OrderRequest.venue: str = "KRX"` additive 추가(기존 위치 인수 순서 불변 — AMQP wire 계약 영향 없음, 브로커 어댑터 계층 DTO까지만). KIS 어댑터: submit_order/cancel_order body에 `EXCG_ID_DVSN_CD` 항상 명시 전송(기존 미전송 → KRX 명시화 견고성 향상), cancel_order/get_current_price 시그니처 venue/market 파라미터 추가(기본값 KRX/"J" — 기존 호출자 무영향). paper broker 시그니처 정합(venue/market 무시). 실측 근거: 2026-07-06 NXT 주문/취소 rt_cd=0 확인.
|
||||
- 2026-08-24 :: issue #62 — `trex.slots.req` holding row `qty`를 broker holdings snapshot 정본으로 전환. `qty_source`/`broker_quantity_available`/`broker_quantity_captured_at`/`slot_quantity`를 추가하고, broker qty unavailable 또는 stale이면 fail-closed(`qty=null`, no core/hub fallback)로 응답.
|
||||
|
||||
|
||||
## REQ-123 Contract Addendum: cost-basis canonical parsing & sync diagnostics
|
||||
|
||||
+62
-6
@@ -40,13 +40,68 @@ pkill -TERM -f trex-core-daemon
|
||||
- `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지.
|
||||
- 테스트는 `TREX_BROKER=paper` + 파생 `trex_core_test` DSN.
|
||||
|
||||
### 컨테이너 설정, 배포 및 rollback
|
||||
### Release image build
|
||||
|
||||
- 이미지는 per-instance TOML, `.env`, 자격증명 또는 연결 정보를 포함하지 않는다. 인스턴스 TOML은 read-only volume으로 mount하고 `trex-core-daemon --config <mounted-path>`로 지정한다. secret은 배포 플랫폼의 secret 기능 또는 runtime environment로만 주입하고 image layer나 command line에 기록하지 않는다.
|
||||
- 게시된 `ghcr.io/fourblocks2020/trex-core:<source-commit-first-12>` tag는 source commit을 찾는 stable selector일 뿐이다. 배포 전 registry가 반환한 `ghcr.io/fourblocks2020/trex-core@sha256:<64-hex>` reference를 기록하고 workload는 이 digest로 pin한다.
|
||||
- rollback은 이전에 검증하고 기록한 digest reference로 workload image를 되돌린 뒤 정상적인 배포 절차로 재시작한다. tag를 다시 해석하거나 source에서 다시 build하지 않는다.
|
||||
- `trex-core-reset`은 파괴적인 DB 작업이며 image rollback이나 자동 rollback 절차에 절대로 포함하지 않는다. schema/data 변경의 복구는 image rollback과 분리된 승인 절차로 수행한다.
|
||||
- v0.1.1부터 image smoke는 DB에 연결하거나 migration을 적용하지 않고, installed package의 SQL migration filename inventory가 해당 source commit의 non-empty canonical inventory와 정확히 일치하는지 검증한다.
|
||||
Release tag의 clean checkout에서 `./scripts/build_release_image.sh trex-core:<version>`을
|
||||
실행한다. 이 경로는 project version과 image tag를 대조하고 OCI version/revision labels,
|
||||
non-root user 및 container `--version`을 검증한다. 전체 명령과 registry 사용법은
|
||||
[`docs/ops/release-image-build.md`](ops/release-image-build.md)를 따른다.
|
||||
|
||||
### 배포/복구/수동 재기동 provenance 검증 (issue #87)
|
||||
|
||||
이 저장소는 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로 별도 실행한다.
|
||||
python scripts/release_provenance_gate.py \
|
||||
--actor "<operator>" \
|
||||
--reason "<ticket-or-incident>" \
|
||||
--instance "<instance_key>" \
|
||||
--operation-id "<deployment-or-incident-id>" \
|
||||
--phase "pre" \
|
||||
--command-class "manual-stop" \
|
||||
--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" \
|
||||
--capture-command "reconciliation_slots=.venv/bin/trex-core slots --status all --instance <instance_key> --json" \
|
||||
--capture-command "queue_consumers=rabbitmqctl list_consumers"
|
||||
```
|
||||
|
||||
- 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로 전환한다. 기존 파일 충돌은 덮어쓰지 않고 실패한다.
|
||||
|
||||
재기동 후 운영 계층은 새 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 실행 여부나 운영 정책 승인을 증명하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
@@ -74,6 +129,7 @@ pkill -TERM -f trex-core-daemon
|
||||
- `sync`와 `verify`는 broker balance를 먼저 한 번 읽어 해당 실행 안에서 재사용한다. balance/credential read 실패는 안전한 error code와 함께 exit `1`로 종료하며, 빈 broker balance를 추정하지 않는다. 성공한 빈 목록만 flat balance로 취급한다.
|
||||
- source evidence에는 mode(`fetched`/`env`/`break_glass`)와 비밀 없는 label만 기록한다. fetched mode는 KIS + `credential_fetch_enabled=true`에서만 사용하고, break-glass는 명시적 운영자 환경 설정으로 fetch를 건너뛴다. Paper와 env KIS 경로는 fetch하지 않는다.
|
||||
- reconciliation에서 `unmanaged_position` 또는 `managed_non_holding_divergence`가 보이면 자동 보정·자동 주문·자동 청산이 없다. broker, ledger, slot evidence를 교차 확인하고 warning/slot 상태를 보존한 채 triage한다.
|
||||
- `unmanaged_position` triage는 `trex-core unmanaged list`로 현재 lifecycle state를, `trex-core unmanaged audit <symbol>`로 lifecycle/operator audit trail을 확인한 뒤 `trex-core unmanaged record <symbol> <action> --evidence-ref ...`로만 disposition을 남긴다.
|
||||
- remediation은 **dry-run → broker/ledger/slot 교차 확인 → 사장님 명시 승인 → 단일 대상 `sync --live --yes` → 재-reconcile** 순서의 별도 release gate다. 이 문서는 실제 target 선택, live 실행 또는 승인을 부여하지 않는다.
|
||||
|
||||
### `report` — 일일 운영 리포트 (REQ-088 / dcy-029, REQ-110/111 / dcy-035)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Release image build
|
||||
|
||||
운영팀은 release tag의 clean checkout에서 아래 명령으로 이미지를 빌드한다.
|
||||
|
||||
```bash
|
||||
git checkout v0.9.0
|
||||
./scripts/build_release_image.sh trex-core:0.9.0
|
||||
```
|
||||
|
||||
스크립트는 `pyproject.toml`의 canonical version과 image tag가 일치하는지 확인하고 현재
|
||||
commit을 OCI `org.opencontainers.image.revision` label로, package version을
|
||||
`org.opencontainers.image.version` label로 기록한다. Dirty checkout, version이 다른 image
|
||||
tag, label 불일치, non-root user 불일치 또는 container `--version` smoke test 실패 시 종료
|
||||
코드 0을 반환하지 않는다.
|
||||
|
||||
Registry 경로를 사용할 때도 version tag를 유지한다.
|
||||
|
||||
```bash
|
||||
./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를
|
||||
[`release-provenance-standard.md`](release-provenance-standard.md)의 PRE/POST gate에 기록한다.
|
||||
이 저장소는 registry publication을 자동화하지 않으므로 push 대상과 권한은 운영팀이 관리한다.
|
||||
+27
-22
@@ -34,11 +34,12 @@
|
||||
|
||||
1. `trex-core-daemon --config <path>.toml` 기동 → `DaemonConfig.from_config(toml)` = `from_env()`(TREX_POSTGRESQL_DSN/`TREX_RABBITMQ_URL` 등) + TOML(`instance_key`, `portfolio_hub_token`, `trading_timezone`, `max_active_slots`) 병합. `--config` 필수(없으면 오류 종료, BAT-013/REQ-048). `instance_key` 로 큐명·라우팅·DB 행 스코프 분리(REQ-056).
|
||||
2. setup: `apply_migrations(dsn)`(멱등; 010 inactive CHECK·011 컬럼 drop 포함) → `execution.reconcile()`(REQ-014) → 이벤트 publisher 의 `declare_topology()`. (일일 preallocation 은 BAT-030 에서 폐기 — 슬롯 생성은 universe reconcile 이 담당.)
|
||||
3. start: 7개 백그라운드 스레드 동시 기동
|
||||
3. start: 8개 백그라운드 스레드 동시 기동
|
||||
- `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014; 매도 수량 브로커 정본 REQ-062). **각 틱 시작 시 `event_publisher.keepalive_tick()` 호출** (REQ-092 — pika 스레드 안전 규칙: 발행 연결 소유 스레드에서만 I/O). 틱 간격(기본 1s) < heartbeat/2(15s) → 서버 측 idle 종료 예방.
|
||||
- `trex-ledger` :: ledger outbox 드레인 (REQ-019)
|
||||
- `trex-intent-rpc` :: `trex.intent.req` 소비자 (prefetch=1, FIFO, dedup via intent_log; `update_thresholds` 액션 포함 REQ-040)
|
||||
- `trex-slots-rpc` :: `trex.slots.req` 소비자 (spy=false 시 holding 슬롯 워치독 `last_seen_at` 갱신 REQ-034; `safety_sell_enabled=false` 시 `trex-safety-sell` 미기동 BAT-036)
|
||||
- `trex-slots-rpc` :: `trex.slots.req` 소비자 (spy=false 시 holding 슬롯 워치독 `last_seen_at` 갱신 REQ-034; `safety_sell_enabled=false` 시 `trex-safety-sell` 미기동 BAT-036)
|
||||
- `trex-strategy-state-set` :: `trex.slots.strategy_state.set` 소비자 (slot-scoped strategy-instance state version CAS)
|
||||
- `trex-control-rpc` :: `trex.control.req` 소비자 — pause/resume/list + kill_switch/resume_kill_switch (REQ-032/043)
|
||||
- `trex-snapshot-scheduler` :: 일일 portfolio-hub 스냅샷 refresh + **universe 슬롯 reconcile**(REQ-058) + holding 불일치 reconcile(REQ-062)
|
||||
- `trex-safety-sell` :: 자율 안전 매도 트리거 루프 (forced_close / stop-loss·take-profit / 장 마감, REQ-037/038/039)
|
||||
@@ -49,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)
|
||||
@@ -64,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).
|
||||
@@ -98,13 +101,14 @@
|
||||
1. 신규 자산(portfolio_id 에 슬롯 없음) → `inactive` 영구 슬롯 생성(멱등 — `ON CONFLICT (instance_key, portfolio_id)` partial unique).
|
||||
2. universe 이탈 + inactive → soft-retire(`retired_at` set; 행 보존, 운영 뷰 `universe_detached`). 이탈 + holding/pending → tombstone(그대로 유지 — safety-sell 경로 보존).
|
||||
3. 가드: universe 빈 응답이면 reconcile skip(degrade 가드); 직전 대비 ≥50% 축소면 retire 보류(`allow_retire=False`, 대량 소실 가드 — dcy-020 OQ-2 D3).
|
||||
4. 이어서 `reconcile_core_holdings`(REQ-062/121): 브로커 잔고 ground truth 로 core holding 검증 — core holding/브로커 0 → slot `error` 격리+WARN, 수량 불일치 → WARN(상태 불변), 브로커 보유인데 대상 instance에 관리 slot이 없으면 `unmanaged_position`, 동일 instance에 `inactive`/`error` 등 non-holding 관리 slot이 있으면 `managed_non_holding_divergence`를 WARN으로 남긴다. 두 broker-only 분류는 상태·warning persistence·주문·청산·slot 생성의 자동 수정을 하지 않는다.
|
||||
4. 이어서 `reconcile_core_holdings`(REQ-062/121): 브로커 잔고 ground truth 로 core holding 검증 — core holding/브로커 0 → slot `error` 격리+WARN, 수량 불일치 → WARN(상태 불변), 브로커 보유인데 대상 instance에 관리 slot이 없으면 `unmanaged_position`, 동일 instance에 `inactive`/`error` 등 non-holding 관리 slot이 있으면 `managed_non_holding_divergence`를 WARN으로 남긴다. `managed_non_holding_divergence`는 기존 warning 경로를 유지하고, `unmanaged_position`은 `(instance_key, symbol)` durable lifecycle state/audit에 기록된다. 같은 수량 반복 관측은 `last_seen_at`만 갱신하고 새 detect/change alarm을 만들지 않는다. 어떤 broker-only 분류도 주문·청산·slot 생성·warning 자동 삭제를 하지 않는다.
|
||||
|
||||
### Broker provider and operator balance-read barrier (BAT-057 / REQ-119/120)
|
||||
|
||||
1. daemon과 `sync`/`verify` CLI는 같은 broker provider를 통해 브로커를 얻는다. `broker=kis` 및 `credential_fetch_enabled=true`일 때만 portfolio-hub의 유효한 인증정보를 fetched source로 사용한다. daemon의 day-start refresh는 이미 같은 KST 날짜에 얻은 source가 있으면 이를 재사용한다.
|
||||
2. 명시적 `TREX_KIS_BROKER_BREAK_GLASS`는 fetched source를 건너뛰고 env KIS 설정을 사용하는 별도 source다. Paper와 `credential_fetch_enabled=false` KIS 경로는 env/config source이며 credential fetch를 하지 않는다.
|
||||
3. `sync`/`verify`는 실행당 잔고를 한 번 먼저 검증해 cache한 뒤 그 값만 사용한다. KIS 잔고 읽기 실패는 빈 잔고로 해석하거나 후속 DB/slot write로 진행하지 않고 command boundary에서 안전하게 실패한다. 성공한 빈 잔고(`[]`)만 검증된 flat balance다.
|
||||
4. Ownership projection이 켜진 mixed-account KIS 경로에서는 주문/전략 조회용 broker가 strategy-owned quantity만 보이도록 balance를 투영할 수 있다. 이때 unmanaged reconciliation은 provider가 별도로 넘기는 authoritative full-balance reader를 사용한다. Missing authority marker, projected/partial reader, self-report failure 또는 balance read failure는 absence resolution authority가 아니며 active unmanaged row를 `resolved`로 바꾸지 않는다.
|
||||
|
||||
## Flow: `포지션 정합 검증` (BAT-008 / REQ-031)
|
||||
|
||||
@@ -138,17 +142,18 @@
|
||||
|
||||
`trex.slots.req` 메시지 처리 (`make_slots_rpc_handler`).
|
||||
|
||||
1. 요청 payload 디코드: `{filter ∈ {all, inactive, pending, holding, error}, spy: bool}`.
|
||||
1. 요청 payload 디코드: `{filter ∈ {all, inactive, pending, holding, error}, spy: bool, strategy_instance_id?: str}`. `spy=false` 전략 요청은 non-empty string `strategy_instance_id`가 필수이며, malformed 값은 slots/state 노출과 watchdog 갱신 없이 `bad_request:strategy_instance_id` 기존 envelope으로 종료한다. `spy=true` 운영자 요청은 ID를 요구하거나 supplied ID로 결과를 필터하지 않는다.
|
||||
2. `slot_repo.list_slots(instance_key)` → status 기준 서버 필터 적용(`inactive`/`pending`/`holding`/`error`; 미인식 = `all`).
|
||||
3. `spy=false` 면 `slot_repo.list_holding_slots()` 의 모든 holding 슬롯에 `WatchdogStore.update(slot_id)`.
|
||||
4. 각 슬롯에 `slot_to_dict(s, snapshot_store=..., watchdog_store=..., pause_store=..., watchdog_timeout_sec=...)` 적용 → 병합 row 산출:
|
||||
- slot 코어 필드: `slot_id/status/symbol/qty/avg_price/entry_price/portfolio_id/forced_close_at/universe_detached`
|
||||
4. 각 슬롯에 `slot_to_dict(s, snapshot_store=..., watchdog_store=..., pause_store=..., watchdog_timeout_sec=...)` 적용 후 broker holdings snapshot 오버레이를 적용해 병합 row 산출:
|
||||
- slot 코어 필드: `slot_id/status/symbol/slot_quantity/avg_price/entry_price/portfolio_id/forced_close_at/universe_detached`
|
||||
- holding authoritative 수량: `qty`(broker), `qty_source`, `broker_quantity_available`, `broker_quantity_captured_at` (`qty_source=unavailable`이면 `qty=null`, mirror fallback 금지)
|
||||
- hub 권위 필드: `asset_quantity`/`cash_balance` (SnapshotStore)
|
||||
- 파생: `warned = (qty != asset_quantity)` (REQ-074 AC-2)
|
||||
- 파생: `warned = (slot_quantity != asset_quantity)` (REQ-074 AC-2)
|
||||
- 추정 현금(REQ-029 OQ-4): `cash_estimated=True` + `cash_balance += pending ledger delta`(best-effort)
|
||||
- 워치독: `watchdog: {expired, last_seen}` inline (WatchdogStore)
|
||||
- 오버레이: `paused` (PauseStore, REQ-032)
|
||||
- strategy 필드 없음 (REQ-074 AC-5)
|
||||
- strategy-state: `spy=false`면 요청 `strategy_instance_id`와 정확히 일치하는 `{version,data,updated_at}` entry 하나만(없으면 생략), `spy=true`면 `{strategy_instance_id: {version,data,updated_at}}` 전체 map
|
||||
5. degraded 상태 동봉: `SnapshotStore.degraded_state()` → `{degraded, reason, message}`.
|
||||
6. 응답: `{slots[], degraded, reason, message}` — `slots` 는 항상 배열, null 금지.
|
||||
|
||||
@@ -255,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회만 송신.
|
||||
@@ -264,7 +269,7 @@
|
||||
- 슬롯 `limit_price` 생애주기(dcy-012/REQ-002): INACTIVE→PENDING_BUY(매수 지정가 set) → PENDING_BUY→HOLDING(clear) → HOLDING→PENDING_SELL(매도 지정가 set 또는 None=시장가) → PENDING_SELL→INACTIVE(clear). 롤백(→INACTIVE) 전이에서도 clear. 외부 취소 후 holding 복귀에서도 clear 되어 재매도는 시장가.
|
||||
- **실행 정책 생애주기(dcy-022/REQ-068)**: `timeout_sec`·`slippage_pct` 는 `limit_price` 와 동일한 생애주기(PENDING_* 진입 시 set, HOLDING/INACTIVE 탈출 시 clear). 슬롯의 `updated_at` 이 dispatch 시각 proxy 로 사용된다(mark_dispatched 가 갱신, stale-alert 와 동일 방법).
|
||||
- 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅).
|
||||
- **venue 전달 경로(BAT-056/REQ-117/118, BAT-059/REQ-124, dcy-041)**: buy/sell intent `venue`(기본 AUTO) → pending `Slot.venue` → KIS 제출 시 KST 세션별 실제 KRX/NXT 확정 → `OrderAck.venue`와 주문번호 원자 저장 → 취소·재시작·Core outbox에서 실제 거래소 재사용. 허용값은 AUTO/KRX/NXT/SOR이며 invalid는 슬롯 전이 전 거부한다. HOLDING은 다음 자율매도를 위해 AUTO, INACTIVE는 하위 호환을 위해 KRX로 초기화한다.
|
||||
- **venue 전달 경로(BAT-056/REQ-117/118, BAT-059/REQ-124, dcy-041, GitHub #61)**: buy/sell intent `venue`(누락 시 SOR) → pending `Slot.venue` → KIS에 명시 KRX/NXT/SOR 제출 → `OrderAck.venue`와 주문번호 원자 저장 → 취소·재시작·Core outbox에서 실제 거래소 재사용. AUTO와 invalid 값은 슬롯 전이 전 거부한다. HOLDING은 다음 주문을 위해 SOR, INACTIVE는 하위 호환을 위해 KRX로 초기화한다.
|
||||
|
||||
### 계좌조회 단기 복구 (dcy-041 / GitHub #45)
|
||||
|
||||
@@ -286,11 +291,11 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
|
||||
|
||||
집행 루프 fill 전이 직후 `ExecutionService` 가 ledger 기록을 outbox 에 적재해 슬롯 전이 critical path 와 ledger write 를 분리한다(원장 기록 단일 주체 = 집행 경로, NFR-3).
|
||||
|
||||
- ledger skip 가시화(dcy-011/REQ-018, issue #11): `_record_ledger` 가 `portfolio_id` 또는 `execution_id` 누락으로 skip 하면 무성 반환하지 않고 **WARN 로그 + `ExecutionResult.ledger_skipped` 카운터**로 노출한다(슬롯 전이는 그대로 진행). portfolio_id 필수화(REQ-007)로 주원인은 제거됐고, 잔여 누락은 운영 점검 쿼리(REQ-055: closed 슬롯 ↔ `ledger_outbox` anti-join, `find_ledger_gap_slots`)로 영구 탐지한다.
|
||||
- ledger skip 가시화(dcy-011/REQ-018, issue #11, issue #68): `_record_ledger` 가 completed fill을 정상 `ledger_outbox` 경로에 올릴 수 없으면 무성 반환하지 않고 **WARN 로그 + `ExecutionResult.ledger_skipped` 카운터 + durable `ledger_skip` row + best-effort `trex.alerts` alarm** 으로 남긴다(슬롯 전이는 그대로 진행). unresolved row는 `trex-core ledger skips list` 로 조회하고, manual backfill 또는 operator resolve 후 resolved 상태와 별도 resolved alarm을 남긴다.
|
||||
|
||||
1. fill 감지: `BrokerPort.poll_fill` 이 `state="filled"` 와 결정적 `execution_id`(paper: `paper:{broker_order_id}:{filled_qty}` / KIS: `kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}`), 계산된 `fee`(`calculate_fee(side, amount)`)·`tax`(`calculate_tax(side, amount)`)를 운반. fee/tax 는 단일 정책 지점(`broker_port.py`)에서 env 로 관리한다(dcy-026/REQ-018 CHG-20260612-01): `TREX_KIS_FEE_BPS`(기본 1.5 = 0.015%, 매수·매도 공통)·`TREX_KRX_TAX_SELL_BPS`(기본 20.0 = 0.2%, **매도 증권거래세**; 매수 tax=0). paper·KIS 가 동일 정책을 공유하며, KIS daily-ccld 응답은 per-fill fee/tax 필드를 제공하지 않아 정책 추정값을 사용한다(향후 노출 endpoint 도입 시 브로커 정본 우선).
|
||||
2. 슬롯 전이: `pending_buy→holding`(avg_price/qty) 또는 `pending_sell→closed` 를 advisory lock 안에서 먼저 commit. 이 단계가 critical path.
|
||||
3. ledger enqueue: 슬롯의 `portfolio_id`(== ledger `asset_id`, OQ-1 결정) 가 있으면 `LedgerTrade(asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at)` 을 `LedgerOutbox.enqueue` 로 적재. 행 식별자는 `correlation_id = {asset_id}:{order_id}:{execution_id}`(로컬 dedup). `portfolio_id` 부재면 ledger 기록 skip 후 슬롯 전이는 정상 유지.
|
||||
3. ledger enqueue: 슬롯의 `portfolio_id`(== ledger `asset_id`, OQ-1 결정) 와 결정적 `execution_id`가 있으면 `LedgerTrade(asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at)` 을 `LedgerOutbox.enqueue` 로 적재한다. 행 식별자는 `correlation_id = {asset_id}:{order_id}:{execution_id}`(로컬 dedup)이다. completed fill이 `portfolio_id`/`execution_id` 누락 또는 unsupported actual venue 등 genuinely non-recordable 상태이면 `ledger_outbox` 대신 `ledger_skip` 에 unresolved 감사 row를 남기고 알람을 best-effort 발행한다. unknown actual venue alone(`order_venue=SOR`, `execution_venue=NULL`)는 계속 정상 outbox 경로다.
|
||||
4. `clear_dispatch` 로 `broker_order_id` 해제.
|
||||
|
||||
## Flow: `ledger writer` (백그라운드, BAT-006)
|
||||
@@ -309,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)
|
||||
|
||||
@@ -345,17 +352,15 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
|
||||
6. slot_transition_log 에 전이 기록 1행을 append 하고 갱신된 슬롯을 반환한다.
|
||||
7. 외부 I/O(브로커 등)는 lock 밖에서 호출자가 수행한다(lock 보유 중 금지).
|
||||
|
||||
## Flow: `slot strategy-state 저장소` (BAT-042 / dcy-028, REQ-079~082)
|
||||
## Flow: `slot strategy-state 저장소` (REQ-0009)
|
||||
|
||||
전략 데몬(예: trex-tsl)이 슬롯 점유 동안 누적하는 opaque 상태(예: TSL peak)를 슬롯 수명에 묶어 server-side 로 보관한다. core 는 `data` 를 해석하지 않는다(opaque UTF-8 + byte size cap 만 검증).
|
||||
전략 상태는 슬롯 점유와 독립된 opaque 상태다. core는 inner `data`를 해석하지 않고 UTF-8 byte size cap만 검증하며, 하나의 슬롯별 map `data[strategy_instance_id] = {version,data,updated_at}`를 보관한다.
|
||||
|
||||
1. **read (piggyback, C-1)**: 데몬이 매 사이클 `trex.slots.req` 로 슬롯을 읽으면 각 행에 `occupancy_seq`(int)와, 보유 시 `strategy_state` 맵(`{<strategy_id>: {version,data,updated_at}}`)이 함께 실린다. 맵은 핸들러가 반환 slot_id 들로 `StrategyStateStore.get_for_slots()` 1회 batched 조회 후 group by 해 조립한다(추가 왕복 없음). 슬롯 현재 occupancy_seq 와 다른 stale 엔트리는 노출하지 않는다.
|
||||
2. **evaluate (pure)**: 데몬은 행의 `strategy_state[strategy_id]` 를 read-only 입력으로 `evaluate()` 에 넣는다(상태 write 는 evaluate 밖, 엔진이 수행).
|
||||
3. **write (별도 RPC, C-2)**: 상태가 바뀌면 `trex.slots.strategy_state.set` 으로 `{slot_id, strategy_id, expected_version, expected_occupancy_seq, data}` 를 보낸다. core 는 슬롯의 현재 occupancy_seq·active 여부를 조회해 CAS 검증: 점유 불일치 → `occupancy_changed`, version 불일치 → `version_conflict`, inactive/미존재 → `slot_inactive`, size 초과 → `state_too_large`, 일치(없으면 expected_version=0) → commit + version+1. malformed → `bad_request`. 동일 입력 재전송은 무부작용.
|
||||
4. **occupancy 가드 (C-2b, R-1)**: 슬롯은 monotonic `occupancy_seq` 를 갖고 새 점유 시작(`inactive→pending_buy`)마다 ++. 영구 슬롯 재사용 시 read v0→close→reopen→write v0 의 ABA(옛 점유 계산이 새 점유에 바인딩)를 occupancy_seq CAS 가 구조적으로 차단한다.
|
||||
5. **close 삭제 (C-3)**: 슬롯이 어떤 경로로든 `→INACTIVE`(전략 매도/safety-sell/forced-close/operator) 되면 `transit_slot` 단일 전이 지점에서 해당 slot_id 의 모든 strategy_state 를 삭제한다. 재사용 슬롯은 새 occupancy_seq + 빈 상태로 시작 — 전략/데몬은 cleanup 책임이 없다(슬롯이 source of truth + GC).
|
||||
1. **read (piggyback)**: `spy=false` 전략 요청은 required non-empty `strategy_instance_id`의 exact entry만 각 slot 행의 single `strategy_state` object로 받으며, 없으면 field가 생략된다. 다른 ID나 전체 map은 노출하지 않는다. `spy=true` 운영자 요청은 ID 없이도 각 slot의 complete instance map을 받고 watchdog을 갱신하지 않는다.
|
||||
2. **write (별도 RPC)**: `trex.slots.strategy_state.set` payload는 `{slot_id, strategy_instance_id, expected_version, data}`다. active slot, size limit, 요청 entry version만 CAS한다. entry가 없으면 `expected_version=0`, 성공하면 그 entry version만 증가한다. `version_conflict`, `slot_inactive`, `state_too_large`, `bad_request:*`는 write를 적용하지 않는다.
|
||||
3. **lifecycle/concurrency**: `→INACTIVE`와 재점유는 map을 삭제하거나 숨기지 않으며 version도 초기화하지 않는다. Slot 자체의 `occupancy_seq`는 기존대로 유지하지만 strategy state persistence/read/write에는 사용하지 않는다. 서로 다른 instance entry의 concurrent 성공 write는 모두 보존된다.
|
||||
|
||||
저장은 별도 `slot_strategy_state` 테이블(복합 PK `(slot_id, strategy_id)`), 슬롯 본체에는 `occupancy_seq` 컬럼 1개만 추가(full 3계층 분할은 후속 Discovery). full 3계층(occupancy 테이블 분할)·다중 writer 동시성·장기 보존은 범위 밖.
|
||||
저장은 별도 `slot_strategy_state` 테이블의 slot-scoped JSON map이며 entry에는 identity metadata를 중복 저장하지 않는다. core-side instance registry, legacy mapping/backfill, caller 인증은 없다.
|
||||
|
||||
## Flow Constraints
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "trex-core"
|
||||
version = "0.1.1"
|
||||
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 = [
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$repo_root"
|
||||
|
||||
version=$(python3 -c 'import pathlib,tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')
|
||||
image_ref=${1:-trex-core:$version}
|
||||
source_commit=$(git rev-parse HEAD)
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "release image build requires a clean checkout" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$image_ref" in
|
||||
*:"$version") ;;
|
||||
*) echo "image reference must use the project version tag :$version" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
docker build \
|
||||
--build-arg "SOURCE_COMMIT=$source_commit" \
|
||||
--build-arg "SOURCE_VERSION=$version" \
|
||||
--tag "$image_ref" \
|
||||
.
|
||||
|
||||
actual_revision=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image_ref")
|
||||
actual_version=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$image_ref")
|
||||
actual_user=$(docker image inspect --format '{{ .Config.User }}' "$image_ref")
|
||||
|
||||
[ "$actual_revision" = "$source_commit" ]
|
||||
[ "$actual_version" = "$version" ]
|
||||
[ "$actual_user" = "10001:10001" ]
|
||||
docker run --rm "$image_ref" --version
|
||||
|
||||
printf 'image=%s\nversion=%s\nrevision=%s\nimage_id=%s\n' \
|
||||
"$image_ref" "$actual_version" "$actual_revision" \
|
||||
"$(docker image inspect --format '{{ .Id }}' "$image_ref")"
|
||||
@@ -1,494 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic verification and publication helpers for the release image."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
IMAGE = "ghcr.io/fourblocks2020/trex-core"
|
||||
CANARY = b"TREX_SECRET_CANARY_DO_NOT_SHIP"
|
||||
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
DIGEST_RE = re.compile(r"(?:^|\s)digest:\s*(sha256:[0-9a-f]{64})(?:\s|$)")
|
||||
IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
DEFAULT_MIGRATION_DIR = (
|
||||
Path(__file__).resolve().parents[1] / "trex_core" / "adapters" / "persistence" / "sql"
|
||||
)
|
||||
MAX_ARCHIVE_DEPTH = 8
|
||||
MAX_ARCHIVE_MEMBERS = 200_000
|
||||
MAX_ARCHIVE_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
|
||||
MAX_ARCHIVE_MEMBER_BYTES = 512 * 1024 * 1024
|
||||
MAX_ARCHIVE_INPUT_BYTES = 1024 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ScanBudget:
|
||||
max_depth: int = MAX_ARCHIVE_DEPTH
|
||||
max_members: int = MAX_ARCHIVE_MEMBERS
|
||||
max_extracted_bytes: int = MAX_ARCHIVE_EXTRACTED_BYTES
|
||||
max_member_bytes: int = MAX_ARCHIVE_MEMBER_BYTES
|
||||
max_input_bytes: int = MAX_ARCHIVE_INPUT_BYTES
|
||||
members: int = 0
|
||||
extracted_bytes: int = 0
|
||||
|
||||
def check_depth(self, depth: int) -> None:
|
||||
if depth > self.max_depth:
|
||||
_scan_limit_exceeded()
|
||||
|
||||
def add_member(self, size: int) -> None:
|
||||
self.members += 1
|
||||
if self.members > self.max_members:
|
||||
_scan_limit_exceeded()
|
||||
if size < 0 or size > self.max_member_bytes:
|
||||
_scan_limit_exceeded()
|
||||
self.extracted_bytes += size
|
||||
if self.extracted_bytes > self.max_extracted_bytes:
|
||||
_scan_limit_exceeded()
|
||||
|
||||
|
||||
def _scan_limit_exceeded() -> None:
|
||||
raise ValueError("archive scan resource limit exceeded")
|
||||
|
||||
|
||||
class Runner(Protocol):
|
||||
def run(
|
||||
self,
|
||||
command: Sequence[str],
|
||||
*,
|
||||
input_text: str | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> Result: ...
|
||||
|
||||
|
||||
class SubprocessRunner:
|
||||
def run(
|
||||
self,
|
||||
command: Sequence[str],
|
||||
*,
|
||||
input_text: str | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> Result:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
input=input_text,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return Result(completed.stdout, completed.stderr)
|
||||
|
||||
|
||||
def validate_commit(commit: str) -> str:
|
||||
if not COMMIT_RE.fullmatch(commit):
|
||||
raise ValueError("source commit must be 40 lowercase hexadecimal characters")
|
||||
return commit
|
||||
|
||||
|
||||
def release_tags(commit: str, ref: str) -> list[str]:
|
||||
validate_commit(commit)
|
||||
tags = [f"{IMAGE}:{commit[:12]}"]
|
||||
if ref == "refs/heads/main":
|
||||
return tags
|
||||
prefix = "refs/tags/"
|
||||
if ref.startswith(prefix) and ref.removeprefix(prefix).startswith("v"):
|
||||
release = ref.removeprefix(prefix)
|
||||
if not re.fullmatch(r"v[A-Za-z0-9._-]*", release):
|
||||
raise ValueError("release ref contains unsupported tag characters")
|
||||
tags.append(f"{IMAGE}:{release}")
|
||||
return tags
|
||||
raise ValueError("publication ref must be main or a v* tag")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_checksum(archive: Path, checksum: Path) -> None:
|
||||
checksum.write_text(f"{sha256_file(archive)} {archive.name}\n", encoding="ascii")
|
||||
|
||||
|
||||
def verify_checksum(archive: Path, checksum: Path) -> None:
|
||||
if not archive.is_file() or not checksum.is_file():
|
||||
raise ValueError("image archive or checksum evidence is missing")
|
||||
fields = checksum.read_text(encoding="ascii").strip().split()
|
||||
if len(fields) != 2 or fields[1] != archive.name or not re.fullmatch(r"[0-9a-f]{64}", fields[0]):
|
||||
raise ValueError("checksum evidence has an invalid format")
|
||||
if sha256_file(archive) != fields[0]:
|
||||
raise ValueError("image archive checksum mismatch")
|
||||
|
||||
|
||||
def validate_image_id(image_id: str) -> str:
|
||||
if not IMAGE_ID_RE.fullmatch(image_id):
|
||||
raise ValueError("image ID must be sha256 followed by 64 lowercase hexadecimal characters")
|
||||
return image_id
|
||||
|
||||
|
||||
def inspect_image_id(image: str, runner: Runner) -> str:
|
||||
result = runner.run(["docker", "image", "inspect", "--format", "{{.Id}}", image])
|
||||
return validate_image_id(result.stdout.strip())
|
||||
|
||||
|
||||
def write_image_id(image: str, output: Path, runner: Runner) -> str:
|
||||
image_id = inspect_image_id(image, runner)
|
||||
output.write_text(f"{image_id}\n", encoding="ascii")
|
||||
return image_id
|
||||
|
||||
|
||||
def read_image_id(path: Path) -> str:
|
||||
text = path.read_text(encoding="ascii")
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}\n?", text):
|
||||
raise ValueError("tested image ID evidence has an invalid format")
|
||||
return text.rstrip("\n")
|
||||
|
||||
|
||||
def verify_image_id(image: str, expected_path: Path, runner: Runner) -> str:
|
||||
expected = read_image_id(expected_path)
|
||||
actual = inspect_image_id(image, runner)
|
||||
if actual != expected:
|
||||
raise ValueError("loaded image ID does not match tested image ID")
|
||||
return actual
|
||||
|
||||
|
||||
SMOKE_PROBES: tuple[tuple[str, ...], ...] = (
|
||||
("python", "-c", "import trex_core"),
|
||||
("trex-core-daemon", "--help"),
|
||||
("trex-core", "--help"),
|
||||
("trex-core-reset", "--help"),
|
||||
)
|
||||
|
||||
|
||||
def canonical_migration_names(migration_dir: Path = DEFAULT_MIGRATION_DIR) -> tuple[str, ...]:
|
||||
names = tuple(sorted(path.name for path in migration_dir.glob("*.sql") if path.is_file()))
|
||||
if not names:
|
||||
raise ValueError("canonical migration inventory is empty")
|
||||
return names
|
||||
|
||||
|
||||
def migration_inventory_code(expected: tuple[str, ...]) -> str:
|
||||
return (
|
||||
"from trex_core.adapters.persistence.migrate import migration_files;"
|
||||
f"expected={expected!r};"
|
||||
"actual=tuple(path.name for path in migration_files());"
|
||||
"assert expected and actual == expected, 'installed migration inventory mismatch'"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_result_clean(result: Result) -> None:
|
||||
if CANARY in (result.stdout + result.stderr).encode():
|
||||
raise ValueError("canary marker found on a scanned surface")
|
||||
|
||||
|
||||
def smoke_image(
|
||||
image: str,
|
||||
runner: Runner,
|
||||
timeout: int = 30,
|
||||
migration_dir: Path = DEFAULT_MIGRATION_DIR,
|
||||
) -> None:
|
||||
expected_migrations = canonical_migration_names(migration_dir)
|
||||
probes = (
|
||||
*SMOKE_PROBES,
|
||||
("python", "-I", "-c", migration_inventory_code(expected_migrations)),
|
||||
)
|
||||
for index, probe in enumerate(probes):
|
||||
name = f"trex-core-smoke-{os.getpid()}-{index}"
|
||||
command = [
|
||||
"docker",
|
||||
"run",
|
||||
"--name",
|
||||
name,
|
||||
"--network",
|
||||
"none",
|
||||
"--read-only",
|
||||
"--cap-drop",
|
||||
"ALL",
|
||||
"--security-opt",
|
||||
"no-new-privileges",
|
||||
"--pids-limit",
|
||||
"64",
|
||||
"--tmpfs",
|
||||
"/tmp:rw,noexec,nosuid,size=16m",
|
||||
"--entrypoint",
|
||||
probe[0],
|
||||
image,
|
||||
*probe[1:],
|
||||
]
|
||||
failure: BaseException | None = None
|
||||
try:
|
||||
_ensure_result_clean(runner.run(command, timeout=timeout))
|
||||
except BaseException as error:
|
||||
failure = error
|
||||
try:
|
||||
_ensure_result_clean(
|
||||
runner.run(["docker", "rm", "--force", name], timeout=timeout)
|
||||
)
|
||||
except BaseException as cleanup_error:
|
||||
if failure is None:
|
||||
raise RuntimeError("smoke container cleanup failed") from cleanup_error
|
||||
if failure is not None:
|
||||
raise RuntimeError(f"smoke probe {index + 1} failed") from failure
|
||||
|
||||
|
||||
def _scan_stream(path: Path) -> None:
|
||||
with path.open("rb") as stream:
|
||||
overlap = b""
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
data = overlap + chunk
|
||||
if CANARY in data:
|
||||
raise ValueError("canary marker found on a scanned surface")
|
||||
overlap = data[-(len(CANARY) - 1):]
|
||||
|
||||
|
||||
def _scan_metadata(member: tarfile.TarInfo) -> None:
|
||||
values = [member.name, member.linkname, member.uname, member.gname]
|
||||
for key, value in member.pax_headers.items():
|
||||
values.extend((key, value))
|
||||
if any(CANARY in str(value).encode("utf-8", errors="replace") for value in values):
|
||||
raise ValueError("canary marker found on a scanned surface")
|
||||
|
||||
|
||||
def _scan_tar(path: Path, budget: _ScanBudget | None = None, depth: int = 0) -> None:
|
||||
budget = budget or _ScanBudget()
|
||||
if path.stat().st_size > (budget.max_input_bytes if depth == 0 else budget.max_member_bytes):
|
||||
_scan_limit_exceeded()
|
||||
_scan_stream(path)
|
||||
try:
|
||||
archive = tarfile.open(path, mode="r:*")
|
||||
except (tarfile.TarError, OSError, EOFError):
|
||||
return
|
||||
with archive:
|
||||
budget.check_depth(depth)
|
||||
for member in archive:
|
||||
budget.add_member(member.size if member.isfile() else 0)
|
||||
_scan_metadata(member)
|
||||
if not member.isfile():
|
||||
continue
|
||||
extracted = archive.extractfile(member)
|
||||
if extracted is None:
|
||||
continue
|
||||
with tempfile.NamedTemporaryFile() as nested:
|
||||
overlap = b""
|
||||
copied = 0
|
||||
while chunk := extracted.read(1024 * 1024):
|
||||
copied += len(chunk)
|
||||
if copied > member.size or copied > budget.max_member_bytes:
|
||||
_scan_limit_exceeded()
|
||||
data = overlap + chunk
|
||||
if CANARY in data:
|
||||
raise ValueError("canary marker found on a scanned surface")
|
||||
nested.write(chunk)
|
||||
overlap = data[-(len(CANARY) - 1):]
|
||||
nested.flush()
|
||||
_scan_tar(Path(nested.name), budget, depth + 1)
|
||||
|
||||
|
||||
def scan_image(image: str, saved_archive: Path, runner: Runner, timeout: int = 60) -> None:
|
||||
for command in (
|
||||
["docker", "history", "--no-trunc", image],
|
||||
["docker", "image", "inspect", image],
|
||||
):
|
||||
_ensure_result_clean(runner.run(command, timeout=timeout))
|
||||
|
||||
container = runner.run(["docker", "create", "--network", "none", image], timeout=timeout)
|
||||
container_id = container.stdout.strip()
|
||||
if not container_id:
|
||||
raise RuntimeError("docker create returned no container id")
|
||||
try:
|
||||
_ensure_result_clean(container)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
filesystem = Path(directory) / "filesystem.tar"
|
||||
_ensure_result_clean(
|
||||
runner.run(
|
||||
["docker", "export", "--output", str(filesystem), container_id],
|
||||
timeout=timeout,
|
||||
)
|
||||
)
|
||||
_scan_tar(filesystem)
|
||||
finally:
|
||||
try:
|
||||
_ensure_result_clean(
|
||||
runner.run(["docker", "rm", "--force", container_id], timeout=timeout)
|
||||
)
|
||||
except BaseException as error:
|
||||
raise RuntimeError("canary scan container cleanup failed") from error
|
||||
_scan_tar(saved_archive)
|
||||
|
||||
|
||||
def assert_clean_output(paths: Sequence[Path]) -> None:
|
||||
for path in paths:
|
||||
_scan_stream(path)
|
||||
|
||||
|
||||
def _push_digest(output: str) -> str:
|
||||
digests = set(DIGEST_RE.findall(output))
|
||||
if len(digests) != 1:
|
||||
raise ValueError("registry push did not return exactly one valid digest")
|
||||
return digests.pop()
|
||||
|
||||
|
||||
def _append(path: Path | None, text: str) -> None:
|
||||
if path is None:
|
||||
return
|
||||
with path.open("a", encoding="utf-8") as destination:
|
||||
destination.write(text)
|
||||
|
||||
|
||||
def publish_image(
|
||||
*,
|
||||
archive: Path,
|
||||
checksum: Path,
|
||||
image_id: Path,
|
||||
local_image: str,
|
||||
commit: str,
|
||||
ref: str,
|
||||
actor: str,
|
||||
token: str,
|
||||
runner: Runner,
|
||||
output_path: Path | None = None,
|
||||
summary_path: Path | None = None,
|
||||
) -> str:
|
||||
tags = release_tags(commit, ref)
|
||||
verify_checksum(archive, checksum)
|
||||
expected_image_id = read_image_id(image_id)
|
||||
loaded = runner.run(["docker", "load", "--input", str(archive)])
|
||||
load_lines = {line.strip() for line in (loaded.stdout + "\n" + loaded.stderr).splitlines()}
|
||||
if f"Loaded image: {local_image}" not in load_lines:
|
||||
raise ValueError("docker load did not report the expected local image")
|
||||
actual_image_id = inspect_image_id(local_image, runner)
|
||||
if actual_image_id != expected_image_id:
|
||||
raise ValueError("loaded image ID does not match tested image ID")
|
||||
revision = runner.run(
|
||||
[
|
||||
"docker",
|
||||
"image",
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{ index .Config.Labels \"org.opencontainers.image.revision\" }}",
|
||||
local_image,
|
||||
]
|
||||
).stdout.strip()
|
||||
if revision != commit:
|
||||
raise ValueError("loaded image revision does not match source commit")
|
||||
|
||||
if not actor or not token:
|
||||
raise ValueError("registry credentials are missing")
|
||||
for tag in tags:
|
||||
runner.run(["docker", "tag", local_image, tag])
|
||||
runner.run(
|
||||
["docker", "login", "ghcr.io", "--username", actor, "--password-stdin"],
|
||||
input_text=token,
|
||||
)
|
||||
digests = []
|
||||
for tag in tags:
|
||||
result = runner.run(["docker", "push", tag])
|
||||
digests.append(_push_digest(result.stdout + "\n" + result.stderr))
|
||||
if len(set(digests)) != 1:
|
||||
raise ValueError("published tags returned different registry digests")
|
||||
|
||||
digest = digests[0]
|
||||
reference = f"{IMAGE}@{digest}"
|
||||
_append(output_path, f"image={reference}\ndigest={digest}\nsource_commit={commit}\n")
|
||||
_append(
|
||||
summary_path,
|
||||
"## Published trex-core image\n\n"
|
||||
f"- Image: `{reference}`\n"
|
||||
f"- Digest: `{digest}`\n"
|
||||
f"- Source commit: `{commit}`\n",
|
||||
)
|
||||
return digest
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
smoke = commands.add_parser("smoke")
|
||||
smoke.add_argument("--image", required=True)
|
||||
smoke.add_argument("--timeout", type=int, default=30)
|
||||
|
||||
scan = commands.add_parser("scan")
|
||||
scan.add_argument("--image", required=True)
|
||||
scan.add_argument("--archive", type=Path, required=True)
|
||||
|
||||
checksum = commands.add_parser("checksum")
|
||||
checksum.add_argument("--archive", type=Path, required=True)
|
||||
checksum.add_argument("--output", type=Path, required=True)
|
||||
|
||||
record_id = commands.add_parser("record-image-id")
|
||||
record_id.add_argument("--image", required=True)
|
||||
record_id.add_argument("--output", type=Path, required=True)
|
||||
|
||||
verify_id = commands.add_parser("verify-image-id")
|
||||
verify_id.add_argument("--image", required=True)
|
||||
verify_id.add_argument("--expected", type=Path, required=True)
|
||||
|
||||
clean = commands.add_parser("assert-clean-output")
|
||||
clean.add_argument("paths", nargs="+", type=Path)
|
||||
|
||||
publish = commands.add_parser("publish")
|
||||
publish.add_argument("--archive", type=Path, required=True)
|
||||
publish.add_argument("--checksum", type=Path, required=True)
|
||||
publish.add_argument("--image-id", type=Path, required=True)
|
||||
publish.add_argument("--local-image", required=True)
|
||||
publish.add_argument("--commit", required=True)
|
||||
publish.add_argument("--ref", required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
runner = SubprocessRunner()
|
||||
if args.command == "smoke":
|
||||
smoke_image(args.image, runner, args.timeout)
|
||||
elif args.command == "scan":
|
||||
scan_image(args.image, args.archive, runner)
|
||||
elif args.command == "checksum":
|
||||
write_checksum(args.archive, args.output)
|
||||
elif args.command == "record-image-id":
|
||||
write_image_id(args.image, args.output, runner)
|
||||
elif args.command == "verify-image-id":
|
||||
verify_image_id(args.image, args.expected, runner)
|
||||
elif args.command == "assert-clean-output":
|
||||
assert_clean_output(args.paths)
|
||||
elif args.command == "publish":
|
||||
publish_image(
|
||||
archive=args.archive,
|
||||
checksum=args.checksum,
|
||||
image_id=args.image_id,
|
||||
local_image=args.local_image,
|
||||
commit=args.commit,
|
||||
ref=args.ref,
|
||||
actor=os.environ.get("GITHUB_ACTOR", ""),
|
||||
token=os.environ.get("REGISTRY_TOKEN", ""),
|
||||
runner=runner,
|
||||
output_path=Path(path) if (path := os.environ.get("GITHUB_OUTPUT")) else None,
|
||||
summary_path=Path(path) if (path := os.environ.get("GITHUB_STEP_SUMMARY")) else None,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
||||
print(f"image release failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect release/restart provenance and enforce audit-checkout gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
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 (
|
||||
collect_release_provenance,
|
||||
isolate_dirty_checkout_files,
|
||||
load_observed_evidence,
|
||||
)
|
||||
|
||||
_COMMAND_CLASSES = ("deploy", "recovery", "manual-stop", "manual-start", "recreate")
|
||||
_REQUIRED_CAPTURE_KEYS = frozenset(
|
||||
{"pending_slots", "reconciliation_slots", "queue_consumers"}
|
||||
)
|
||||
_SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
||||
|
||||
|
||||
def _record_path(
|
||||
output_dir: Path,
|
||||
*,
|
||||
operation_id: str,
|
||||
phase: str,
|
||||
instance: str,
|
||||
command_class: str,
|
||||
) -> Path:
|
||||
for value in (operation_id, phase, instance, command_class):
|
||||
if not _SAFE_ID.fullmatch(value):
|
||||
raise ValueError("record filename components must be safe identifiers")
|
||||
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
||||
nonce = uuid.uuid4().hex[:12]
|
||||
return output_dir / (
|
||||
f"{stamp}--{operation_id}--{phase}--{instance}--{command_class}--{nonce}.json"
|
||||
)
|
||||
|
||||
|
||||
def _parse_capture_command(raw: str) -> tuple[str, list[str]]:
|
||||
if "=" not in raw:
|
||||
raise ValueError("capture command must be KEY=command")
|
||||
key, command = raw.split("=", 1)
|
||||
key = key.strip()
|
||||
parts = shlex.split(command.strip())
|
||||
if not key or not parts:
|
||||
raise ValueError("capture command must be KEY=command")
|
||||
return key, parts
|
||||
|
||||
|
||||
def _validate_capture_command(key: str, command: list[str], *, instance: str) -> None:
|
||||
executable = Path(command[0]).name
|
||||
args = command[1:]
|
||||
expected = {
|
||||
"pending_slots": ["slots", "--status", "pending", "--instance", instance, "--json"],
|
||||
"reconciliation_slots": ["slots", "--status", "all", "--instance", instance, "--json"],
|
||||
"queue_consumers": ["list_consumers"],
|
||||
}
|
||||
if key not in expected:
|
||||
raise ValueError(f"unsupported capture key: {key}")
|
||||
if key == "queue_consumers":
|
||||
valid = executable == "rabbitmqctl" and args == expected[key]
|
||||
else:
|
||||
valid = executable == "trex-core" and args == expected[key]
|
||||
if not valid:
|
||||
raise ValueError(f"capture command is not the approved read-only form for {key}")
|
||||
|
||||
|
||||
def _outside_checkout(path: Path, checkout: Path, *, label: str) -> Path:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
resolved.relative_to(checkout.resolve())
|
||||
except ValueError:
|
||||
return resolved
|
||||
raise ValueError(f"{label} must be outside the audit checkout")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--actor", required=True)
|
||||
parser.add_argument("--reason", required=True)
|
||||
parser.add_argument("--instance", required=True)
|
||||
parser.add_argument("--operation-id", required=True)
|
||||
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("--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)
|
||||
parser.add_argument("--output-dir")
|
||||
parser.add_argument("--isolate-dir")
|
||||
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)
|
||||
if key in capture_commands:
|
||||
raise ValueError(f"duplicate capture key: {key}")
|
||||
_validate_capture_command(key, command, instance=args.instance)
|
||||
capture_commands[key] = command
|
||||
if set(capture_commands) != _REQUIRED_CAPTURE_KEYS:
|
||||
missing = sorted(_REQUIRED_CAPTURE_KEYS - set(capture_commands))
|
||||
extra = sorted(set(capture_commands) - _REQUIRED_CAPTURE_KEYS)
|
||||
raise ValueError(f"capture command keys mismatch: missing={missing}, extra={extra}")
|
||||
|
||||
checkout = Path(args.checkout_dir).resolve()
|
||||
output_default = checkout.parent / f"{checkout.name}-release-provenance"
|
||||
isolate_default = checkout.parent / f"{checkout.name}-release-provenance-isolation"
|
||||
output_dir = _outside_checkout(
|
||||
Path(args.output_dir) if args.output_dir else output_default,
|
||||
checkout,
|
||||
label="output directory",
|
||||
)
|
||||
isolate_dir = _outside_checkout(
|
||||
Path(args.isolate_dir) if args.isolate_dir else isolate_default,
|
||||
checkout,
|
||||
label="isolation directory",
|
||||
)
|
||||
|
||||
report = collect_release_provenance(
|
||||
actor=args.actor,
|
||||
reason=args.reason,
|
||||
instance=args.instance,
|
||||
command_class=args.command_class,
|
||||
operation_id=args.operation_id,
|
||||
phase=args.phase,
|
||||
checkout_dir=checkout,
|
||||
observed_evidence=observed_evidence,
|
||||
runtime_files=args.runtime_file,
|
||||
capture_commands=capture_commands,
|
||||
broker_log_path=args.broker_log_path,
|
||||
)
|
||||
except (RuntimeError, TypeError, ValueError) as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = _record_path(
|
||||
output_dir,
|
||||
operation_id=args.operation_id,
|
||||
phase=args.phase,
|
||||
instance=args.instance,
|
||||
command_class=args.command_class,
|
||||
)
|
||||
|
||||
dirty_checkout = bool(report["checkout"]["dirty_paths"])
|
||||
if dirty_checkout:
|
||||
report["dirty_checkout_isolation"] = isolate_dirty_checkout_files(
|
||||
checkout_dir=checkout,
|
||||
isolate_dir=isolate_dir,
|
||||
)
|
||||
|
||||
# Exclusive create prevents a provenance record from being overwritten.
|
||||
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(
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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())
|
||||
@@ -32,7 +32,7 @@ def main() -> int:
|
||||
)
|
||||
repo.reconcile_universe(instance, {portfolio_id: symbol})
|
||||
broker = PaperBroker(fill_after_polls=0)
|
||||
outbox = InMemoryLedgerOutbox()
|
||||
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
|
||||
ledger = InMemoryLedger()
|
||||
execution = ExecutionService(
|
||||
slots,
|
||||
|
||||
@@ -7,6 +7,7 @@ Unit tests run always; the sandbox integration test runs only when KIS
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -58,7 +59,8 @@ def test_account_scope_hash_separates_endpoint_and_application_identity():
|
||||
(403, "authorization_failed"),
|
||||
(422, "broker_rejected"),
|
||||
(429, "transport_failed"),
|
||||
(500, "transport_failed"),
|
||||
(500, "http_5xx"),
|
||||
(504, "timeout"),
|
||||
(None, "broker_rejected"),
|
||||
],
|
||||
)
|
||||
@@ -143,7 +145,9 @@ def test_poll_fill_uses_current_all_market_contract(monkeypatch):
|
||||
|
||||
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fake_get)
|
||||
|
||||
status = broker.poll_fill("91258:1234", side="buy", symbol="005930")
|
||||
status = broker.poll_fill(
|
||||
"91258:1234", side="buy", symbol="005930", order_date="20260608"
|
||||
)
|
||||
|
||||
assert status.state == "accepted"
|
||||
assert seen["tr_id"] == "VTTC0081R"
|
||||
@@ -347,8 +351,8 @@ def test_get_balance_fails_closed_on_later_transport_and_http_errors(monkeypatch
|
||||
request = httpx.Request("GET", "https://example.test/balance")
|
||||
response = httpx.Response(503, request=request)
|
||||
for failure, expected_code in [
|
||||
(httpx.ConnectTimeout("unavailable"), "transport_failed"),
|
||||
(httpx.HTTPStatusError("bad gateway", request=request, response=response), "transport_failed"),
|
||||
(httpx.ConnectTimeout("unavailable"), "timeout"),
|
||||
(httpx.HTTPStatusError("bad gateway", request=request, response=response), "http_5xx"),
|
||||
]:
|
||||
broker, calls = _balance_broker(monkeypatch, [
|
||||
_BalanceResponse(_balance_payload([{"pdno": "005930", "hldg_qty": "2"}], fk="A", nk="1"), "F"),
|
||||
@@ -404,6 +408,29 @@ def test_get_available_cash_uses_cash_only_amount(monkeypatch):
|
||||
assert b.get_available_cash() == 695_591
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure", "expected_code"),
|
||||
[
|
||||
(httpx.ReadTimeout("timed out"), "timeout"),
|
||||
(
|
||||
httpx.HTTPStatusError(
|
||||
"bad gateway",
|
||||
request=httpx.Request("GET", "https://example.test"),
|
||||
response=httpx.Response(502, request=httpx.Request("GET", "https://example.test")),
|
||||
),
|
||||
"http_5xx",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_available_cash_classifies_timeout_and_http_5xx(monkeypatch, failure, expected_code):
|
||||
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
||||
monkeypatch.setattr(b, "_headers", lambda _tr: {})
|
||||
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: (_ for _ in ()).throw(failure))
|
||||
with pytest.raises(BrokerBalanceError) as raised:
|
||||
b.get_available_cash()
|
||||
assert raised.value.code == expected_code
|
||||
|
||||
|
||||
def test_submit_transport_failure_blocks_automatic_duplicate(monkeypatch):
|
||||
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
||||
monkeypatch.setattr(b, "_headers", lambda _tr: {})
|
||||
@@ -562,7 +589,9 @@ def test_submit_unknown_confirmed_absent_becomes_retryable_reject(monkeypatch):
|
||||
status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
|
||||
|
||||
assert status.state == "rejected"
|
||||
assert status.raw == {"reason": "submission_confirmed_absent"}
|
||||
assert status.raw["reason"] == "submission_confirmed_absent"
|
||||
assert status.raw["poll_performed"] is True
|
||||
assert status.raw["poll_count"] == 1
|
||||
|
||||
|
||||
def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
|
||||
@@ -578,15 +607,90 @@ def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
|
||||
|
||||
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fail)
|
||||
|
||||
first = b.poll_fill("91258:1234", side="buy", symbol="005930")
|
||||
immediate = b.poll_fill("91258:1234", side="buy", symbol="005930")
|
||||
first = b.poll_fill(
|
||||
"91258:1234", side="buy", symbol="005930", order_date="20260608"
|
||||
)
|
||||
immediate = b.poll_fill(
|
||||
"91258:1234", side="buy", symbol="005930", order_date="20260608"
|
||||
)
|
||||
|
||||
assert first.state == "unknown"
|
||||
assert first.raw["reason"] == "fill_inquiry_transport"
|
||||
assert "timed out" not in str(first.raw)
|
||||
assert immediate.state == "accepted"
|
||||
assert immediate.raw == {"reason": "fill_poll_backoff"}
|
||||
assert immediate.raw["reason"] == "fill_poll_backoff"
|
||||
assert immediate.raw["poll_performed"] is False
|
||||
assert immediate.raw["poll_count"] == 1
|
||||
assert len(calls) == 1
|
||||
|
||||
clock[0] += 31
|
||||
again = b.poll_fill("91258:1234", side="buy", symbol="005930")
|
||||
again = b.poll_fill(
|
||||
"91258:1234", side="buy", symbol="005930", order_date="20260608"
|
||||
)
|
||||
assert again.state == "unknown"
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_unresolved_fill_poll_uses_elapsed_bounded_backoff_and_converges(monkeypatch):
|
||||
wall_now = datetime(2026, 9, 10, 12, 0, tzinfo=UTC)
|
||||
b = KISBroker(
|
||||
"12345678-01",
|
||||
app_key="k",
|
||||
app_secret="s",
|
||||
base_url=_MOCK_URL,
|
||||
clock=lambda: wall_now,
|
||||
)
|
||||
monkeypatch.setattr(b, "_headers", lambda _tr: {})
|
||||
mono = [100.0]
|
||||
monkeypatch.setattr("trex_core.adapters.broker.kis.monotonic", lambda: mono[0])
|
||||
calls = []
|
||||
terminal = [False]
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
calls.append(1)
|
||||
parent = {
|
||||
"odno": "1234", "orgn_odno": "", "ord_qty": "10",
|
||||
"tot_ccld_qty": "0", "avg_prvs": "0", "cncl_yn": "",
|
||||
"rjct_qty": "0", "rmn_qty": "0", "ord_dt": "20260910",
|
||||
"ord_gno_brno": "91258", "pdno": "005930",
|
||||
}
|
||||
child = {
|
||||
**parent, "odno": "1235", "orgn_odno": "1234",
|
||||
"cnc_cfrm_qty": "10" if terminal[0] else "0",
|
||||
}
|
||||
return {"rt_cd": "0", "output1": [parent, child]}
|
||||
|
||||
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
|
||||
submitted_at = wall_now - timedelta(hours=7)
|
||||
|
||||
first = b.poll_fill(
|
||||
"91258:1234", side="buy", symbol="005930",
|
||||
order_date="20260910", submitted_at=submitted_at,
|
||||
)
|
||||
assert first.state == "unknown"
|
||||
assert first.raw["poll_performed"] is True
|
||||
assert first.raw["poll_count"] == 1
|
||||
assert first.raw["backoff_sec"] == 300.0
|
||||
assert first.raw["stuck_duration_sec"] == 7 * 3600
|
||||
|
||||
terminal[0] = True
|
||||
mono[0] += 299
|
||||
skipped = b.poll_fill(
|
||||
"91258:1234", side="buy", symbol="005930",
|
||||
order_date="20260910", submitted_at=submitted_at,
|
||||
)
|
||||
assert skipped.state == "accepted"
|
||||
assert skipped.raw["last_result_state"] == "unknown"
|
||||
assert len(calls) == 1
|
||||
|
||||
mono[0] += 1
|
||||
resolved = b.poll_fill(
|
||||
"91258:1234", side="buy", symbol="005930",
|
||||
order_date="20260910", submitted_at=submitted_at,
|
||||
)
|
||||
assert resolved.state == "cancelled"
|
||||
assert resolved.raw["poll_count"] == 2
|
||||
assert len(calls) == 2
|
||||
|
||||
@@ -7,7 +7,7 @@ pre-seeded, httpx.get faked) and assert the FillStatus mapping.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from trex_core.adapters.broker import kis as kis_mod
|
||||
from trex_core.adapters.broker.kis import KISBroker
|
||||
@@ -23,7 +23,7 @@ def _broker():
|
||||
base_url="https://openapivts.koreainvestment.com:29443", # mock endpoint
|
||||
)
|
||||
b._token = "tok"
|
||||
b._token_exp = datetime.now(tz=timezone.utc) + timedelta(hours=1)
|
||||
b._token_exp = datetime.now(tz=UTC) + timedelta(hours=1)
|
||||
return b
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ def _row(**kw):
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260608",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "005930",
|
||||
}
|
||||
base.update(kw)
|
||||
return {"rt_cd": "0", "output1": [base]}
|
||||
@@ -59,13 +60,17 @@ def _row(**kw):
|
||||
|
||||
def test_full_external_cancel_maps_to_cancelled(monkeypatch):
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(_row(tot_ccld_qty="0", cncl_yn="Y")))
|
||||
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
|
||||
)
|
||||
assert fs.state == "cancelled" and fs.is_terminal_reject and not fs.is_partial_cancel
|
||||
|
||||
|
||||
def test_zero_fill_not_cancelled_stays_accepted(monkeypatch):
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(_row(tot_ccld_qty="0", cncl_yn="N")))
|
||||
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
|
||||
)
|
||||
assert fs.state == "accepted" and not fs.is_terminal_reject
|
||||
|
||||
|
||||
@@ -83,7 +88,9 @@ def test_zero_fill_full_rejection_maps_to_rejected(monkeypatch):
|
||||
)
|
||||
),
|
||||
)
|
||||
fs = _broker().poll_fill(f"06010:{ODNO}", side="buy", symbol="005930")
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="005930", order_date="20260608"
|
||||
)
|
||||
assert fs.state == "rejected" and fs.is_terminal_reject
|
||||
|
||||
|
||||
@@ -93,7 +100,9 @@ def test_zero_fill_partial_rejection_does_not_infer_terminal(monkeypatch):
|
||||
"get",
|
||||
_fake_get(_row(ord_qty="10", tot_ccld_qty="0", rjct_qty="4", cncl_yn="")),
|
||||
)
|
||||
fs = _broker().poll_fill(f"06010:{ODNO}", side="buy", symbol="005930")
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="005930", order_date="20260608"
|
||||
)
|
||||
assert fs.state == "accepted" and not fs.is_terminal_reject
|
||||
|
||||
|
||||
@@ -103,7 +112,9 @@ def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch):
|
||||
"get",
|
||||
_fake_get(_row(ord_qty="10", tot_ccld_qty="4", avg_prvs="70000", cncl_yn="Y")),
|
||||
)
|
||||
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
|
||||
)
|
||||
assert fs.state == "partially_filled"
|
||||
assert fs.cancelled and fs.is_partial_cancel
|
||||
assert fs.filled_qty == 4 and fs.avg_price == 70000.0
|
||||
@@ -116,5 +127,726 @@ def test_full_fill_unaffected_by_cancel_flag(monkeypatch):
|
||||
"get",
|
||||
_fake_get(_row(ord_qty="10", tot_ccld_qty="10", avg_prvs="70000", cncl_yn="N")),
|
||||
)
|
||||
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
|
||||
)
|
||||
assert fs.is_filled and fs.filled_qty == 10 and not fs.is_partial_cancel
|
||||
|
||||
|
||||
# ── Regression: KIS child cancel row (orgn_odno) ─────────────────────────────
|
||||
|
||||
def _payload_with_child_cancel(parent_odno: str, child_odno: str, ord_qty: str = "89", symbol: str = "225190"):
|
||||
"""Simulate KIS returning original row (cncl_yn='') + separate cancel child."""
|
||||
parent = {
|
||||
"odno": parent_odno,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": ord_qty,
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"rmn_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": symbol,
|
||||
}
|
||||
child = {
|
||||
"odno": child_odno,
|
||||
"orgn_odno": parent_odno,
|
||||
"ord_qty": ord_qty,
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "Y",
|
||||
"rjct_qty": "0",
|
||||
"rmn_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": symbol,
|
||||
}
|
||||
return {"rt_cd": "0", "output1": [parent, child]}
|
||||
|
||||
|
||||
def test_child_cancel_row_orgn_odno_maps_to_cancelled(monkeypatch):
|
||||
"""KIS child row with orgn_odno=<original> + cncl_yn=Y → full cancel."""
|
||||
payload = _payload_with_child_cancel(ODNO, "0000123457")
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
assert fs.state == "cancelled", f"expected cancelled, got {fs.state}"
|
||||
assert fs.is_terminal_reject
|
||||
|
||||
|
||||
def test_correction_child_row_not_misclassified_as_cancel(monkeypatch):
|
||||
"""Correction child (cncl_yn!='Y') must not be treated as a cancel.
|
||||
|
||||
Issue #81 AC: a correction/replacement child means the order is superseded
|
||||
but we cannot safely determine terminal status without the replacement
|
||||
identity → fail-closed as 'unknown' (not 'accepted/open_confirmed').
|
||||
"""
|
||||
parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
correction_child = {
|
||||
"odno": "0000123458",
|
||||
"orgn_odno": ODNO,
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "N", # correction, not cancel
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, correction_child]})
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
# Correction child means replacement identity is unresolved → fail-closed
|
||||
assert fs.state == "unknown", f"expected unknown, got {fs.state}"
|
||||
raw = fs.raw if isinstance(fs.raw, dict) else {}
|
||||
assert raw.get("reason") == "correction_child_present_unresolved"
|
||||
# Must NOT be open_confirmed — that would allow new orders on the same slot
|
||||
assert not raw.get("order_open_confirmed", False)
|
||||
|
||||
|
||||
def test_correction_child_cancel_confirmed_quantity_maps_to_cancelled(monkeypatch):
|
||||
"""KIS cancel-confirm quantity is terminal evidence even when cncl_yn is blank."""
|
||||
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="31", symbol="053050")
|
||||
parent, child = payload["output1"]
|
||||
child.update(cncl_yn="", cnc_cfrm_qty="31")
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
|
||||
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
|
||||
)
|
||||
|
||||
assert fs.state == "cancelled"
|
||||
assert fs.filled_qty == 0
|
||||
assert fs.raw["reason"] == "replacement_chain_terminal_cancel"
|
||||
assert "resolved_broker_order_id" not in fs.raw
|
||||
|
||||
|
||||
def test_live_replacement_remains_fail_closed(monkeypatch):
|
||||
payload = _payload_with_child_cancel(ODNO, "0000123458")
|
||||
_, child = payload["output1"]
|
||||
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="10", ord_qty="10")
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
|
||||
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
|
||||
)
|
||||
|
||||
assert fs.state == "unknown"
|
||||
assert fs.raw["reason"] == "replacement_live_unresolved"
|
||||
assert not fs.raw.get("order_open_confirmed", False)
|
||||
|
||||
|
||||
def test_replacement_partial_fill_then_confirmed_cancel(monkeypatch):
|
||||
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
|
||||
_, child = payload["output1"]
|
||||
child.update(
|
||||
cncl_yn="",
|
||||
cnc_cfrm_qty="6",
|
||||
tot_ccld_qty="4",
|
||||
rmn_qty="0",
|
||||
avg_prvs="70100",
|
||||
)
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
|
||||
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
|
||||
)
|
||||
|
||||
assert fs.state == "partially_filled"
|
||||
assert fs.filled_qty == 4
|
||||
assert fs.avg_price == 70100.0
|
||||
assert fs.is_partial_cancel
|
||||
assert fs.raw["reason"] == "replacement_chain_terminal_cancel"
|
||||
|
||||
|
||||
def test_repeated_cumulative_fill_across_generations_is_not_ambiguous(monkeypatch):
|
||||
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
|
||||
parent, child = payload["output1"]
|
||||
parent.update(tot_ccld_qty="4", rmn_qty="0", avg_prvs="70100")
|
||||
child.update(
|
||||
cncl_yn="",
|
||||
cnc_cfrm_qty="6",
|
||||
tot_ccld_qty="4",
|
||||
rmn_qty="0",
|
||||
avg_prvs="70100",
|
||||
)
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
|
||||
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
|
||||
)
|
||||
|
||||
assert fs.state == "partially_filled"
|
||||
assert fs.filled_qty == 4
|
||||
assert fs.avg_price == 70100.0
|
||||
assert fs.is_partial_cancel
|
||||
|
||||
|
||||
def test_multi_generation_replacement_chain_terminal_cancel(monkeypatch):
|
||||
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
|
||||
parent, replacement = payload["output1"]
|
||||
replacement.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
|
||||
terminal = {
|
||||
**replacement,
|
||||
"odno": "0000123459",
|
||||
"orgn_odno": replacement["odno"],
|
||||
"cnc_cfrm_qty": "10",
|
||||
}
|
||||
payload["output1"] = [terminal, parent, replacement]
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
|
||||
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
|
||||
)
|
||||
|
||||
assert fs.state == "cancelled"
|
||||
assert fs.raw["replacement_depth"] == 2
|
||||
|
||||
|
||||
def test_replacement_chain_branching_is_ambiguous(monkeypatch):
|
||||
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
|
||||
parent, child = payload["output1"]
|
||||
child.update(cncl_yn="", cnc_cfrm_qty="10")
|
||||
sibling = {**child, "odno": "0000123459"}
|
||||
payload["output1"] = [parent, child, sibling]
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
|
||||
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
|
||||
)
|
||||
|
||||
assert fs.state == "unknown"
|
||||
assert fs.raw["reason"] == "replacement_chain_ambiguous"
|
||||
|
||||
|
||||
def test_zero_remaining_without_cancel_confirmation_remains_unknown(monkeypatch):
|
||||
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
|
||||
_, child = payload["output1"]
|
||||
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
|
||||
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
|
||||
)
|
||||
|
||||
assert fs.state == "unknown"
|
||||
assert fs.raw["reason"] == "correction_child_present_unresolved"
|
||||
|
||||
|
||||
def test_stable_zero_fill_replacement_chain_converges_after_grace(monkeypatch):
|
||||
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="31", symbol="053050")
|
||||
parent, child = payload["output1"]
|
||||
parent.update(rmn_qty="0")
|
||||
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
|
||||
monotonic_now = [100.0]
|
||||
monkeypatch.setattr(kis_mod, "monotonic", lambda: monotonic_now[0])
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
|
||||
broker = _broker()
|
||||
|
||||
first = broker.poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
|
||||
)
|
||||
assert first.state == "unknown"
|
||||
assert first.raw["reason"] == "correction_child_present_unresolved"
|
||||
|
||||
monotonic_now[0] += 90
|
||||
confirmed = broker.poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
|
||||
)
|
||||
assert confirmed.state == "cancelled"
|
||||
assert confirmed.raw["reason"] == "replacement_chain_stable_zero_terminal"
|
||||
|
||||
|
||||
def test_zero_fill_terminal_grace_resets_when_chain_changes(monkeypatch):
|
||||
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="31", symbol="053050")
|
||||
parent, child = payload["output1"]
|
||||
parent.update(rmn_qty="0")
|
||||
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
|
||||
monotonic_now = [100.0]
|
||||
monkeypatch.setattr(kis_mod, "monotonic", lambda: monotonic_now[0])
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
|
||||
broker = _broker()
|
||||
|
||||
first = broker.poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
|
||||
)
|
||||
assert first.state == "unknown"
|
||||
|
||||
monotonic_now[0] += 90
|
||||
child["rmn_qty"] = "31"
|
||||
changed = broker.poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
|
||||
)
|
||||
assert changed.raw["reason"] == "replacement_live_unresolved"
|
||||
|
||||
monotonic_now[0] += 90
|
||||
child["rmn_qty"] = "0"
|
||||
restarted_grace = broker.poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
|
||||
)
|
||||
assert restarted_grace.state == "unknown"
|
||||
|
||||
monotonic_now[0] += 90
|
||||
confirmed = broker.poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
|
||||
)
|
||||
assert confirmed.state == "cancelled"
|
||||
|
||||
|
||||
def test_incomplete_cancel_confirmation_remains_unknown(monkeypatch):
|
||||
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
|
||||
_, child = payload["output1"]
|
||||
child.update(cncl_yn="", cnc_cfrm_qty="3", rmn_qty="0")
|
||||
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
|
||||
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
|
||||
)
|
||||
|
||||
assert fs.state == "unknown"
|
||||
assert fs.raw["reason"] == "correction_child_present_unresolved"
|
||||
|
||||
|
||||
def test_partial_fill_with_child_cancel_row(monkeypatch):
|
||||
"""Partial fill on parent + cancel child → partial_cancel."""
|
||||
parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "4",
|
||||
"avg_prvs": "70000",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
child = {
|
||||
"odno": "0000123459",
|
||||
"orgn_odno": ODNO,
|
||||
"ord_qty": "6",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "Y",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, child]})
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
|
||||
)
|
||||
assert fs.state == "partially_filled"
|
||||
assert fs.cancelled and fs.is_partial_cancel
|
||||
assert fs.filled_qty == 4
|
||||
|
||||
|
||||
# ── Regression: identity mismatch / ambiguous child rows ─────────────────────
|
||||
|
||||
def test_child_cancel_mismatched_branch_is_ignored(monkeypatch):
|
||||
"""Cancel child from a different branch (ord_gno_brno) must not affect parent."""
|
||||
parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
child_wrong_branch = {
|
||||
"odno": "0000199999",
|
||||
"orgn_odno": ODNO,
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "Y",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "99999", # different branch
|
||||
"pdno": "225190",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, child_wrong_branch]})
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
# Child has wrong branch → not a matching cancel → parent stays accepted
|
||||
assert fs.state == "accepted", f"expected accepted (mismatch branch), got {fs.state}"
|
||||
|
||||
|
||||
def test_child_cancel_mismatched_symbol_is_ignored(monkeypatch):
|
||||
"""Cancel child with different symbol (pdno) must not affect parent."""
|
||||
parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
child_wrong_symbol = {
|
||||
"odno": "0000199998",
|
||||
"orgn_odno": ODNO,
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "Y",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "000660", # different symbol
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, child_wrong_symbol]})
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
assert fs.state == "accepted", f"expected accepted (mismatch symbol), got {fs.state}"
|
||||
|
||||
|
||||
def test_child_cancel_mismatched_date_is_ignored(monkeypatch):
|
||||
"""Cancel child with different ord_dt must not affect parent."""
|
||||
parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
child_wrong_date = {
|
||||
"odno": "0000199997",
|
||||
"orgn_odno": ODNO,
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "Y",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260831", # different date
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, child_wrong_date]})
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
assert fs.state == "accepted", f"expected accepted (mismatch date), got {fs.state}"
|
||||
|
||||
|
||||
def test_ambiguous_multiple_child_cancel_rows_return_unknown(monkeypatch):
|
||||
"""Two matching cancel children → ambiguous identity → fail-closed as unknown."""
|
||||
parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
child1 = {**parent, "odno": "0000123460", "orgn_odno": ODNO, "cncl_yn": "Y", "ord_qty": "10"}
|
||||
child2 = {**parent, "odno": "0000123461", "orgn_odno": ODNO, "cncl_yn": "Y", "ord_qty": "10"}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, child1, child2]})
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
assert fs.state == "unknown", f"expected unknown (ambiguous children), got {fs.state}"
|
||||
raw = fs.raw if isinstance(fs.raw, dict) else {}
|
||||
assert raw.get("reason") == "child_cancel_ambiguous"
|
||||
|
||||
|
||||
# ── Regression: parent row identity mismatch with request ────────────────────
|
||||
|
||||
def test_parent_row_wrong_branch_returns_unknown(monkeypatch):
|
||||
"""Parent row with wrong ord_gno_brno (branch) relative to broker_order_id → unknown."""
|
||||
parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "99999", # wrong branch — request has 06010
|
||||
"pdno": "225190",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent]})
|
||||
)
|
||||
# broker_order_id encodes branch 06010, but row has 99999
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
assert fs.state == "unknown", f"expected unknown (wrong parent branch), got {fs.state}"
|
||||
raw = fs.raw if isinstance(fs.raw, dict) else {}
|
||||
assert "mismatch" in raw.get("reason", "")
|
||||
|
||||
|
||||
def test_parent_row_wrong_date_returns_unknown(monkeypatch):
|
||||
"""Parent row with wrong ord_dt relative to order_date arg → unknown."""
|
||||
parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260831", # wrong date — request has 20260901
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent]})
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
assert fs.state == "unknown", f"expected unknown (wrong parent date), got {fs.state}"
|
||||
raw = fs.raw if isinstance(fs.raw, dict) else {}
|
||||
assert "mismatch" in raw.get("reason", "")
|
||||
|
||||
|
||||
def test_parent_row_wrong_symbol_returns_unknown(monkeypatch):
|
||||
"""Parent row with wrong pdno (symbol) relative to symbol arg → unknown."""
|
||||
parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "000660", # wrong symbol — request has 225190
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent]})
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
assert fs.state == "unknown", f"expected unknown (wrong parent symbol), got {fs.state}"
|
||||
raw = fs.raw if isinstance(fs.raw, dict) else {}
|
||||
assert "mismatch" in raw.get("reason", "")
|
||||
|
||||
|
||||
def test_correction_child_wrong_identity_is_not_treated_as_correction(monkeypatch):
|
||||
"""Correction child with wrong branch/date/symbol must not trigger correction_child_present_unresolved."""
|
||||
parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
unrelated_correction = {
|
||||
"odno": "0000199990",
|
||||
"orgn_odno": ODNO,
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "N",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260831", # different date → not related to our request
|
||||
"ord_gno_brno": "99999", # different branch
|
||||
"pdno": "000660", # different symbol
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx,
|
||||
"get",
|
||||
_fake_get({"rt_cd": "0", "output1": [parent, unrelated_correction]}),
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
# Unrelated correction must not cause correction_child_present_unresolved
|
||||
assert fs.state == "accepted", f"expected accepted (unrelated correction), got {fs.state}"
|
||||
raw = fs.raw if isinstance(fs.raw, dict) else {}
|
||||
assert raw.get("reason") != "correction_child_present_unresolved"
|
||||
|
||||
|
||||
# ── Regression: parent identity boundary cases (missing / wrong-first / ambiguous) ─
|
||||
|
||||
def test_parent_missing_identity_fields_returns_unknown(monkeypatch):
|
||||
"""Parent row with missing pdno and ord_dt → treated as identity mismatch (fail-closed)."""
|
||||
parent_no_identity = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "Y", # would be cancelled if accepted
|
||||
"rjct_qty": "0",
|
||||
# ord_dt, ord_gno_brno, pdno intentionally absent
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent_no_identity]})
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
# Missing identity → fail-closed, not terminal
|
||||
assert fs.state == "unknown", f"expected unknown (missing identity), got {fs.state}"
|
||||
assert not fs.is_terminal_reject
|
||||
|
||||
|
||||
def test_child_cancel_missing_identity_fields_not_accepted_as_cancel(monkeypatch):
|
||||
"""Cancel child with missing identity fields must NOT be accepted as matching child."""
|
||||
parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
child_no_identity = {
|
||||
"odno": "0000123461",
|
||||
"orgn_odno": ODNO,
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "Y",
|
||||
"rjct_qty": "0",
|
||||
# ord_dt, ord_gno_brno, pdno intentionally absent
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx,
|
||||
"get",
|
||||
_fake_get({"rt_cd": "0", "output1": [parent, child_no_identity]}),
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
# Child has missing identity → not a matching cancel; parent has no fill → accepted
|
||||
assert fs.state == "accepted", f"expected accepted (child missing identity), got {fs.state}"
|
||||
|
||||
|
||||
def test_wrong_parent_first_exact_parent_later_finds_cancel(monkeypatch):
|
||||
"""When the wrong-branch parent comes first, the exact parent+cancel child later must win."""
|
||||
wrong_parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "99999", # wrong branch — comes first in list
|
||||
"pdno": "225190",
|
||||
}
|
||||
exact_parent = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010", # correct branch — comes second
|
||||
"pdno": "225190",
|
||||
}
|
||||
child = {
|
||||
"odno": "0000123462",
|
||||
"orgn_odno": ODNO,
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "Y",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx,
|
||||
"get",
|
||||
_fake_get({"rt_cd": "0", "output1": [wrong_parent, exact_parent, child]}),
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
# Must find the exact parent + cancel child even though wrong parent comes first
|
||||
assert fs.state == "cancelled", f"expected cancelled (exact parent later), got {fs.state}"
|
||||
|
||||
|
||||
def test_ambiguous_exact_parents_returns_unknown(monkeypatch):
|
||||
"""Two rows with identical exact identity → ambiguous parent → unknown."""
|
||||
parent1 = {
|
||||
"odno": ODNO,
|
||||
"orgn_odno": "",
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "0",
|
||||
"avg_prvs": "0",
|
||||
"cncl_yn": "",
|
||||
"rjct_qty": "0",
|
||||
"ord_dt": "20260901",
|
||||
"ord_gno_brno": "06010",
|
||||
"pdno": "225190",
|
||||
}
|
||||
parent2 = {**parent1} # exact duplicate
|
||||
monkeypatch.setattr(
|
||||
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent1, parent2]})
|
||||
)
|
||||
fs = _broker().poll_fill(
|
||||
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
|
||||
)
|
||||
assert fs.state == "unknown", f"expected unknown (ambiguous parents), got {fs.state}"
|
||||
raw = fs.raw if isinstance(fs.raw, dict) else {}
|
||||
assert raw.get("reason") == "parent_identity_ambiguous"
|
||||
|
||||
@@ -25,6 +25,7 @@ class FakeBroker:
|
||||
def __init__(self, balance, fill=None):
|
||||
self.balance = balance
|
||||
self.fill = fill or FillStatus(state="accepted")
|
||||
self.poll_metadata = None
|
||||
|
||||
def name(self):
|
||||
return "fake"
|
||||
@@ -32,7 +33,16 @@ class FakeBroker:
|
||||
def submit_order(self, req):
|
||||
return OrderAck(accepted=True, broker_order_id="1")
|
||||
|
||||
def poll_fill(self, broker_order_id, *, side, symbol):
|
||||
def poll_fill(
|
||||
self,
|
||||
broker_order_id,
|
||||
*,
|
||||
side,
|
||||
symbol,
|
||||
order_date=None,
|
||||
submitted_at=None,
|
||||
):
|
||||
self.poll_metadata = (order_date, submitted_at)
|
||||
return self.fill
|
||||
|
||||
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
|
||||
@@ -77,6 +87,23 @@ def test_confirmed_fill_is_persisted_once_and_manual_remainder_stays_hidden(tmp_
|
||||
assert StrategyOwnershipStore(store.path).snapshot()[0] == {"005930": 2}
|
||||
|
||||
|
||||
def test_poll_preserves_original_order_metadata_through_ownership_decorator(tmp_path):
|
||||
store = StrategyOwnershipStore(tmp_path / "ownership.json")
|
||||
delegate = FakeBroker([])
|
||||
broker = OwnershipAwareBroker(delegate, store)
|
||||
submitted_at = datetime(2026, 8, 14, 6, 20, tzinfo=timezone.utc)
|
||||
|
||||
broker.poll_fill(
|
||||
"1",
|
||||
side="buy",
|
||||
symbol="005930",
|
||||
order_date="20260814",
|
||||
submitted_at=submitted_at,
|
||||
)
|
||||
|
||||
assert delegate.poll_metadata == ("20260814", submitted_at)
|
||||
|
||||
|
||||
def test_strategy_quantity_above_raw_broker_total_fails_closed(tmp_path):
|
||||
store = StrategyOwnershipStore(tmp_path / "ownership.json")
|
||||
store.apply_fill(
|
||||
|
||||
@@ -91,38 +91,28 @@ def test_order_request_venue_nxt():
|
||||
assert req.venue == "NXT"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("at", "expected"),
|
||||
[
|
||||
(datetime(2026, 7, 29, 8, 0, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||
(datetime(2026, 7, 29, 8, 49, 59, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||
(datetime(2026, 7, 29, 9, 0, 30, tzinfo=timezone(timedelta(hours=9))), "KRX"),
|
||||
(datetime(2026, 7, 29, 15, 29, 59, tzinfo=timezone(timedelta(hours=9))), "KRX"),
|
||||
(datetime(2026, 7, 29, 15, 30, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||
(datetime(2026, 7, 29, 20, 0, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||
],
|
||||
)
|
||||
def test_auto_venue_resolves_by_kst_session(at, expected):
|
||||
assert resolve_order_venue("AUTO", at) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("venue", ["KRX", "NXT", "SOR"])
|
||||
@pytest.mark.parametrize(
|
||||
"at",
|
||||
[
|
||||
datetime(2026, 7, 29, 7, 59, 59, tzinfo=timezone(timedelta(hours=9))),
|
||||
datetime(2026, 7, 29, 8, 50, tzinfo=timezone(timedelta(hours=9))),
|
||||
datetime(2026, 7, 29, 9, 0, 29, tzinfo=timezone(timedelta(hours=9))),
|
||||
datetime(2026, 7, 29, 8, 0, tzinfo=timezone(timedelta(hours=9))),
|
||||
datetime(2026, 7, 29, 8, 55, tzinfo=timezone(timedelta(hours=9))),
|
||||
datetime(2026, 7, 29, 20, 0, 1, tzinfo=timezone(timedelta(hours=9))),
|
||||
],
|
||||
)
|
||||
def test_auto_venue_fails_closed_outside_supported_sessions(at):
|
||||
with pytest.raises(ValueError, match="auto_venue_session_closed"):
|
||||
def test_explicit_venue_is_preserved_independent_of_time(venue, at):
|
||||
assert resolve_order_venue(venue, at) == venue
|
||||
|
||||
|
||||
def test_auto_venue_is_rejected():
|
||||
at = datetime(2026, 7, 29, 8, 1, tzinfo=timezone(timedelta(hours=9)))
|
||||
with pytest.raises(ValueError, match="unsupported_order_venue"):
|
||||
resolve_order_venue("AUTO", at)
|
||||
|
||||
|
||||
def test_submit_auto_uses_nxt_and_reports_actual_venue(monkeypatch):
|
||||
def test_submit_sor_preserves_sor(monkeypatch):
|
||||
captured = {}
|
||||
at = datetime(2026, 7, 29, 8, 1, tzinfo=timezone(timedelta(hours=9)))
|
||||
at = datetime(2026, 7, 29, 8, 55, tzinfo=timezone(timedelta(hours=9)))
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
captured["body"] = json
|
||||
@@ -134,39 +124,75 @@ def test_submit_auto_uses_nxt_and_reports_actual_venue(monkeypatch):
|
||||
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
|
||||
ack = broker.submit_order(
|
||||
OrderRequest(
|
||||
slot_id="s-auto",
|
||||
slot_id="s-sor",
|
||||
side="buy",
|
||||
symbol="005930",
|
||||
qty=1,
|
||||
venue="AUTO",
|
||||
venue="SOR",
|
||||
)
|
||||
)
|
||||
|
||||
assert ack.accepted is True
|
||||
assert ack.venue == "NXT"
|
||||
assert captured["body"]["EXCG_ID_DVSN_CD"] == "NXT"
|
||||
assert ack.venue == "SOR"
|
||||
assert captured["body"]["EXCG_ID_DVSN_CD"] == "SOR"
|
||||
|
||||
|
||||
def test_submit_auto_outside_session_rejects_without_http(monkeypatch):
|
||||
at = datetime(2026, 7, 29, 8, 55, tzinfo=timezone(timedelta(hours=9)))
|
||||
broker = _mock_broker(clock=lambda: at)
|
||||
@pytest.mark.parametrize(
|
||||
("row", "expected"),
|
||||
[
|
||||
({"ord_exg_gb": "1"}, "KRX"),
|
||||
({"ord_exg_gb": "2"}, "NXT"),
|
||||
({"ord_exg_gb": "3"}, "KRX"),
|
||||
({"ord_exg_gb": "4"}, "NXT"),
|
||||
({"excg_id_dvsn_cd": "KRX"}, "KRX"),
|
||||
({"excg_id_dvsn_cd": "NXT"}, "NXT"),
|
||||
({"excg_id_dvsn_cd": "SOR-KRX"}, "KRX"),
|
||||
({"excg_id_dvsn_Cd": "SOR-NXT"}, "NXT"),
|
||||
({"excg_id_dvsn_cd": "SOR"}, None),
|
||||
({"excg_dvsn_cd": "02"}, None),
|
||||
({}, None),
|
||||
],
|
||||
)
|
||||
def test_kis_execution_venue_parser_is_allowlisted(row, expected):
|
||||
assert KISBroker._execution_venue_from_row(row) == expected
|
||||
|
||||
def unexpected_post(*args, **kwargs):
|
||||
raise AssertionError("AUTO session rejection must happen before HTTP")
|
||||
|
||||
monkeypatch.setattr(kis_mod.httpx, "post", unexpected_post)
|
||||
ack = broker.submit_order(
|
||||
OrderRequest(
|
||||
slot_id="s-auto-closed",
|
||||
side="buy",
|
||||
symbol="005930",
|
||||
qty=1,
|
||||
venue="AUTO",
|
||||
)
|
||||
def test_kis_fill_status_carries_sor_actual_execution_venue():
|
||||
status = KISBroker._fill_status_from_row(
|
||||
{
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "10",
|
||||
"avg_prvs": "70000",
|
||||
"ord_dt": "20260819",
|
||||
"ord_gno_brno": "12345",
|
||||
"odno": "0000000001",
|
||||
"excg_id_dvsn_cd": "NXT",
|
||||
},
|
||||
side="buy",
|
||||
today="20260819",
|
||||
fallback_odno="0000000001",
|
||||
)
|
||||
assert status.execution_venue == "NXT"
|
||||
|
||||
|
||||
def test_kis_sor_fill_without_actual_exchange_keeps_execution_venue_unknown():
|
||||
status = KISBroker._fill_status_from_row(
|
||||
{
|
||||
"ord_qty": "10",
|
||||
"tot_ccld_qty": "10",
|
||||
"avg_prvs": "70000",
|
||||
"ord_dt": "20260824",
|
||||
"ord_gno_brno": "12345",
|
||||
"odno": "0000000002",
|
||||
"excg_id_dvsn_cd": "SOR",
|
||||
},
|
||||
side="buy",
|
||||
today="20260824",
|
||||
fallback_odno="0000000002",
|
||||
)
|
||||
|
||||
assert ack.accepted is False
|
||||
assert ack.reason == "auto_venue_session_closed"
|
||||
assert status.is_filled
|
||||
assert status.execution_venue is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -94,7 +110,8 @@ def test_client_errors_are_permanent(status):
|
||||
def test_outcome_parses_cash_quantity_after_on_new():
|
||||
"""dcy-018 / REQ-017: option D authoritative cash/quantity from 201 response."""
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(201, json={"cash_balance_after": 300000.0,
|
||||
return httpx.Response(201, json={"status": "new",
|
||||
"cash_balance_after": 300000.0,
|
||||
"asset_quantity_after": 10})
|
||||
|
||||
out = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)).record_trade(_trade())
|
||||
@@ -102,12 +119,21 @@ def test_outcome_parses_cash_quantity_after_on_new():
|
||||
assert out.cash_balance_after == 300000.0 and out.asset_quantity_after == 10.0
|
||||
|
||||
|
||||
def test_outcome_parses_after_on_duplicate():
|
||||
@pytest.mark.parametrize(
|
||||
"duplicate_marker",
|
||||
[
|
||||
{"status": "duplicate"}, # portfolio-hub canonical response
|
||||
{"duplicate": True}, # backward-compatible legacy response
|
||||
],
|
||||
)
|
||||
def test_outcome_parses_after_on_duplicate(duplicate_marker):
|
||||
"""duplicate(200) also carries current authoritative values (OQ-2 b)."""
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"duplicate": True,
|
||||
"cash_balance_after": 50000.0,
|
||||
"asset_quantity_after": 3})
|
||||
return httpx.Response(200, json={
|
||||
**duplicate_marker,
|
||||
"cash_balance_after": 50000.0,
|
||||
"asset_quantity_after": 3,
|
||||
})
|
||||
|
||||
out = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)).record_trade(_trade())
|
||||
assert out.result is LedgerWriteResult.DUPLICATE_OK
|
||||
|
||||
@@ -101,6 +101,31 @@ class TestPublishRetryOnHalfOpen:
|
||||
|
||||
assert reconnect_called["yes"], "_force_reconnect must be called on first failure"
|
||||
|
||||
def test_sell_retry_reuses_identical_payload_and_mandatory_flag(self):
|
||||
t = _make_transport()
|
||||
_inject_connected(t)
|
||||
payload = {
|
||||
"slot_id": "slot-95",
|
||||
"event_type": "slot_filled_sell",
|
||||
"sequence_at": 11,
|
||||
}
|
||||
routing_key = "slot_filled_sell.SELL-B.005930"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
t,
|
||||
"_do_basic_publish",
|
||||
side_effect=[OSError("first attempt"), None],
|
||||
) as publish,
|
||||
patch.object(t, "_force_reconnect"),
|
||||
):
|
||||
t.publish_event(routing_key, payload)
|
||||
|
||||
assert publish.call_count == 2
|
||||
assert publish.call_args_list[0] == publish.call_args_list[1]
|
||||
assert publish.call_args_list[0].args[0] == routing_key
|
||||
assert publish.call_args_list[0].kwargs["mandatory"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-091 AC-2: retry also fails → TransportUnavailableError raised
|
||||
|
||||
@@ -5,6 +5,7 @@ queue/routing keys to avoid colliding with other tests or runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
@@ -149,6 +150,63 @@ def test_event_publish_and_per_strategy_consume(mq_url):
|
||||
_drain_queue(mq_url, queue_b)
|
||||
|
||||
|
||||
def test_sell_event_retry_preserves_payload_and_strategy_route(mq_url, monkeypatch):
|
||||
"""AC-0039: live retry keeps identity and uses mandatory confirmed publish."""
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
owner = f"SELL-{suffix}"
|
||||
other_owner = f"OTHER-{suffix}"
|
||||
queue = topology.strategy_event_queue_name(owner)
|
||||
other_queue = topology.strategy_event_queue_name(other_owner)
|
||||
routing_key = topology.event_routing_key("slot_filled_sell", owner, "005930")
|
||||
payload = {
|
||||
"slot_id": f"slot-{suffix}",
|
||||
"event_type": "slot_filled_sell",
|
||||
"sequence_at": 7,
|
||||
}
|
||||
publisher = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
attempts: list[tuple[str, bytes, bool]] = []
|
||||
try:
|
||||
publisher.declare_topology()
|
||||
publisher.declare_strategy_queue(
|
||||
queue, topology.strategy_event_binding_pattern(owner),
|
||||
)
|
||||
publisher.declare_strategy_queue(
|
||||
other_queue, topology.strategy_event_binding_pattern(other_owner),
|
||||
)
|
||||
original_publish = publisher._do_basic_publish
|
||||
|
||||
def fail_first_then_publish(rk: str, body: bytes, *, mandatory: bool) -> None:
|
||||
attempts.append((rk, body, mandatory))
|
||||
if len(attempts) == 1:
|
||||
raise OSError("forced first-attempt failure")
|
||||
original_publish(rk, body, mandatory=mandatory)
|
||||
|
||||
monkeypatch.setattr(publisher, "_do_basic_publish", fail_first_then_publish)
|
||||
publisher.publish_event(routing_key, payload)
|
||||
|
||||
assert len(attempts) == 2
|
||||
assert attempts[0] == attempts[1]
|
||||
assert attempts[0][0] == routing_key
|
||||
assert attempts[0][2] is True
|
||||
assert json.loads(attempts[0][1]) == payload
|
||||
|
||||
publisher._ensure_connected()
|
||||
channel = publisher._channel
|
||||
assert channel is not None
|
||||
method, _props, body = channel.basic_get(queue, auto_ack=True)
|
||||
assert method is not None
|
||||
assert body is not None
|
||||
assert json.loads(body) == payload
|
||||
other_method, _other_props, _other_body = channel.basic_get(
|
||||
other_queue, auto_ack=True,
|
||||
)
|
||||
assert other_method is None
|
||||
finally:
|
||||
publisher.close()
|
||||
_drain_queue(mq_url, queue)
|
||||
_drain_queue(mq_url, other_queue)
|
||||
|
||||
|
||||
def test_call_rpc_timeout(mq_url):
|
||||
queue = f"trex.test.rpc.timeout.{uuid.uuid4().hex[:8]}"
|
||||
client = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""PostgreSQL auto-reconcile transaction sequencing tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from trex_core.adapters.persistence.auto_reconcile import PostgreSQLAutoReconcileStore
|
||||
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
|
||||
from trex_core.adapters.portfolio.reconcile_warning_store import (
|
||||
PostgreSQLReconcileWarningStore,
|
||||
)
|
||||
from trex_core.application.auto_reconcile import AutoReconcileCommand
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, rowcounts=(1, 1), fail_on_insert=False):
|
||||
self.rowcounts = iter(rowcounts)
|
||||
self.rowcount = -1
|
||||
self.fail_on_insert = fail_on_insert
|
||||
self.calls = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
normalized = " ".join(sql.split())
|
||||
self.calls.append((normalized, params))
|
||||
if normalized.startswith("UPDATE daily_slot"):
|
||||
self.rowcount = next(self.rowcounts)
|
||||
elif normalized.startswith("DELETE FROM slot_reconcile_warning"):
|
||||
self.rowcount = next(self.rowcounts)
|
||||
elif normalized.startswith("INSERT INTO slot_transition_log") and self.fail_on_insert:
|
||||
raise RuntimeError("audit insert failed")
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
self.commits = 0
|
||||
self.rollbacks = 0
|
||||
|
||||
def cursor(self):
|
||||
return self._cursor
|
||||
|
||||
def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
def rollback(self):
|
||||
self.rollbacks += 1
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeConnectionContext:
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
|
||||
def __enter__(self):
|
||||
return self.conn
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
def _command():
|
||||
return AutoReconcileCommand(
|
||||
slot_id=uuid4(),
|
||||
instance_key="inst-a",
|
||||
portfolio_id="pf-a",
|
||||
symbol="019685",
|
||||
expected_qty=1452.0,
|
||||
expected_avg_price=2100.0,
|
||||
target_qty=726.0,
|
||||
target_avg_price=2172.46829187,
|
||||
hub_quantity=726.0,
|
||||
broker_quantity=726,
|
||||
evidence_observed_at=datetime.now(tz=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _install(monkeypatch, conn):
|
||||
module = SimpleNamespace(connect=lambda dsn, autocommit=False: FakeConnectionContext(conn))
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.auto_reconcile._require_psycopg",
|
||||
lambda _module=None: module,
|
||||
)
|
||||
|
||||
|
||||
def test_postgresql_auto_reconcile_commits_update_b_clear_and_audit(monkeypatch):
|
||||
cursor = FakeCursor(rowcounts=(1, 1))
|
||||
conn = FakeConnection(cursor)
|
||||
_install(monkeypatch, conn)
|
||||
|
||||
assert PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
|
||||
|
||||
assert conn.commits == 1
|
||||
assert conn.rollbacks == 0
|
||||
statements = [sql for sql, _params in cursor.calls]
|
||||
assert statements[0].startswith("SELECT pg_advisory_xact_lock")
|
||||
assert "status='holding'" in statements[1]
|
||||
assert "avg_price IS NOT DISTINCT FROM" in statements[1]
|
||||
assert "broker_order_id IS NULL" in statements[1]
|
||||
assert statements[2].startswith("DELETE FROM slot_reconcile_warning")
|
||||
assert cursor.calls[2][1][1] == "B"
|
||||
assert statements[3].startswith("INSERT INTO slot_transition_log")
|
||||
assert cursor.calls[3][1][2:4] == (
|
||||
"hub_broker_matched_core_mirror_stale",
|
||||
"auto_reconcile",
|
||||
)
|
||||
|
||||
|
||||
def test_postgresql_auto_reconcile_cas_failure_rolls_back_without_warning_or_audit(monkeypatch):
|
||||
cursor = FakeCursor(rowcounts=(0,))
|
||||
conn = FakeConnection(cursor)
|
||||
_install(monkeypatch, conn)
|
||||
|
||||
assert not PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
|
||||
|
||||
assert conn.commits == 0
|
||||
assert conn.rollbacks == 1
|
||||
assert len(cursor.calls) == 2
|
||||
|
||||
|
||||
def test_postgresql_auto_reconcile_audit_failure_rolls_back_slot_and_warning(monkeypatch):
|
||||
cursor = FakeCursor(rowcounts=(1, 1), fail_on_insert=True)
|
||||
conn = FakeConnection(cursor)
|
||||
_install(monkeypatch, conn)
|
||||
|
||||
with pytest.raises(RuntimeError, match="audit insert failed"):
|
||||
PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
|
||||
|
||||
assert conn.commits == 0
|
||||
assert conn.rollbacks == 1
|
||||
assert len(cursor.calls) == 4
|
||||
|
||||
|
||||
def _seed_real_transaction_state(pg_clean):
|
||||
repo = PostgreSQLSlotRepository(pg_clean)
|
||||
service = SlotService(repo, instance_key="inst-a")
|
||||
warning = PostgreSQLReconcileWarningStore(pg_clean)
|
||||
repo.reconcile_universe("inst-a", {"pf-a": "019685"})
|
||||
slot = repo.find_slot_by_portfolio("inst-a", "pf-a")
|
||||
assert slot is not None
|
||||
pending = service.transit_slot(
|
||||
slot.slot_id,
|
||||
SlotState.INACTIVE,
|
||||
SlotState.PENDING_BUY,
|
||||
reason="buy",
|
||||
actor="test",
|
||||
symbol="019685",
|
||||
)
|
||||
holding = service.transit_slot(
|
||||
pending.slot_id,
|
||||
SlotState.PENDING_BUY,
|
||||
SlotState.HOLDING,
|
||||
reason="filled",
|
||||
actor="test",
|
||||
qty=1452.0,
|
||||
avg_price=2100.0,
|
||||
)
|
||||
warning.set_warned(
|
||||
str(holding.slot_id),
|
||||
portfolio_id="pf-a",
|
||||
case_code="B",
|
||||
actor="reconcile",
|
||||
)
|
||||
warning.set_warned(
|
||||
str(holding.slot_id),
|
||||
portfolio_id="pf-a",
|
||||
case_code="C",
|
||||
actor="reconcile",
|
||||
)
|
||||
command = AutoReconcileCommand(
|
||||
slot_id=holding.slot_id,
|
||||
instance_key="inst-a",
|
||||
portfolio_id="pf-a",
|
||||
symbol="019685",
|
||||
expected_qty=1452.0,
|
||||
expected_avg_price=2100.0,
|
||||
target_qty=726.0,
|
||||
target_avg_price=2172.46829187,
|
||||
hub_quantity=726.0,
|
||||
broker_quantity=726,
|
||||
evidence_observed_at=datetime.now(tz=UTC),
|
||||
)
|
||||
return repo, warning, holding, command
|
||||
|
||||
|
||||
def test_postgresql_auto_reconcile_real_transaction_commit(pg_clean):
|
||||
repo, warning, holding, command = _seed_real_transaction_state(pg_clean)
|
||||
|
||||
assert PostgreSQLAutoReconcileStore(pg_clean).apply_holding_mirror(command)
|
||||
|
||||
current = repo.fetch_slot(holding.slot_id)
|
||||
assert current is not None
|
||||
assert current.status is SlotState.HOLDING
|
||||
assert current.qty == 726.0
|
||||
assert current.avg_price == pytest.approx(2172.46829187)
|
||||
info = warning.get_warning_info(str(holding.slot_id))
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["C"]
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT actor, reason FROM slot_transition_log "
|
||||
"WHERE slot_id = %s ORDER BY ts DESC LIMIT 1",
|
||||
(holding.slot_id,),
|
||||
).fetchone()
|
||||
assert row == ("auto_reconcile", "hub_broker_matched_core_mirror_stale")
|
||||
|
||||
|
||||
def test_postgresql_auto_reconcile_real_transaction_audit_failure_rolls_back(pg_clean):
|
||||
repo, warning, holding, command = _seed_real_transaction_state(pg_clean)
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
before_audit_row = conn.execute(
|
||||
"SELECT count(*) FROM slot_transition_log WHERE slot_id = %s",
|
||||
(holding.slot_id,),
|
||||
).fetchone()
|
||||
assert before_audit_row is not None
|
||||
before_audit_count = before_audit_row[0]
|
||||
|
||||
with psycopg.connect(pg_clean, autocommit=True) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE FUNCTION trex_test_fail_auto_reconcile_audit()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'forced auto-reconcile audit failure';
|
||||
END
|
||||
$$
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TRIGGER trex_test_fail_auto_reconcile_audit
|
||||
BEFORE INSERT ON slot_transition_log
|
||||
FOR EACH ROW WHEN (NEW.actor = 'auto_reconcile')
|
||||
EXECUTE FUNCTION trex_test_fail_auto_reconcile_audit()
|
||||
"""
|
||||
)
|
||||
|
||||
try:
|
||||
with pytest.raises(psycopg.errors.RaiseException, match="forced auto-reconcile"):
|
||||
PostgreSQLAutoReconcileStore(pg_clean).apply_holding_mirror(command)
|
||||
|
||||
current = repo.fetch_slot(holding.slot_id)
|
||||
assert current is not None
|
||||
assert current.qty == 1452.0
|
||||
assert current.avg_price == 2100.0
|
||||
info = warning.get_warning_info(str(holding.slot_id))
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["B", "C"]
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
after_audit_row = conn.execute(
|
||||
"SELECT count(*) FROM slot_transition_log WHERE slot_id = %s",
|
||||
(holding.slot_id,),
|
||||
).fetchone()
|
||||
assert after_audit_row is not None
|
||||
after_audit_count = after_audit_row[0]
|
||||
assert after_audit_count == before_audit_count
|
||||
finally:
|
||||
with psycopg.connect(pg_clean, autocommit=True) as conn:
|
||||
conn.execute(
|
||||
"DROP TRIGGER IF EXISTS trex_test_fail_auto_reconcile_audit ON slot_transition_log"
|
||||
)
|
||||
conn.execute("DROP FUNCTION IF EXISTS trex_test_fail_auto_reconcile_audit()")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user