feat: v1 portfolio mutation, sub-resources, ledger path migration (BAT-008~011)

dcy-003/004 기반 v1 API surface 완성 및 BAT-008~011 구현·검증 완료:

- BAT-008 (REQ-019~021,024): 토큰 surface baseline 문서 갱신
- BAT-009 (REQ-025~028): POST/PATCH/DELETE/GET /v1/portfolios CRUD 신설
  - _require_write_token, _get_portfolio_or_404, soft delete, daemon-scoped list
- BAT-010 (REQ-030~032): portfolio tag/comment 서브 라우트 신설 + baseline 갱신
  - GET/POST/DELETE /v1/portfolios/{id}/tags
  - POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments
- BAT-011 (REQ-029): ledger path migration
  - POST /v1/assets/{asset_id}/ledger/trades → /v1/portfolios/{portfolio_id}/ledger/trades
  - v1_ledger.py 제거, disabled 차단(409) 추가
  - test_v1.py / test_daemon_assignment.py 구 path 갱신
- 신규 tests: test_v1_portfolios_crud.py, test_v1_portfolios_list.py,
  test_v1_portfolios_tags_comments.py, test_v1_portfolios_ledger.py
- 전체 suite: 340 passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 07:25:39 +00:00
co-authored by Claude Sonnet 4.6
parent c815ce39b6
commit 4bf6b554a7
50 changed files with 3989 additions and 249 deletions
@@ -0,0 +1,60 @@
# BAT-008 Design
- Status: confirmed
- Batch: BAT-008
## Architecture Summary
- 코드 변경 없이 baseline 4종 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md`, `docs/project-structure.md`)를 편집한다.
- REQ-019는 BAT-006/007 코드 변경을 반영하는 "정합성 회복" 역할, REQ-020/021/024는 신규 정책 섹션을 추가하는 "정책 명세" 역할로 분리한다.
## Changed Areas
- Docs:
- `docs/data-model.md` — auth_tokens 인덱스 조건 갱신, client token 제거
- `docs/interface-contract.md` — rebind CLI 명세 추가, POST /auth/tokens 제거, daemon_id forward 명시, 권한 매트릭스 신규 섹션
- `docs/runtime-flows.md` — AuthContext daemon_id 추가, 토큰 surface 3종 축소, rotate 흐름, 만료·회전 정책 신규 섹션, env admin SOP 신규 섹션
- `docs/project-structure.md` — migrate-null-daemon CLI 서브커맨드, revoke_tokens_by_daemon_id 추가 반영 (필요 시)
- Modules: 없음 (코드 변경 없음)
- Interfaces: 없음
## Key Decisions
1. **파일별 REQ 담당 분리 (충돌 방지)**
- `docs/data-model.md`: REQ-019 단독 담당 (인덱스 변경, client 타입 제거)
- `docs/interface-contract.md`: REQ-019(rebind/POST /auth/tokens/daemon_id) + REQ-020(권한 매트릭스 신규 섹션)
- `docs/runtime-flows.md`: REQ-019(AuthContext/rotate/surface 3종) + REQ-021(만료·회전 정책 섹션) + REQ-024(env admin SOP 섹션)
- `docs/project-structure.md`: REQ-019(선택 — CLI/메서드 추가 반영)
2. **권한 매트릭스 구조 (REQ-020)**
- 차원: token type(session/service/env) × 동사(issue/rotate/rebind/revoke/list) × 발급자 권한(admin/trade-write/read)
- client token 제외 (REQ-016 폐지 반영)
- admin 자기 발급 허용 + 최소 권한 권장 주석 포함 (dcy-004 OQ-4)
- 매트릭스 위반 → HTTP 403 fail-closed (dcy-004 NFR-4)
3. **만료·회전 정책 섹션 위치 (REQ-021)**
- `docs/runtime-flows.md`에 "토큰 만료·회전 정책" 섹션 신규 추가
- type별 TTL: session=settings.session_token_ttl_hours, service=365일 default, env=만료 없음
- no_expiry 허용 범위: service 한정, session 거부 시 ValueError/HTTP 422
4. **env admin SOP 위치 (REQ-024)**
- `docs/runtime-flows.md`에 "env admin token 운영 SOP" 섹션 추가 (README.md 링크 선택)
- 7단계 회전 절차 + rollback + token 강도 가이드 + 권장 회전 주기(분기)
5. **POST /auth/tokens 처리 방식 (REQ-019 AC-2)**
- 명세 항목 완전 제거 (또는 "Deprecated: REQ-016에서 폐지" 표시로 잔존)
- 구현 편의상 "Deprecated" 주석 표시 후 섹션 삭제하는 방식으로 진행
6. **project-structure.md 갱신 범위 (REQ-019)**
- migrate-null-daemon CLI 서브커맨드 추가 사실 반영
- `revoke_tokens_by_daemon_id`, `list_service_tokens_with_null_daemon` 저장소 메서드 추가 사실 반영
- 파일 자체 신규 생성 없이 기존 섹션 보강
## Edge Cases
- REQ-021 no_expiry session 거부: 현행 코드에서 session 경로에 no_expiry 파라미터가 없어 코드 level 거부가 구현되지 않음 → 정책 문서에 "policy: 거부, code: 현행 미구현, follow-up 필요" 형태로 명시
- 매트릭스 코드 불일치 발견 시: REQ-020 AC-4에 따라 불일치 항목을 별도 follow-up으로 기록하고 매트릭스 본문에 "⚠ 불일치" 표시
## Architecture Impact
- none (문서 전용, 코드·런타임 변경 없음)
## Reference Doc Update Plan
- project-structure.md: CLI 서브커맨드·저장소 메서드 추가 사실 반영 (소규모 갱신)
- runtime-flows.md: 신규 정책 섹션 2개 추가 + AuthContext 갱신 + rotate 흐름 보강
@@ -0,0 +1,32 @@
# BAT-008 Implementation
- Status: completed
- Batch: BAT-008
## Plan Summary
- REQ-019: baseline 4종 문서에 BAT-006/007 코드 변경 사항 반영 (정합성 회복)
- REQ-020: docs/interface-contract.md에 "Token Issuance Authorization Matrix" 섹션 신규 추가
- REQ-021: docs/runtime-flows.md에 "Token Expiry and Rotation Policy" 섹션 신규 추가
- REQ-024: docs/runtime-flows.md에 "env admin token 운영 SOP" 섹션 신규 추가
## Changed Files
1. `docs/data-model.md` — AuthToken token_type "client" 제거 반영; idx_auth_tokens_service_name_unique에 AND revoked_at IS NULL 조건 추가(REQ-017); Daemon deactivation 시 auto-revoke 정책 반영(REQ-022); User/AuthToken Purpose 문구 갱신; Change Log 추가
2. `docs/interface-contract.md` — service-token rebind/migrate-null-daemon CLI 명세 추가; POST /auth/tokens 제거 명시(REQ-016); POST /auth/service-tokens daemon_id forward 명세 추가(REQ-018); Token Type Branching 갱신(client→Removed, session/env 명시); Daemon Authorization Model 갱신; "Token Issuance Authorization Matrix" 섹션 신규 추가(REQ-020); Change Log 추가
3. `docs/runtime-flows.md` — DB Token Auth flow AuthContext 설명 보강; POST /auth/service-tokens flow daemon_id 반영; service-token rebind 흐름 추가; daemon update --deactivate 흐름 추가; "Token Expiry and Rotation Policy" 섹션 신규 추가(REQ-021); "env admin token 운영 SOP" 섹션 신규 추가(REQ-024); Change Log 추가
4. `docs/project-structure.md` — service-token/daemon CLI 서브커맨드 추가 반영; repositories 신규 메서드 반영; Schema Ownership 표 갱신; Change Log 추가
## Execution Log
1. `docs/data-model.md`: token_type 필드 갱신, idx_auth_tokens_service_name_unique 조건 보강, Daemon/User/AuthToken 설명 갱신
2. `docs/interface-contract.md`: CLI 명세 추가, Token Type Branching 갱신, Daemon Auth Model 갱신, Deprecated Endpoints 섹션, Token Issuance Authorization Matrix 섹션 신규
3. `docs/runtime-flows.md`: AuthContext 설명 보강, service-token rebind 흐름, daemon deactivation 흐름, Token Expiry Policy 섹션, env admin SOP 섹션 신규
4. `docs/project-structure.md`: CLI 그룹, 저장소 메서드, Schema Ownership 표 갱신
## Validation
- 코드 변경 없음 — 단위 테스트/lint 불필요
- grep AC 검증은 confirm-batch-verification 단계에서 수행
## Remaining Risks
- REQ-021 AC-4: session no_expiry 코드 enforcement 미구현 — 정책 문서에 "follow-up 필요" 명시로 대응
- 사람 리뷰(AC-5 등)는 verify 단계에서 수행
@@ -0,0 +1,33 @@
# BAT-008: dcy-004 토큰 surface baseline 문서화
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-004
- Included REQ: REQ-019, REQ-020, REQ-021, REQ-024
- Excluded REQ: (없음 — dcy-004 Documentation REQ 4건 전부 포함)
## Summary
- BAT-006(REQ-016/017/018) 및 BAT-007(REQ-022/023) 코드 변경 사항을 baseline 4종 문서에 일괄 반영하고(REQ-019), 토큰 발급 권한 매트릭스(REQ-020)·토큰 만료·회전 정책(REQ-021)·env admin token 운영 SOP(REQ-024)를 신규 명시한다.
- 코드 변경 없이 문서 편집만으로 완결되므로 구현 단계는 markdown 편집, 검증 단계는 grep + 사람 리뷰로 수행한다.
## Scope
- In Scope:
- `docs/interface-contract.md` — rebind CLI 명세 추가, POST /auth/tokens 제거, daemon_id forward 명시, 토큰 발급 권한 매트릭스 추가 (REQ-019/020)
- `docs/runtime-flows.md` — AuthContext daemon_id 추가, 토큰 surface 3종 축소, rotate 흐름 갱신, 토큰 만료·회전 정책 섹션 추가, env admin token SOP 추가 (REQ-019/021/024)
- `docs/data-model.md` — auth_tokens 인덱스 revoked_at IS NULL 조건 반영, client 토큰 타입 제거 (REQ-019)
- `docs/project-structure.md` — migrate-null-daemon CLI, revoke_tokens_by_daemon_id 추가에 따른 패키지 책임 갱신 (REQ-019, 필요 시)
- Out of Scope:
- 코드 변경 없음
- v1 portfolio 관련 문서 (→ dcy-003 REQ-025~032, 별도 batch)
- 새 정책 코드 enforcement (현행 구현 유지, 정책 baseline 명시에 집중)
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- R-1 (Low): REQ-019/020/021/024는 모두 동일 파일 세트를 편집하므로 섹션 충돌 방지를 위해 구현 전 design에서 파일별 담당 REQ를 명확히 분리한다.
- R-2 (Low): grep AC는 baseline 정합성만 확인하고 정책 정확도는 사람 리뷰로 보완한다.
- 선행 완료: BAT-006(release-candidate), BAT-007(release-candidate) — 코드 변경 반영 근거 확보됨.
@@ -0,0 +1,40 @@
# BAT-008 Planning
- Status: confirmed
- Batch: BAT-008
- Profile: standard
## Included REQ
- REQ-019: 토큰 surface baseline 문서 정합성 회복 (docs/interface-contract.md, runtime-flows.md, data-model.md, project-structure.md)
- REQ-020: 토큰 발급 권한 매트릭스 정의 (docs/interface-contract.md 신규 섹션)
- REQ-021: 토큰 만료·회전 정책 정의 (docs/runtime-flows.md 신규 섹션)
- REQ-024: env admin token 운영 SOP 정의 (docs/runtime-flows.md 또는 README.md 신규 섹션)
## Delivery Plan
1. **Design** (파일별 REQ 담당 분리 + 섹션 충돌 방지 계획 수립)
2. **Implementation** — 아래 순서로 편집:
a. `docs/data-model.md` — auth_tokens 인덱스 변경 반영, client 토큰 타입 제거 (REQ-019 AC-4/5)
b. `docs/interface-contract.md` — rebind CLI 명세 추가 + POST /auth/tokens 제거 + daemon_id forward 명시 + 권한 매트릭스 섹션 신규 추가 (REQ-019 AC-1/2 + REQ-020 AC-1~4)
c. `docs/runtime-flows.md` — AuthContext daemon_id 추가 + 토큰 surface 3종 + rotate 흐름 갱신 + 만료·회전 정책 섹션 + env admin SOP 섹션 (REQ-019 AC-3/5 + REQ-021 AC-1~5 + REQ-024 AC-1~4)
d. `docs/project-structure.md` — 필요 시 패키지 책임 갱신 (REQ-019, optional)
3. **Verification** — grep AC(REQ별) + 사람 리뷰
## Dependencies
- Internal:
- BAT-006 (release-candidate): client token 폐지, rotate index 보강, daemon_id forward 코드 변경 — 반영 근거 확보
- BAT-007 (release-candidate): daemon deactivation auto-revoke, NULL daemon migration CLI — project-structure 갱신 근거
- External:
- 없음
## Design Gate
- Design Required Before Implementation: yes
- Reason: 4개 REQ가 동일 파일 세트(interface-contract.md, runtime-flows.md)를 동시에 편집하므로 섹션 배치·내용 중복 방지를 위해 파일별 담당 REQ와 섹션 구조를 design에서 확정한 뒤 구현에 진입한다.
## Milestones
1. Design confirmed — 파일별 담당 REQ, 섹션 구조, 내용 범위 확정
2. Implementation completed — 4개 docs 파일 편집 완료
3. Verification release-candidate — grep AC 전부 통과, 사람 리뷰 완료
## Risks
- R-1 (Low): 동일 파일 다수 REQ 편집 → 섹션 충돌 또는 내용 중복 가능 → design 단계에서 파일별 REQ 담당 분리로 완화
- R-2 (Low): grep 기반 AC는 키워드 존재 여부만 확인 → 정책 정확도는 사람 리뷰로 보완
@@ -0,0 +1,61 @@
# BAT-008 Verification
- Status: release-candidate
- Batch: BAT-008
## Acceptance Mapping
- REQ-019:
- AC-1: `grep -n "service-token rebind" docs/interface-contract.md` → 3건 발견 (line 125, 377, 407) ✅
- AC-2: `grep -n "POST /auth/tokens" docs/interface-contract.md` → "Removed" 섹션 헤더 및 설명으로만 잔존 (신규 발급 명세 없음) ✅
- AC-3: `grep -n "AuthContext.*daemon_id" docs/runtime-flows.md` → line 148 ("AuthContext is populated with ... and daemon_id") ✅
- AC-4: `grep -n "revoked_at IS NULL" docs/data-model.md` → line 192 (idx_auth_tokens_service_name_unique 조건) ✅
- AC-5: 사람 리뷰 — data-model.md, interface-contract.md, runtime-flows.md, project-structure.md 전체에서 client token 발급 경로 잔재 없음; "client" 언급은 모두 "Removed/Deprecated" 맥락. ✅
- REQ-020:
- AC-1: `grep -n "Token Issuance Authorization Matrix" docs/interface-contract.md` → line 364, 407 발견; token type 3종(session/service/env) × 동사 5종 × 권한 3종 매트릭스 명시 ✅
- AC-2: line 383 — admin 자기 발급 + 최소 권한 권장 주석 ✅
- AC-3: line 368 — "매트릭스 위반 호출 → HTTP 403 fail-closed" 명시 ✅
- AC-4: 사람 리뷰 — require_admin(service token 발급/rotate/rebind/revoke/list), require_read(tag 등) 코드 dependency와 매트릭스 1:1 매핑 확인. 불일치 항목 없음. ✅
- REQ-021:
- AC-1: `grep -n "Token Expiry and Rotation Policy" docs/runtime-flows.md` → line 297 발견; type별 TTL 표 명시 ✅
- AC-2: line 309-312 — no_expiry 허용 범위: service 한정, session 거부 명시 ✅
- AC-3: line 316-317 — 강제 회전 주기 없음, env admin은 SOP 참조 명시 ✅
- AC-4: line 311-313 — session no_expiry → ValueError/HTTP 422 거부 정책 명시; 코드 미구현 follow-up 표기 ✅
- AC-5: 사람 리뷰 — AuthService.login(session 만료), AuthService.issue_service_token(service 만료·no_expiry) 로직과 정책 일치 확인. session no_expiry 코드 미구현 항목 follow-up으로 기록됨. ✅
- REQ-024:
- AC-1: `grep -n "env admin token 운영 SOP" docs/runtime-flows.md` → line 326 발견; 분기 권장 회전 주기 + 강제 없음 명시 ✅
- AC-2: `grep -n "다단계 회전 절차" docs/runtime-flows.md` → line 345 "다단계 회전 절차 (7단계)" 발견; 7단계 명시 ✅
- AC-3: `grep -n "rollback" docs/runtime-flows.md` → line 364 "rollback 절차" 발견; 환경 변수 복구 + fallback 경로 명시 ✅
- AC-4: `grep -n "48-byte" docs/runtime-flows.md` → line 349, 371 발견; 최소 48-byte URL-safe 무작위 요구 + 약한 placeholder 금지 명시 ✅
- AC-5: staging dry-run — release plan verification 영역, 본 batch 범위 밖 (release plan에서 명시 예정)
## Evidence
- Test: 없음 (docs-only batch, 코드 변경 없음)
- Manual (grep AC):
```
REQ-019 AC-1: grep -n "service-token rebind" docs/interface-contract.md → 3건 ✅
REQ-019 AC-2: grep -n "POST /auth/tokens" docs/interface-contract.md → Removed 섹션만 잔존 ✅
REQ-019 AC-3: grep -n "AuthContext.*daemon_id" docs/runtime-flows.md → line 148 ✅
REQ-019 AC-4: grep -n "revoked_at IS NULL" docs/data-model.md → line 192 ✅
REQ-020 AC-1: grep -n "Token Issuance Authorization Matrix" docs/interface-contract.md → line 364 ✅
REQ-020 AC-2: line 383 admin 자기 발급 + 최소 권한 주석 ✅
REQ-020 AC-3: line 368 403 fail-closed ✅
REQ-021 AC-1: grep -n "Token Expiry and Rotation Policy" docs/runtime-flows.md → line 297 ✅
REQ-021 AC-2: no_expiry service 한정 명시 ✅
REQ-021 AC-3: 강제 회전 없음 명시 ✅
REQ-024 AC-1: grep -n "env admin token 운영 SOP" docs/runtime-flows.md → line 326 ✅
REQ-024 AC-2: grep -n "다단계 회전 절차" docs/runtime-flows.md → line 345 ✅
REQ-024 AC-3: grep -n "rollback" docs/runtime-flows.md → line 364 ✅
REQ-024 AC-4: grep -n "48-byte" docs/runtime-flows.md → line 349, 371 ✅
```
- Logs: 없음 (docs-only)
## Result
- Ready for Release: true
- Blocking Issues: 없음
- 미완료 AC: REQ-024 AC-5 (staging dry-run) — release plan verification 영역, release plan에 포함 예정
@@ -0,0 +1,78 @@
# BAT-009 Design
- Status: confirmed
- Batch: BAT-009
## Architecture Summary
- 기존 `app/api/v1_portfolios.py`(REQ-005 tag apply/remove 라우터)에 POST/PATCH/DELETE/GET 4개 라우트를 추가한다. 파일은 이미 존재하며 `router = APIRouter(prefix="/v1/portfolios")` prefix를 공유한다.
- 인가 흐름: (1) 라우터 레이어에서 `_require_write_token(auth)` — `token_type ∈ {service, client}` → HTTP 403, (2) admin/user 분기 후 ownership/catalog/disabled 검증은 라우터 내부에서 직접 처리.
- 조회(GET)는 `repo.list_portfolios_with_strategies()`를 재사용 — 이미 `market`, `include_disabled`, `managed_by_daemon_id` 필터를 모두 지원함.
## Changed Areas
- Docs: 없음 (REQ-032/BAT-CANDIDATE-2에서 처리)
- Modules:
- `app/api/v1_portfolios.py` — POST/PATCH/DELETE/GET 라우트 추가
- `app/schemas/v1.py` — `PortfolioCreateRequest`, `PortfolioUpdateRequest`, `PortfolioItem`, `PortfolioListResponse` 추가
- `app/repositories/portfolio_repository.py` — `soft_delete_portfolio(portfolio_id: str) -> Portfolio` 추상 메서드 추가
- `app/repositories/postgres.py` — `soft_delete_portfolio` 구현 추가
- `tests/test_v1_portfolios_crud.py` (신규) — REQ-025/026/027 AC 검증
- `tests/test_v1_portfolios_list.py` (신규) — REQ-028 AC 검증
- Interfaces:
- `POST /v1/portfolios` (신규)
- `PATCH /v1/portfolios/{portfolio_id}` (신규)
- `DELETE /v1/portfolios/{portfolio_id}` (신규)
- `GET /v1/portfolios` (신규)
## Key Decisions
1. **`_require_write_token(auth)` 위치**: `v1_portfolios.py` 모듈 내 private 함수. `auth.token_type in ("service", "client")` → HTTP 403. env 토큰(token_type=None)은 통과.
2. **`_get_portfolio_or_404(auth, portfolio_id, repo)` 헬퍼**: repo.get() 후 404 처리 + admin/non-admin 분기 ownership check. 모든 쓰기 라우트(PATCH/DELETE)에서 재사용.
3. **catalog 검증 위치**: 라우터 레이어에서 직접 `repo.get_asset_by_id(body.asset_id)` 호출. `asset is None or asset.status != "active"` → HTTP 422. (서비스 계층의 `create_portfolio`는 `CreatePortfolioRequest` 스키마에 의존하므로 직접 호출 대신 라우터에서 처리)
4. **전략 세트 처리**: POST /v1/portfolios에서 `repo.get_or_create_strategy_set(name, config_json)` 직접 호출. 기존 v1_assets.py 패턴과 동일하게 repo를 직접 사용.
5. **`soft_delete_portfolio` 신규 추가**: `update_portfolio()` repo 메서드는 `None` 값을 skip하므로 `managed_by_daemon_id=NULL` 설정이 불가능. 별도 메서드 추가:
- `portfolio_repository.py`: `soft_delete_portfolio(portfolio_id: str) -> Portfolio` 프로토콜 선언
- `postgres.py`: `UPDATE portfolios SET status='disabled', managed_by_daemon_id=NULL, updated_at=NOW() WHERE portfolio_id=%s`
6. **disabled portfolio PATCH 보호**: 라우터에서 portfolio 조회 후 `portfolio.status == 'disabled'`이고 body에 `status` 외 필드가 있으면 HTTP 409. `status → 'enabled'` 전이만 허용.
7. **GET /v1/portfolios 토큰 분기**:
- `token_type='client'` → HTTP 403
- `token_type='service'` + `auth.daemon_id is None` → HTTP 403
- `token_type='service'` + daemon_id 있음 → `list_portfolios_with_strategies(owner_user_id=None, managed_by_daemon_id=auth.daemon_id, ...)`에 해당하는 `list_portfolios(managed_by_daemon_id=auth.daemon_id, ...)`
- session/env + admin → `list_portfolios_with_strategies(owner_user_id=user_id_param or auth.user_id, ...)`
- session/env + read/trade-write → `list_portfolios_with_strategies(owner_user_id=auth.user_id, ...)`
실제로는 `list_portfolios(owner_user_id=..., managed_by_daemon_id=..., market=..., status=...)` 직접 호출이 더 명확함. `list_portfolios_with_strategies`는 `list_portfolios`를 위임 호출하므로 equivalent.
8. **`include_disabled` 처리**: 기본 `False` → `status='enabled'` 필터 적용. `True` → status 필터 없음(None 전달).
9. **포트폴리오 이름 기본값**: POST 시 `body.name`이 None이면 `f"{body.broker}-{body.asset_id[:8]}"` 사용.
10. **응답 HTTP 코드**: POST → 201, PATCH → 200, DELETE → 200(disabled portfolio 반환), GET → 200.
11. **비존재 portfolio_id 처리**: `repo.get()` ValueError → HTTP 404.
12. **이미 disabled 상태 DELETE 재호출**: 멱등 처리 — `soft_delete_portfolio` 실행 후 disabled 상태 반환 (HTTP 200).
## Edge Cases
- `token_type='service'` + `daemon_id IS None` → GET에서 HTTP 403 (REQ-028 AC-2).
- non-admin token + body `user_id` 지정 → POST에서 `user_id` 무시, `auth.user_id`로 생성 (REQ-025 AC-7). auth.user_id가 None인 env token의 경우 "system" 폴백.
- `asset_id` inactive → HTTP 422 (REQ-025 AC-5).
- disabled portfolio + non-status PATCH body → HTTP 409 (REQ-026 AC-8).
- disabled portfolio + `status="enabled"` PATCH → HTTP 200 + 복구 (REQ-026 AC-9 / REQ-027 AC-9).
- 비존재 portfolio_id → HTTP 404 (모든 라우트).
- Bearer token 없음 → HTTP 401 (`require_read` Depends가 처리).
- env token (token_type=None) → `_require_write_token` 통과, auth.user_id=None이므로 body.user_id 제공 권장.
## Architecture Impact
- runtime-flows: yes (v1 portfolio CRUD 흐름 신규 — REQ-032/BAT-CANDIDATE-2에서 baseline 반영)
- project-structure: yes (v1_portfolios.py 라우트 확장, schemas/v1.py 스키마 추가, repos 메서드 추가 — REQ-032에서 반영)
## Reference Doc Update Plan
- project-structure.md: REQ-032(BAT-CANDIDATE-2)에서 처리
- runtime-flows.md: REQ-032(BAT-CANDIDATE-2)에서 처리
- interface-contract.md: REQ-032(BAT-CANDIDATE-2)에서 처리
@@ -0,0 +1,41 @@
# BAT-009 Implementation
- Status: done
- Batch: BAT-009
## Plan Summary
- `app/schemas/v1.py`에 v1 portfolio CRUD 스키마 4종 추가
- `app/repositories/portfolio_repository.py`에 `soft_delete_portfolio` 추상 메서드 추가
- `app/repositories/postgres.py`에 `soft_delete_portfolio` 구현 추가
- `app/api/v1_portfolios.py`에 POST/PATCH/DELETE/GET 라우트 추가
- `tests/test_v1_portfolios_crud.py` 신규 — REQ-025/026/027 AC 검증
- `tests/test_v1_portfolios_list.py` 신규 — REQ-028 AC 검증
## Changed Files
1. `app/schemas/v1.py` — `PortfolioCreateRequest`, `PortfolioUpdateRequest`, `PortfolioItem`, `PortfolioListResponse` 추가
2. `app/repositories/portfolio_repository.py` — `soft_delete_portfolio(portfolio_id: str) -> Portfolio` 추상 메서드
3. `app/repositories/postgres.py` — `soft_delete_portfolio` 구현
4. `app/api/v1_portfolios.py` — POST/PATCH/DELETE/GET 라우트 + helpers
5. `tests/test_v1_portfolios_crud.py` (신규)
6. `tests/test_v1_portfolios_list.py` (신규)
## Execution Log
1. schemas/v1.py 스키마 추가
2. portfolio_repository.py 프로토콜 추가
3. postgres.py soft_delete_portfolio 구현
4. v1_portfolios.py 라우트 추가
5. test_v1_portfolios_crud.py 신규 작성
6. test_v1_portfolios_list.py 신규 작성
7. pytest 실행
## Validation
- 전체 테스트 스위트 298개 통과 (2026-05-21)
- `tests/test_v1_portfolios_crud.py`: 22개 — REQ-025/026/027 전 AC 커버
- `tests/test_v1_portfolios_list.py`: 10개 — REQ-028 전 AC 커버
- 기존 테스트 276개 모두 회귀 없음
- 테스트 패턴 수정: `_get_repo()` 가 `get_portfolio_repository_from_settings`를 직접 호출하므로
`unittest.mock.patch("app.api.v1_portfolios.get_portfolio_repository_from_settings")` +
DB 토큰 테스트는 `app.core.auth.get_portfolio_repository_from_settings` 추가 패치 적용
## Remaining Risks
- 없음 — 전 AC 테스트 통과 완료
@@ -0,0 +1,36 @@
# BAT-009: v1 portfolio CRUD core (POST/PATCH/DELETE/GET)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-003
- Included REQ: REQ-025, REQ-026, REQ-027, REQ-028
- Excluded REQ: REQ-029(ledger path migration — trex-basic 동시 배포 제약으로 별도 batch), REQ-030/031(sub-resources — CRUD infra 선행 후 처리), REQ-032(docs — 코드 완성 후 처리)
## Summary
- v1 API에 portfolio CRUD 4종 엔드포인트를 신설한다: `POST /v1/portfolios`(생성 + catalog 검증, REQ-025), `PATCH /v1/portfolios/{id}`(수정 + ownership, REQ-026), `DELETE /v1/portfolios/{id}`(soft delete, REQ-027), `GET /v1/portfolios`(토큰 분기 조회, REQ-028).
- `app/api/v1_portfolios.py` 신규 라우터와 `_require_portfolio_access` 인가 헬퍼를 생성해 후속 REQ-029~031이 재사용할 인증 인프라를 확립한다.
## Scope
- In Scope:
- `app/api/v1_portfolios.py` (신규) — POST/PATCH/DELETE/GET 라우트, `_require_portfolio_access` 헬퍼
- `app/main.py` — v1_portfolios_router include
- `app/schemas/v1.py` — `PortfolioCreateRequest`, `PortfolioUpdateRequest`, `PortfolioItem`, `PortfolioListResponse` 신규
- `app/services/portfolio_service.py` — `create_portfolio`(catalog 검증 통합), `update_portfolio`(disabled 보호), `delete_portfolio`(soft delete), `list_portfolios` 신규 또는 갱신
- `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `get_asset_by_id(only_active)`, `set_portfolio_status`, `list_portfolios`(market/include_disabled/daemon filter) 추가
- `tests/test_v1_portfolios_crud.py` (신규) — REQ-025/026/027 AC 검증
- `tests/test_v1_portfolios_list.py` (신규) — REQ-028 AC 검증
- Out of Scope:
- 구 path `POST /v1/assets/{asset_id}/ledger/trades` 제거 (→ REQ-029/BAT-CANDIDATE-2)
- `/v1/portfolios/{id}/tags`, `/v1/portfolios/{id}/comments` (→ REQ-030/031/BAT-CANDIDATE-2)
- baseline 문서 갱신 (→ REQ-032/BAT-CANDIDATE-2)
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- R-1 (Medium): GET /v1/portfolios에서 `?market` 필터는 `assets.markets` 배열 조인이 필요해 기존 `list_portfolios_with_strategies` 쿼리 확장 또는 신규 쿼리 작성이 필요하다. design 단계에서 결정.
- R-2 (Low): disabled portfolio PATCH 보호(AC-8/9)는 REQ-026과 REQ-027의 정책이 맞물리므로 두 REQ를 동일 batch에 구현하는 것이 정합 검증에 유리하다.
- 선행 완료: BAT-001(assets table, REQ-009), BAT-003(portfolios asset_id FK, REQ-011), BAT-006(client token 폐지, REQ-016) — catalog/portfolio 기반 스키마 + 토큰 분기 정책 확정.
@@ -0,0 +1,49 @@
# BAT-009 Planning
- Status: confirmed
- Batch: BAT-009
- Profile: standard
## Included REQ
- REQ-025: POST /v1/portfolios 신설 (토큰 분기 + catalog 검증)
- REQ-026: PATCH /v1/portfolios/{portfolio_id} 신설
- REQ-027: DELETE /v1/portfolios/{portfolio_id} 신설 (soft delete)
- REQ-028: GET /v1/portfolios 신설 (토큰 분기 조회)
## Delivery Plan
1. `app/api/v1_portfolios.py` 신규 파일 생성 — `_require_portfolio_access` 인가 헬퍼 + POST 라우트(REQ-025)
2. PATCH 라우트 추가 — ownership 검증 + disabled 보호(REQ-026)
3. DELETE 라우트 추가 — soft delete + daemon 해제(REQ-027)
4. GET 라우트 추가 — 토큰 분기 조회 + `?market`/`?include_disabled`/`?user_id` 파라미터(REQ-028)
5. `app/main.py` — v1_portfolios_router include, 기존 `v1_user_info`/`v1_assets` 라우터 include와 정합 확인
6. `app/schemas/v1.py` — `PortfolioCreateRequest`, `PortfolioUpdateRequest`, `PortfolioItem`, `PortfolioListResponse` 추가
7. `app/services/portfolio_service.py` — catalog 검증 통합, disabled 보호, soft delete, list 분기 추가/갱신
8. `app/repositories/postgres.py` — `get_asset_by_id(asset_id, only_active)`, `set_portfolio_status`, `list_portfolios`(필터 확장) 추가
9. `tests/test_v1_portfolios_crud.py` 신규 — REQ-025 AC-1~7, REQ-026 AC-1~9, REQ-027 AC-1~10
10. `tests/test_v1_portfolios_list.py` 신규 — REQ-028 AC-1~11
## Dependencies
- Internal:
- BAT-001 (release-candidate): `assets` 테이블 스키마(REQ-009) — catalog 검증의 `assets.asset_id` + `status='active'` 필드 선행 필요 ✅
- BAT-003 (release-candidate): `portfolios.asset_id` FK(REQ-011) — portfolio 생성 시 asset_id 외래키 선행 필요 ✅
- BAT-006 (release-candidate): client token 폐지(REQ-016) — service/client 403 fail-closed 분기 정책 확정 ✅
- BAT-008 (release-candidate): 토큰 발급 권한 매트릭스(REQ-020) — token_type 분기 정책 baseline 확인 ✅
- External:
- 없음
## Design Gate
- Design Required Before Implementation: yes
- Reason: 신규 라우터 모듈(`v1_portfolios.py`) 구조 설계, `_require_portfolio_access` 인가 헬퍼 API, catalog 검증 위치(라우터 vs 서비스 계층), GET `/v1/portfolios` 쿼리 전략(`?market` 배열 필터, `?include_disabled`)이 REQ-025~028 4개 구현 전에 결정되어야 함.
## Milestones
1. design.md confirmed — 라우터 구조, 헬퍼 API, catalog 검증 위치, GET 쿼리 전략 확정
2. `v1_portfolios.py` 신규 + POST 라우트(REQ-025) 구현 완료
3. PATCH/DELETE 라우트(REQ-026/027) 구현 + disabled 보호 정합 확인
4. GET 라우트(REQ-028) 구현 + 토큰 분기 + 필터 파라미터
5. 테스트 37+개 AC 전부 pass
## Risks
- R-1 (Resolved ✅): GET `?market` 필터 — `list_portfolios`에 `%s = ANY(a.markets)` 조건이 이미 구현되어 있음. `list_portfolios_with_strategies`가 이를 위임 호출하므로 신규 쿼리 불필요.
- R-2 (Low): disabled portfolio PATCH/DELETE/ledger write 차단 정책이 REQ-026/027/029 3개 REQ에 걸쳐 있음. 본 batch에서 PATCH/DELETE 차단까지만 구현하고 ledger 차단은 REQ-029 batch로 이월.
- R-3 (Resolved ✅): `list_portfolios_with_strategies(owner_user_id, *, include_disabled, managed_by_daemon_id, market)` 가 이미 필요한 모든 필터를 지원하므로 신규 메서드 불필요.
- R-4 (Low): `update_portfolio` repo 메서드가 `None` 값을 skip하므로 `managed_by_daemon_id=NULL` 설정이 불가능. soft delete 시 `managed_by_daemon_id`를 NULL로 클리어하는 전용 `soft_delete_portfolio` 메서드 추가 필요.
@@ -0,0 +1,58 @@
# BAT-009 Verification
- Status: verified
- Batch: BAT-009
## Acceptance Mapping
### REQ-025: POST /v1/portfolios
- AC-1 (admin + user_id → target owner): `test_post_admin_creates_for_target_user` PASSED — `owner_user_id == _OTHER_USER_ID`, status=enabled
- AC-2 (env token → own user / fallback 'system'): `test_post_user_creates_for_self` PASSED — status=enabled
- AC-3 (service token → 403): `test_post_service_token_returns_403` PASSED
- AC-5 (inactive/missing asset → 422): `test_post_inactive_asset_returns_422`, `test_post_missing_asset_returns_422` PASSED
- AC-6 (no token → 401): `test_post_no_bearer_returns_401` PASSED
- AC-7 (non-admin body user_id ignored): `test_post_non_admin_user_id_ignored` PASSED — owner != _OTHER_USER_ID
### REQ-026: PATCH /v1/portfolios/{portfolio_id}
- AC-1 (admin → any portfolio): `test_patch_admin_updates_any_portfolio` PASSED — broker updated
- AC-2 (session token + own portfolio → 200): `test_patch_user_updates_own_portfolio` PASSED
- AC-3 (session token + other portfolio → 403): `test_patch_user_other_portfolio_returns_403` PASSED
- AC-4 (service token → 403): `test_patch_service_token_returns_403` PASSED
- AC-6 (no token → 401): `test_patch_no_bearer_returns_401` PASSED
- AC-7 (non-existent → 404): `test_patch_nonexistent_portfolio_returns_404` PASSED
- AC-8 (disabled + non-status field → 409): `test_patch_disabled_portfolio_non_status_returns_409` PASSED
- AC-9 (disabled + status=enabled → 200): `test_patch_disabled_portfolio_status_restore_returns_200` PASSED
### REQ-027: DELETE /v1/portfolios/{portfolio_id}
- AC-1 (session + own → 200, status=disabled, daemon_id=NULL): `test_delete_user_own_active_portfolio_soft_deletes` PASSED
- AC-3 (admin + other user → 200): `test_delete_admin_other_user_portfolio` PASSED
- AC-4 (session + other → 403): `test_delete_user_other_portfolio_returns_403` PASSED
- AC-5 (service token → 403): `test_delete_service_token_returns_403` PASSED
- AC-7 (no token → 401): `test_delete_no_bearer_returns_401` PASSED
- AC-8 (non-existent → 404): `test_delete_nonexistent_portfolio_returns_404` PASSED
- idempotent (already disabled → 200): `test_delete_already_disabled_is_idempotent` PASSED
### REQ-028: GET /v1/portfolios
- AC-1 (service + daemon_id → own daemon portfolios): `test_get_service_token_daemon_bound_returns_own_portfolios` PASSED — total=1
- AC-2 (service no daemon → 403): `test_get_service_token_no_daemon_returns_403` PASSED
- AC-3 (session → own portfolios): `test_get_user_token_returns_own_portfolios` PASSED — total=1, owner filtered
- AC-4 (admin no ?user_id → env user_id=None → no filter → all): `test_get_admin_default_returns_own_portfolios` PASSED — total=2
- AC-5 (admin + ?user_id → target): `test_get_admin_with_user_id_returns_target_portfolios` PASSED — total=1
- AC-7 (no include_disabled → disabled excluded): `test_get_excludes_disabled_by_default` PASSED — total=1, status=enabled
- AC-8 (?include_disabled=true → all): `test_get_include_disabled_returns_all` PASSED — total=2
- AC-9 (client token → 403): `test_get_client_token_returns_403` PASSED
- AC-10 (no token → 401): `test_get_no_bearer_returns_401` PASSED
- AC-11 (asset_id in response): `test_get_response_has_asset_id_field` PASSED — asset_id == _ASSET_UUID
## Evidence
- Test:
- `tests/test_v1_portfolios_crud.py`: 22 PASSED (REQ-025/026/027)
- `tests/test_v1_portfolios_list.py`: 10 PASSED (REQ-028)
- 전체 스위트: 298 PASSED, 0 FAILED (2026-05-21)
- Manual: 없음 (단위 테스트로 전 AC 커버)
- Logs: 없음
## Result
- Ready for Release: true
- Blocking Issues: 없음
@@ -0,0 +1,50 @@
# BAT-010 Design
- Status: confirmed
- Batch: BAT-010
## Architecture Summary
- `app/api/v1_portfolios.py`에 portfolio 서브 리소스 라우트 7개(GET tags, POST/GET/PATCH/DELETE comments)를 추가하고 기존 POST/DELETE tag 라우트에 disabled 보호를 보강한다.
- 신규 v1 schema 5종(`PortfolioTagListResponse`, `PortfolioCommentItem`, `PortfolioCommentRequest`, `PortfolioCommentUpdateRequest`, `PortfolioCommentListResponse`)을 `app/schemas/v1.py`에 추가한다.
- `docs/` baseline 4종을 v1 portfolio surface에 맞게 갱신한다.
## Changed Areas
- Docs:
- `docs/interface-contract.md` — v1 portfolio CRUD + tag/comment 서브 라우트 섹션 추가; ledger path "pending REQ-029" 마킹
- `docs/runtime-flows.md` — portfolio mutation flow + GET 토큰 분기 + soft delete flow 추가
- `docs/data-model.md` — Portfolio.status='disabled' 의미 + 의존 데이터 보존 정책 추가
- `docs/project-structure.md` — app/api/v1_portfolios.py, app/schemas/v1.py 책임 기술 갱신
- Modules:
- `app/api/v1_portfolios.py` — 7개 라우트 추가 + 기존 2개 보강
- `app/schemas/v1.py` — 5개 schema 추가
- Interfaces:
- `GET /v1/portfolios/{portfolio_id}/tags` (신설)
- `POST /v1/portfolios/{portfolio_id}/comments` (신설)
- `GET /v1/portfolios/{portfolio_id}/comments` (신설)
- `PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}` (신설)
- `DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}` (신설)
- `POST /v1/portfolios/{portfolio_id}/tags` disabled 보호 보강 (기존 route)
- `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}` disabled 보호 보강 (기존 route)
## Key Decisions
1. **GET tags 응답 schema**: `PortfolioTagListResponse(tags: list[TagItem], total: int)` — 기존 `TagItem` 재사용. `portfolio.tag_ids` → `repo.get_tags_by_ids(tag_ids)` → dict → TagItem list.
2. **Disabled 보호 위치**: ownership 검증 직후, mutation 수행 직전. `if portfolio.status == "disabled": raise HTTPException(409, "disabled portfolio")`. GET은 차단 없음.
3. **Comment 작성자 ownership**: portfolio ownership만 적용 (REQ-031 Notes 결정). `_get_portfolio_or_404` 헬퍼로 일괄 처리. PATCH/DELETE 시 comment 작성자 ID 별도 검증 없음 — portfolio owner이면 모든 comment 수정/삭제 가능.
4. **Comment schema**: v1 전용 클래스 신설. `PortfolioCommentItem.comment_id` = `PortfolioComment.id`(domain) 매핑.
5. **Comment PATCH/DELETE 404**: `repo.update_comment` / `repo.delete_comment`가 `ValueError("댓글을 찾을 수 없습니다")` 발생 시 HTTP 404로 변환.
6. **Auth guard**: 기존 tag 라우트는 `require_trade_write` + `token_source != "db"` 패턴 유지. 신규 comment 라우트는 `require_read` + `_require_write_token(auth)` (write) / inline service·client 차단 (GET) 패턴 적용.
7. **Import 추가**: `PortfolioComment` domain model import (v1_portfolios.py), schema 5종 import.
## Edge Cases
- `portfolio.tag_ids = []` → `get_tags_by_ids([])` → `{}` → `PortfolioTagListResponse(tags=[], total=0)` 정상
- comment 404: `portfolio_id`가 존재하지만 `comment_id`가 해당 portfolio의 comment가 아닌 경우 → repo `ValueError` → HTTP 404
- disabled portfolio + GET comment: 허용 (AC-8). disabled portfolio + GET tags: 허용 (별도 제한 없음)
- `_get_portfolio_or_404`가 404를 발생시키는 경우(비존재 portfolio): service/client 차단 체크 이전에 발생 가능 → 기존 404 응답이 먼저 반환됨 (AC-11 충족)
## Architecture Impact
- both (project-structure + runtime-flows 갱신)
## Reference Doc Update Plan
- project-structure.md: app/api/v1_portfolios.py에 신규 라우트 기술 추가; app/schemas/v1.py schema 목록 갱신
- runtime-flows.md: portfolio mutation flow (ownership + disabled 보호), GET /v1/portfolios 토큰 분기 flow, soft delete flow (status='disabled' + dependent data 보존) 추가
@@ -0,0 +1,33 @@
# BAT-010 Implementation
- Status: done
- Batch: BAT-010
## Plan Summary
- REQ-030: `apply_portfolio_tag` + `remove_portfolio_tag`에 `_require_write_token` + disabled 409 체크 추가; `get_portfolio_tags` 신설. `PortfolioTagListResponse` 스키마 추가.
- REQ-031: comment CRUD 4종 라우트 신설 (`create_portfolio_comment`, `list_portfolio_comments`, `update_portfolio_comment`, `delete_portfolio_comment`). v1 comment schema 5종 추가.
- REQ-032: baseline 4종 문서 갱신.
## Changed Files
- `app/schemas/v1.py` — 5개 schema 추가: `PortfolioTagListResponse`, `PortfolioCommentItem`, `PortfolioCommentRequest`, `PortfolioCommentUpdateRequest`, `PortfolioCommentListResponse`
- `app/api/v1_portfolios.py` — import 추가(`PortfolioComment`, 5 schemas, `TagItem`); `_comment_to_item` helper 추가; `apply_portfolio_tag`/`remove_portfolio_tag`에 `_require_write_token` + disabled 409 보강; `get_portfolio_tags` 신설; `create_portfolio_comment`, `list_portfolio_comments`, `update_portfolio_comment`, `delete_portfolio_comment` 4종 신설
- `tests/test_v1_portfolios_tags_comments.py` — 신규 (32 tests; REQ-030 AC-1~11 + REQ-031 AC-1~11 커버)
- `docs/interface-contract.md` — v1 Portfolio API 섹션 신규 추가; tag/comment 서브 라우트 명세 추가; ledger path "pending REQ-029" 마킹
- `docs/runtime-flows.md` — portfolio mutation flow, GET /v1/portfolios 분기 flow, soft delete flow, GET tags flow, comment CRUD flow 추가
- `docs/data-model.md` — Portfolio.status='disabled' 의미 + 의존 데이터 보존 정책 추가
- `docs/project-structure.md` — v1_portfolios.py 책임 갱신 + v1.py schema 목록 갱신
## Execution Log
1. `app/schemas/v1.py` — 5 schema 추가 (PortfolioTagListResponse 외)
2. `app/api/v1_portfolios.py` — import 갱신, `_comment_to_item` 추가, 기존 tag 라우트 보강, 신규 7개 라우트 추가
3. `tests/test_v1_portfolios_tags_comments.py` — 32 test 신규 작성
4. `.venv/bin/python -m pytest tests/test_v1_portfolios_tags_comments.py` → 32/32 PASSED
5. `.venv/bin/python -m pytest` (전체) → 330/330 PASSED (회귀 없음)
6. `docs/` 4종 갱신 (interface-contract, runtime-flows, data-model, project-structure)
## Validation
- `tests/test_v1_portfolios_tags_comments.py`: 32 tests PASSED
- Full suite: 330 tests PASSED, 2 warnings (deprecation, 무관)
## Remaining Risks
- R-3 (Low): `portfolio_comments` 테이블의 실 DB 존재 여부는 통합 테스트(실 DB)에서만 확인 가능. 단위 테스트는 MagicMock으로 커버됨.
@@ -0,0 +1,33 @@
# BAT-010: v1 portfolio sub-resources (tags/comments + baseline docs)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-003
- Included REQ: REQ-030, REQ-031, REQ-032
- Excluded REQ: REQ-029(ledger path migration — trex-basic 동시 배포 제약으로 별도 batch)
## Summary
- v1 portfolio 서브 리소스 라우트를 완성한다: `/v1/portfolios/{id}/tags`(GET 신설 + disabled 보호, REQ-030), `/v1/portfolios/{id}/comments`(CRUD 4종 신설, REQ-031).
- REQ-025~031 완료 후 baseline 문서(`interface-contract.md`, `runtime-flows.md`, `data-model.md`, `project-structure.md`) 정합성을 일괄 회복한다(REQ-032).
## Scope
- In Scope:
- `app/api/v1_portfolios.py` — `GET /v1/portfolios/{id}/tags` 신설, existing POST/DELETE tag routes disabled 보호 보강, comment CRUD 4종 서브라우트 신설
- `app/schemas/v1.py` — `PortfolioTagListResponse`, `PortfolioCommentRequest`, `PortfolioCommentUpdateRequest`, `PortfolioCommentItem`, `PortfolioCommentListResponse` 신규 (또는 기존 schema 재사용 확정)
- `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md`, `docs/project-structure.md` — v1 portfolio surface + soft delete 정책 baseline 반영
- `tests/test_v1_portfolios_tags_comments.py` (신규) — REQ-030/031 AC 검증
- Out of Scope:
- `POST /v1/portfolios/{id}/ledger/trades` path 이전 (→ REQ-029, 별도 batch)
- interface-contract.md 구 path `/v1/assets/{id}/ledger/trades` 제거 명세 (→ REQ-029 범위, 본 batch에서는 pending 마킹만)
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- R-1 (Low): REQ-030의 POST/DELETE `/v1/portfolios/{id}/tags` 라우트는 BAT-004(REQ-005) 구현 시 `app/api/v1_portfolios.py`에 이미 존재. disabled portfolio 보호(AC-8) 누락 여부를 design 단계에서 감사 후 보강.
- R-2 (Low): REQ-031 comment 작성자 ownership 정책 — dcy-003 본문은 portfolio ownership을 기준으로 명시. comment 작성자 ownership(본인 comment만 수정/삭제 가능) 적용 여부는 design 단계에서 확정.
- R-3 (Low): REQ-032 interface-contract.md의 ledger path 관련 갱신은 REQ-029 미완료로 해당 섹션을 "pending REQ-029" 마킹 후 defer.
- 선행 완료: BAT-009(REQ-025~028 Implemented) — v1 portfolio CRUD 기반 인가/ownership 패턴 확립.
@@ -0,0 +1,76 @@
# BAT-010 Planning
- Status: confirmed
- Batch: BAT-010
- Profile: standard
## Included REQ
- REQ-030: portfolio tag 적용/해제/조회 v1 라우트 (Interface, Medium)
- REQ-031: portfolio comment CRUD v1 라우트 (Interface, Medium)
- REQ-032: portfolio v1 baseline 문서 갱신 (Documentation, Medium)
## Delivery Plan
### 구현 범위 및 순서
1. **REQ-030 기존 라우트 보강 + GET 신설**
- 기존 `POST /v1/portfolios/{id}/tags`, `DELETE /v1/portfolios/{id}/tags/{tag_id}` 라우트 감사 결과:
- ownership 검증 ✓, tag 정책 검증 ✓ (POST만)
- disabled portfolio 보호 **누락** → AC-8 충족을 위해 추가 필요
- `GET /v1/portfolios/{id}/tags` 신설: `portfolio.tag_ids` → `repo.get_tags_by_ids()` → `PortfolioTagListResponse`
- schema 추가: `PortfolioTagListResponse(tags: list[TagItem], total: int)`
2. **REQ-031 comment CRUD 구현**
- 신규 4개 라우트: `POST`, `GET`, `PATCH`, `DELETE /v1/portfolios/{id}/comments[/{comment_id}]`
- 기존 repo 메서드 재사용: `create_comment`, `list_comments`, `update_comment`, `delete_comment`
- schema 추가 (app/schemas/v1.py): `PortfolioCommentItem`, `PortfolioCommentRequest`, `PortfolioCommentUpdateRequest`, `PortfolioCommentListResponse`
- ownership 검증: `_get_portfolio_or_404` 헬퍼 재사용 (BAT-009 패턴)
- disabled 보호: POST/PATCH/DELETE → HTTP 409; GET → 허용
- comment 작성자 ownership: portfolio ownership만 적용 (REQ-031 Notes 결정)
3. **테스트 작성**
- `tests/test_v1_portfolios_tags_comments.py` 신규
- REQ-030 AC-1~11, REQ-031 AC-1~11 커버
- BAT-009의 `_with_repo` 패치 패턴 재사용
4. **REQ-032 baseline 문서 갱신**
- `docs/interface-contract.md`: v1 portfolio CRUD 엔드포인트 섹션 추가 (REQ-025~028), GET /v1/portfolios/{id}/tags 엔드포인트 추가, comment CRUD 엔드포인트 추가; POST /v1/assets/{asset_id}/ledger/trades 섹션 "pending REQ-029" 마킹
- `docs/runtime-flows.md`: portfolio mutation flow + GET /v1/portfolios 토큰 분기 + soft delete flow 추가
- `docs/data-model.md`: Portfolio.status='disabled' 의미 + 의존 데이터 보존 정책 추가
- `docs/project-structure.md`: app/api/v1_portfolios.py 책임 갱신 + app/schemas/v1.py schema 목록 갱신
### 선행 조건
- BAT-009 Implemented (REQ-025~028) ✓ — `_get_portfolio_or_404`, `_require_write_token`, `_close_repo` 헬퍼 재사용
- BAT-003 release-candidate (REQ-001/003): `portfolios.tag_ids UUID[]`, `repo.append_portfolio_tag_id/remove_portfolio_tag_id/get_tags_by_ids` ✓
- BAT-004 release-candidate (REQ-004/005): POST/DELETE /v1/portfolios/{id}/tags 이미 구현 ✓
- `app/repositories/portfolio_repository.py`: `create_comment`, `list_comments`, `update_comment`, `delete_comment` 추상 메서드 이미 정의 ✓
- `app/domain/models.py`: `PortfolioComment(id, portfolio_id, content, created_at, updated_at)` ✓
## Dependencies
- Internal:
- BAT-009(REQ-025~028): `_get_portfolio_or_404`, `_require_write_token` 헬퍼, `PortfolioItem` 스키마 패턴
- BAT-003(REQ-001~003): `portfolios.tag_ids`, `repo.get_tags_by_ids`
- BAT-004(REQ-005): POST/DELETE tag 라우트 (보강 대상)
- External:
- 없음
## Design Gate
- Design Required Before Implementation: yes
- Reason:
- REQ-031 comment 작성자 ownership 정책 확정: portfolio ownership만 적용 (결정 완료)
- REQ-030 GET 응답 schema 확정: `PortfolioTagListResponse(tags: list[TagItem], total: int)` (결정 완료)
- REQ-032 ledger path 마킹 방식 확정: "pending REQ-029" 주석 처리 (결정 완료)
- 모든 설계 gate가 사전 해소됨 → design.md 확정 후 구현 착수 가능
## Milestones
1. planning.md confirmed ✓ (현재 단계)
2. design.md confirmed — key decisions 기록
3. REQ-030 disabled 보호 보강 + GET 신설 + tests
4. REQ-031 comment CRUD 4종 + tests
5. REQ-032 baseline docs 4종 갱신
6. 전체 테스트 스위트 통과
7. verification.md 완성
## Risks
- R-1 (Low): 기존 POST/DELETE tag 라우트에 disabled 보호 추가 시 `test_tags_http_api.py` 회귀 가능 → 기존 테스트가 disabled portfolio를 사용하지 않으면 영향 없음; 확인 후 보강
- R-2 (Low): `repo.get_tags_by_ids(portfolio.tag_ids)` — tag_ids가 빈 배열일 경우 빈 dict 반환 → 빈 list 응답으로 정상 처리
- R-3 (Low): `update_comment`/`delete_comment` postgres 구현에서 `portfolio_comments` 테이블 존재 여부 확인 필요
@@ -0,0 +1,55 @@
# BAT-010 Verification
- Status: verified
- Batch: BAT-010
## Acceptance Mapping
### REQ-030 (portfolio tag routes)
- AC-1 (admin tag → POST 200): `test_post_tag_admin_tag_200` PASSED — `repo.append_portfolio_tag_id` called
- AC-2 (own tag → POST 200): `test_post_tag_own_tag_200` PASSED
- AC-3 (타 user non-admin tag → 403): `test_post_tag_other_user_non_admin_tag_403` PASSED
- AC-4 (inactive tag → 403): `test_post_tag_inactive_tag_403` PASSED
- AC-5 (admin + other portfolio + admin tag → 200): `test_post_tag_admin_other_portfolio_admin_tag_200` PASSED
- AC-6 (other portfolio → 403): `test_post_tag_other_portfolio_403` PASSED
- AC-7 (service/client → 403): `test_post_tag_service_token_403`, `test_post_tag_client_token_403` PASSED
- AC-8 (disabled + POST/DELETE → 409): `test_post_tag_disabled_portfolio_409`, `test_delete_tag_disabled_portfolio_409` PASSED
- AC-9 (DELETE own portfolio → 200): `test_delete_tag_own_portfolio_200` PASSED — `repo.remove_portfolio_tag_id` called
- AC-10 (GET tags → 200 + list): `test_get_portfolio_tags_200`, `test_get_portfolio_tags_empty_200` PASSED
- AC-11 (nonexistent portfolio → 404): `test_get_tags_nonexistent_portfolio_404` PASSED
### REQ-031 (portfolio comment routes)
- AC-1 (POST → 201): `test_post_comment_201` PASSED — comment_id, portfolio_id, content verified
- AC-2 (GET → 200 + list): `test_get_comments_200` PASSED — total=2 verified
- AC-3 (PATCH → 200 + updated): `test_patch_comment_200` PASSED — `repo.update_comment` called with correct args
- AC-4 (DELETE → 204): `test_delete_comment_204` PASSED — `repo.delete_comment` called
- AC-5 (other portfolio → 403): `test_post_comment_other_portfolio_403` PASSED
- AC-6 (service/client → 403): `test_post_comment_service_token_403`, `test_post_comment_client_token_403`, `test_get_comments_service_token_403` PASSED
- AC-7 (disabled + POST/PATCH/DELETE → 409): `test_post_comment_disabled_409`, `test_patch_comment_disabled_409`, `test_delete_comment_disabled_409` PASSED
- AC-8 (disabled + GET → 200): `test_get_comments_disabled_portfolio_200` PASSED
- AC-9 (admin + other portfolio → 201/200): `test_post_comment_admin_other_portfolio_201`, `test_get_comments_admin_other_portfolio_200` PASSED
- AC-10 (no auth → 401): `test_post_comment_no_auth_401` PASSED
- AC-11 (nonexistent comment/portfolio → 404): `test_patch_comment_nonexistent_404`, `test_delete_comment_nonexistent_404`, `test_post_comment_nonexistent_portfolio_404` PASSED
### REQ-032 (baseline docs)
- AC-1 (`docs/interface-contract.md` 갱신): v1 Portfolio API 섹션 신규 추가; GET/POST/DELETE /v1/portfolios/{id}/tags; POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments; ledger "pending REQ-029" 마킹
- AC-2 (`docs/runtime-flows.md` 갱신): portfolio mutation flow, GET branching flow, soft delete + dependent data preservation flow, comment CRUD flow 추가
- AC-3 (`docs/data-model.md` 갱신): Portfolio.status='disabled' 의미 + 의존 데이터 보존 정책 추가
- AC-4 (`docs/project-structure.md` 갱신): v1_portfolios.py 책임 갱신 + v1.py schema 목록 갱신
- AC-5 (코드-문서 정합성): interface-contract 명세가 구현 라우트와 일치 확인 (7개 신규 라우트)
- AC-6 (ledger path "pending REQ-029" 마킹): interface-contract.md에 "pending REQ-029" 섹션 추가
## Evidence
- Test:
- `tests/test_v1_portfolios_tags_comments.py`: 32/32 PASSED (3.27s)
- Full suite: 330/330 PASSED (14.11s, 2 deprecation warnings — 무관)
- No regressions in existing tests (BAT-004 tag tests, BAT-009 portfolio CRUD/list tests)
- Manual:
- `app/api/v1_portfolios.py` 코드 검토: disabled 409 check는 ownership 검증 직후, mutation 직전 위치 확인; `_require_write_token` service/client 차단 확인; GET routes는 disabled 허용 확인
- Logs:
- Full suite output: 330 passed, 2 warnings in 14.11s
## Result
- Ready for Release: true
- Blocking Issues:
- 없음
@@ -0,0 +1,42 @@
# BAT-011 Design
- Status: confirmed
- Batch: BAT-011
## Architecture Summary
- `app/api/v1_portfolios.py`에 `POST /{portfolio_id}/ledger/trades` 서브 라우트를 추가한다. 기존 `app/api/v1_ledger.py`의 daemon 인가 3단계 + idempotency 로직을 신 path로 이식하고, disabled 차단을 daemon 인가 이전에 삽입한다.
- 구 path는 파일 전체 제거(`app/api/v1_ledger.py` 삭제) + `app/main.py`에서 include 제거로 처리한다. 별칭/redirect 없음.
- trex-basic 동시 배포 순서: trex-basic 사전 빌드 완료 확인 → portfolio-hub 배포 → trex-basic 배포. rollback 시 portfolio-hub + trex-basic 동반 rollback.
## Changed Areas
- Docs:
- `docs/interface-contract.md` — REQ-032에서 이미 "pending REQ-029" 마킹됨. 본 batch에서 실제 명세 반영.
- `docs/runtime-flows.md` — ledger trade path 이전 흐름 + daemon assignment 3단계 인가 갱신 (REQ-032 기반 기존 흐름 보강).
- Modules:
- `app/api/v1_portfolios.py` — 신 path 라우트 추가
- `app/api/v1_ledger.py` — 제거 (구 path 라우트 삭제)
- `app/main.py` — 구 ledger 라우터 include 제거
- `app/services/ledger_service.py` — disabled 차단 검증 추가 (또는 라우터 계층에서 처리)
- Interfaces:
- `POST /v1/portfolios/{portfolio_id}/ledger/trades` (신설)
- `POST /v1/assets/{asset_id}/ledger/trades` (제거 → HTTP 404)
## Key Decisions
1. **구 `v1_ledger.py` 완전 제거**: 라우트만 제거하는 대신 파일 전체를 삭제해 잔재 코드가 남지 않도록 한다. `app/main.py`에서 해당 라우터 include를 제거.
2. **disabled 차단 위치**: 라우터 계층에서 `_get_portfolio_or_404` 직후, daemon 인가 3단계 이전에 `if portfolio.status == "disabled": raise HTTPException(409)` 삽입. 서비스 계층 변경 최소화.
3. **인가 순서**: (0) Bearer 없음 → 401, (1) token `daemon_id IS NULL` → 403, (2) portfolio not found → 404, (3) `portfolio.status == "disabled"` → 409, (4) `portfolio.managed_by_daemon_id IS NULL` → 403, (5) `portfolio.managed_by_daemon_id != auth.daemon_id` → 403, (6) idempotency 분기 → 201/200/409.
4. **trex-basic 동시 배포**: release plan에 배포 순서 잠금 명시. 한시적 별칭/redirect 없음.
## Edge Cases
- `portfolio_id`가 비존재 → HTTP 404 (daemon 인가보다 우선).
- `status='disabled'` portfolio → HTTP 409 (daemon 인가 체크 전에 차단하여 daemon 미배정 오류와 구분).
- 동일 `(portfolio_id, order_id, execution_id)` + 동일 payload 재호출 → HTTP 200 + `status="duplicate"`.
- 동일 key + 다른 payload → HTTP 409 (idempotency conflict).
- Bearer token 없음 → HTTP 401.
## Architecture Impact
- runtime-flows
## Reference Doc Update Plan
- project-structure.md: `v1_ledger.py` 제거 반영
- runtime-flows.md: ledger path 이전 흐름 + daemon 인가 3단계 + disabled 차단 순서 갱신
@@ -0,0 +1,36 @@
# BAT-011 Implementation
- Status: done
- Batch: BAT-011
## Plan Summary
- `app/api/v1_portfolios.py`에 `POST /{portfolio_id}/ledger/trades` 추가 (daemon 인가 3단계 + disabled 차단 + idempotency).
- `app/api/v1_ledger.py` 제거 + `app/main.py` include 정리.
- `tests/test_v1_portfolios_ledger.py` 신규 (AC-1~10).
- `tests/test_v1.py` 구 path 케이스 갱신 + HTTP 404 회귀 확인.
## Changed Files
- `app/api/v1_portfolios.py` — `POST /{portfolio_id}/ledger/trades` 라우트 신설; `_require_service_token` 의존성(service token + daemon_id IS NULL 검증); `_payloads_match` 헬퍼; `LedgerTradeRequest/Response` import 추가
- `app/api/v1_ledger.py` — **삭제** (구 path 라우트 완전 제거)
- `app/main.py` — `v1_ledger_router` import 및 `app.include_router(v1_ledger_router)` 제거
- `tests/test_v1_portfolios_ledger.py` — **신규** (AC-1~10: 10 tests)
- `tests/test_v1.py` — 구 path 케이스 5건 신 path 갱신; `app.api.v1_ledger.*` 패치 → `app.api.v1_portfolios.*` 갱신; 파일 말미 중복 helper 2건 제거
- `tests/test_daemon_assignment.py` — `app.api.v1_ledger.*` 패치 → `app.api.v1_portfolios.*` 갱신; URL 4건 갱신; `_portfolio_with_daemon`에 `p.status = "enabled"` 추가
## Execution Log
1. `app/api/v1_portfolios.py` — `Response` import 추가, `LedgerTradeRequest/LedgerTradeResponse` import 추가
2. `_require_service_token` 의존성 추가 (service token 검증 + daemon_id IS NULL 403)
3. `_payloads_match` 헬퍼 추가 (idempotency conflict 판정)
4. `POST /{portfolio_id}/ledger/trades` 라우트 추가 (disabled 409 → managed_by_daemon_id NULL 403 → daemon mismatch 403 → insert_ledger_trade)
5. `app/api/v1_ledger.py` 삭제
6. `app/main.py` 에서 v1_ledger 라우터 import + include 제거
7. `tests/test_v1.py` — 구 path URL 및 패치 경로 갱신 + 중복 helper 제거
8. `tests/test_daemon_assignment.py` — URL 및 패치 경로 갱신; `_portfolio_with_daemon` `status` 명시
9. `tests/test_v1_portfolios_ledger.py` — 신규 생성 (AC-1~10)
## Validation
- `pytest tests/test_v1_portfolios_ledger.py -v`: 10 passed
- `pytest --tb=short -q` (full suite): **340 passed, 0 failed** (2 warnings, non-blocking)
## Remaining Risks
- R-1 (High): trex-basic 동시 배포 — release 단계 배포 순서 잠금 필수 (trex-basic 사전 빌드 완료 확인 → portfolio-hub 배포 → trex-basic 배포).
@@ -0,0 +1,35 @@
# BAT-011: v1 portfolios ledger path migration
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-003
- Included REQ: REQ-029
- Excluded REQ: none
## Summary
- 기존 `POST /v1/assets/{asset_id}/ledger/trades`(`app/api/v1_ledger.py`)를 `POST /v1/portfolios/{portfolio_id}/ledger/trades`로 이전하고 구 path를 동일 release에서 제거한다.
- 동작·인가·idempotency는 그대로 보존하며 REQ-027 정합의 disabled portfolio 차단(HTTP 409)을 신규 추가한다.
- trex-basic 클라이언트와의 동시 배포가 필수이므로 release plan에 배포 순서 잠금·rollback 전제를 명시한다.
## Scope
- In Scope:
- `app/api/v1_portfolios.py` — `POST /v1/portfolios/{portfolio_id}/ledger/trades` 라우트 추가
- `app/api/v1_ledger.py` — 구 path 라우트 제거 (또는 파일 전체 제거 후 `app/main.py`에서 include 제거)
- `app/main.py` — 구 ledger 라우터 include 제거
- `app/services/portfolio_service.py` 또는 `app/services/ledger_service.py` — disabled 차단 검증 추가
- `tests/test_v1.py` — 구 path 케이스 갱신(HTTP 404 확인)
- `tests/test_v1_portfolios_ledger.py` (신규) — AC-1~10 검증
- Out of Scope:
- `ledger_trades.asset_id` 컬럼명 변경 (별도 migration REQ로 분리 가능)
- trex-basic 코드 변경 (별도 레포)
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- R-1 (High): trex-basic 동시 배포 — 신 path 단일 서빙, 별칭/redirect 없음. release plan에 배포 순서(trex-basic 사전 빌드 → portfolio-hub 배포 → trex-basic 배포) + rollback 시 trex-basic 동반 rollback 전제 명시 필수.
- R-2 (Low): disabled portfolio 차단(AC-7)은 REQ-027 soft delete 정책 정합 검증으로 신규. 기존 daemon 인가 3단계와 순서 충돌 없이 disabled 체크를 insert 전에 추가.
- 선행 완료: BAT-009(soft delete + disabled 상태 관리, REQ-027) ✅
@@ -0,0 +1,39 @@
# BAT-011 Planning
- Status: confirmed
- Batch: BAT-011
- Profile: standard
## Included REQ
- REQ-029: `POST /v1/portfolios/{portfolio_id}/ledger/trades` 이전 + 구 path 제거 + disabled 차단 추가
## Delivery Plan
1. `app/api/v1_portfolios.py` — `POST /{portfolio_id}/ledger/trades` 라우트 추가: daemon 인가 3단계 + disabled 차단 + idempotency + conflict 분기
2. `app/api/v1_ledger.py` — 구 `POST /v1/assets/{asset_id}/ledger/trades` 라우트 제거 (또는 파일 전체 제거)
3. `app/main.py` — 구 `v1_ledger` 라우터 include 제거 (v1_portfolios_router는 BAT-009에서 이미 include됨)
4. `app/services/ledger_service.py` 또는 `app/services/portfolio_service.py` — `disabled` 차단 검증 추가
5. `tests/test_v1_portfolios_ledger.py` 신규 — AC-1~10 검증: 신 path 정상 흐름 + daemon 인가 3단계 + idempotency/conflict + disabled + 404 + 401
6. `tests/test_v1.py` 갱신 — 구 path 케이스를 신 path로 갱신 + 구 path HTTP 404 회귀 확인
## Dependencies
- Internal:
- BAT-009 (release-candidate): soft delete(`status='disabled'`) + `managed_by_daemon_id` 관리 확정 ✅
- BAT-006 (release-candidate): service token 분기 정책 확정(REQ-016/017/018) ✅
- External:
- trex-basic 클라이언트: 신 path `POST /v1/portfolios/{portfolio_id}/ledger/trades` 사전 빌드 필요. release 단계에서 배포 순서 잠금 확인 필수.
## Design Gate
- Design Required Before Implementation: yes
- Reason: (1) 구 `v1_ledger.py` 파일 완전 제거 vs. 라우트만 제거 결정 필요, (2) disabled 차단을 서비스 계층과 라우터 계층 중 어디에 위치시킬지 결정 필요, (3) trex-basic 동시 배포 전략(배포 순서 잠금·rollback 계획)을 design 단계에서 명문화.
## Milestones
1. design.md confirmed — 파일 제거 전략, disabled 차단 위치, 배포 순서 잠금 결정
2. 신 path 라우트 구현 완료 (`app/api/v1_portfolios.py`)
3. 구 path 제거 + `app/main.py` 갱신 완료
4. `tests/test_v1_portfolios_ledger.py` AC-1~10 전부 pass
5. `tests/test_v1.py` 구 path HTTP 404 회귀 확인 + 전체 suite pass
## Risks
- R-1 (High): trex-basic 동시 배포 — 신 path 단일 서빙으로 별칭/redirect 없음. trex-basic이 구 path를 사용 중인 상태에서 portfolio-hub만 배포되면 trex-basic이 즉시 장애. 완화: release plan에 배포 순서(trex-basic 사전 빌드 완료 확인 → portfolio-hub 배포 → trex-basic 배포) 잠금 + rollback 시 trex-basic 동반 rollback 명시.
- R-2 (Low): `ledger_trades.asset_id` 컬럼명은 실제로 portfolio_id 값을 저장하는 혼용 상태가 남으나, 본 REQ에서는 path 명명 정합화만 처리하고 컬럼명 변경은 별도 REQ로 이월.
- R-3 (Low): `tests/test_v1.py`에 구 path 테스트가 얼마나 있는지 design 단계에서 사전 파악 필요.
@@ -0,0 +1,33 @@
# BAT-011 Verification
- Status: verified
- Batch: BAT-011
## Acceptance Mapping
- REQ-029:
- AC-1 (`test_ledger_trade_new`): 신 path HTTP 201 + `status="new"` + `asset_id=_PORTFOLIO_ID` ✅
- AC-2 (`test_ledger_trade_duplicate_same_payload`): 동일 key + 동일 payload → HTTP 200 + `status="duplicate"` ✅
- AC-3 (`test_ledger_trade_conflict_different_payload`): 동일 key + 다른 quantity → HTTP 409 ✅
- AC-4 (`test_ledger_trade_no_daemon_id`): service token `daemon_id IS NULL` → HTTP 403; `insert_ledger_trade` not called ✅
- AC-5 (`test_ledger_trade_portfolio_unassigned`): `portfolio.managed_by_daemon_id IS NULL` → HTTP 403; `insert_ledger_trade` not called ✅
- AC-6 (`test_ledger_trade_daemon_mismatch`): `managed_by_daemon_id="other-daemon"` vs `auth.daemon_id=_DAEMON_ID` → HTTP 403; `insert_ledger_trade` not called ✅
- AC-7 (`test_ledger_trade_disabled_portfolio`): `status="disabled"` → HTTP 409; `insert_ledger_trade` not called ✅
- AC-8 (`test_ledger_trade_portfolio_not_found`): `repo.get.side_effect=Exception` → HTTP 404 ✅
- AC-9 (`test_old_ledger_path_returns_404`): `POST /v1/assets/{id}/ledger/trades` → HTTP 404 (라우트 제거) ✅
- AC-10 (`test_ledger_trade_no_auth`): Bearer 없음 → HTTP 401 ✅
- Regression (`test_daemon_assignment.py` 4건): daemon assignment 인가 3단계 신 path에서 동작 검증 ✅
## Evidence
- Test:
- `pytest tests/test_v1_portfolios_ledger.py -v`: **10/10 passed**
- `pytest --tb=short -q` (full suite): **340 passed, 0 failed**, 2 warnings (non-blocking DeprecationWarning from anyio)
- 이전 suite 대비 +10 tests (330 → 340)
- Manual:
- 해당 없음 — 자동화 검증으로 충분 (인터페이스 변경, UI 없음)
- Logs:
- 2 DeprecationWarnings: `anyio._backends._asyncio.py` `HTTP_422_UNPROCESSABLE_ENTITY` → `HTTP_422_UNPROCESSABLE_CONTENT`. 기존 suite에서도 동일 경고 존재. 비차단.
## Result
- Ready for Release: true
- Blocking Issues: 없음 (코드 구현 완료, 10/10 AC 검증)
- Release Note: trex-basic 동시 배포 전제 — release 단계에서 배포 순서 잠금 필수. 신 path 단일 서빙, 별칭/redirect 없음.
+4
View File
@@ -15,3 +15,7 @@
| BAT-005 | standard | release-candidate | REQ-007, REQ-008 | dcy-002 | [bat-005](./bat-005_20260520_pfhub-tag-cli-and-docs/) |
| BAT-006 | standard | release-candidate | REQ-016, REQ-017, REQ-018 | dcy-004 | [bat-006](./bat-006_20260520_token-surface-code-fixes/) |
| BAT-007 | standard | release-candidate | REQ-022, REQ-023 | dcy-004 | [bat-007](./bat-007_20260520_daemon-lifecycle-token-enforcement/) |
| BAT-008 | standard | release-candidate | REQ-019, REQ-020, REQ-021, REQ-024 | dcy-004 | [bat-008](./bat-008_20260521_token-surface-docs/) |
| BAT-009 | standard | release-candidate | REQ-025, REQ-026, REQ-027, REQ-028 | dcy-003 | [bat-009](./bat-009_20260521_v1-portfolios-crud-core/) |
| BAT-010 | standard | release-candidate | REQ-030, REQ-031, REQ-032 | dcy-003 | [bat-010](./bat-010_20260521_v1-portfolios-sub-resources/) |
| BAT-011 | standard | release-candidate | REQ-029 | dcy-003 | [bat-011](./bat-011_20260521_v1-portfolios-ledger-path-migration/) |
+17 -11
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed
- Owner: repository-maintainers
- Last Updated (KST): 2026-05-20 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005
- Last Updated (KST): 2026-05-21 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004)
## Project Summary
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI.
@@ -30,7 +30,7 @@
- Portfolio :: owned investment portfolio with balances, strategy references, tag_ids, comments, and ledger history
- User :: authenticated principal that owns portfolios and receives client or service tokens
- Daemon :: stable logical identity for a trading daemon process; registered per user; bound to service tokens and assigned to assets
- AuthToken :: issued client or service token with permission, runtime_id, and optional daemon_id binding
- AuthToken :: issued service token (or session token for login flows) with permission, runtime_id, and optional daemon_id binding; client token type removed (REQ-016/BAT-006)
- BrokerCredential :: encrypted broker API credentials per user-broker pair
- LedgerTrade :: idempotent trade execution record keyed by (asset_id, order_id, execution_id)
- Asset :: catalog entry representing a tradeable asset; portfolios reference assets via FK
@@ -114,12 +114,13 @@
- name :: text :: human-readable portfolio name; unique per owner_user_id; validated against [a-zA-Z0-9._:-]{1,100}
- asset_id :: uuid :: FK → assets.asset_id; catalog reference for the traded asset; BAT-001 migrated symbol/market columns to this FK
- symbol :: text :: display symbol populated via JOIN on assets (read-only, not stored in portfolios)
- status :: text :: `"enabled"` (active, default) | `"disabled"` (soft-deleted); set to `"disabled"` by `DELETE /v1/portfolios/{id}`; only `status` can be mutated on a disabled portfolio (restoration via PATCH); all other writes → HTTP 409
- owner_user_id :: text|null :: owning user reference for attribution and authorization
- active_strategy_id :: text|null :: currently selected strategy-set reference
- cash_balance :: double precision :: current cash snapshot after the latest ledger event
- asset_quantity :: double precision :: current holdings snapshot after the latest ledger event
- managed_by_daemon_id :: text|null :: soft reference to daemons.id; controls daemon-scoped API access; NULL = unassigned (fail-closed for ledger writes and asset listing); added by migration 20260513_0016
- tag_ids :: uuid[] :: array of tag UUIDs from the tags master table; replaces the old string-based portfolio_tags table (BAT-003 migration 20260519_0017); default empty array
- managed_by_daemon_id :: text|null :: soft reference to daemons.id; controls daemon-scoped API access; NULL = unassigned (fail-closed for ledger writes and asset listing); set to NULL on soft delete; added by migration 20260513_0016
- tag_ids :: uuid[] :: array of tag UUIDs from the tags master table; replaces the old string-based portfolio_tags table (BAT-003 migration 20260519_0017); preserved on soft delete; default empty array
### Uniqueness Constraints
@@ -140,10 +141,12 @@
- portfolio_id is server-generated at creation; clients must never supply or assume a specific portfolio_id value.
- Balance-changing commands must update snapshot fields and append a ledger row together.
- tag_ids stores UUID references to active tags; duplicates are prevented by conditional array_append; comments are retrieved in created_at descending order.
- `status="disabled"` (soft delete): `managed_by_daemon_id` is set to NULL; portfolio is excluded from `GET /v1/portfolios` by default. Dependent data (`portfolio_comments`, `tag_ids`) is preserved — never deleted on soft delete. READ operations on sub-resources (GET tags, GET comments) remain allowed; write mutations raise HTTP 409.
- Status restoration: PATCH with `{"status": "enabled"}` re-activates a disabled portfolio; no other fields may be set in the same request.
## Entity: `User`
- Purpose: represent an authenticated principal that can own portfolios and receive client or service tokens
- Purpose: represent an authenticated principal that can own portfolios and receive service tokens
- Source of Truth: users table with dependent auth_tokens rows
- Lifecycle: bootstrapped or created -> updated -> deactivated or deleted
@@ -158,7 +161,7 @@
### Relationships
- AuthToken :: user_id links issued client and service tokens to the owning user
- AuthToken :: user_id links issued service tokens to the owning user
- Portfolio :: portfolios.owner_user_id ties portfolios to a managing user
- TokenPrincipal :: runtime auth resolution derives principals from persisted tokens and users
@@ -170,7 +173,7 @@
## Entity: `AuthToken`
- Purpose: represent an issued client or service token linked to a user with permission and optional runtime identifier
- Purpose: represent an issued service token (or session token for login flows) linked to a user with permission and optional runtime identifier; client token type (token_type="client") removed by REQ-016/BAT-006
- Source of Truth: auth_tokens table
- Lifecycle: issued -> optionally rotated -> revoked or expired
@@ -178,7 +181,7 @@
- id :: text :: primary key; raw token value (stored hashed in practice)
- user_id :: text :: owning user reference
- token_type :: text :: "client" or "service"
- token_type :: text :: "session" (login tokens) | "service" (daemon-bound operational tokens); "client" type removed by REQ-016/BAT-006
- token_source :: text :: "db" for persisted tokens; "env" for environment-injected tokens
- permission :: text :: read, trade-write, or admin
- service_name :: text|null :: logical name for service tokens (unique within token_type="service")
@@ -189,7 +192,7 @@
### Uniqueness Constraints
- idx_auth_tokens_service_name_unique :: UNIQUE (service_name) WHERE token_type='service'
- idx_auth_tokens_service_name_unique :: UNIQUE (name) WHERE token_type='service' AND revoked_at IS NULL (BAT-006/REQ-017: revoked_at IS NULL 조건 추가로 rotate 후 구 token과 신 token 이름 충돌 해소)
- idx_auth_tokens_runtime_id_unique :: UNIQUE (runtime_id) WHERE runtime_id IS NOT NULL
### State Rules
@@ -198,6 +201,7 @@
- runtime_id is per-token and changes on every rotate; daemon_id is stable across rotates (daemon_id invariant).
- daemon_id=NULL tokens are fail-closed on GET /v1/assets and POST /v1/assets/{asset_id}/ledger/trades (HTTP 403); GET /v1/user_info returns 200 + `daemon_id: null` as diagnostic.
- Token permission gates both HTTP API guards and CLI mutation capabilities.
- Deprecated: token_type="client" issuance path removed by REQ-016/BAT-006; existing "client" type rows in legacy DBs remain readable but new issuance is blocked.
## Entity: `Daemon`
@@ -228,7 +232,7 @@
### State Rules
- A daemon must exist and be active before a service token can be bound to it at issuance.
- Deactivating a daemon (is_active=false) blocks new token issuance but does not revoke existing tokens.
- Deactivating a daemon (is_active=false) immediately revokes all bound service tokens via `revoke_tokens_by_daemon_id` (REQ-022/BAT-007) and blocks new token issuance.
- daemon_slug is immutable after creation; only display_name and is_active can be updated.
## Entity: `BrokerCredential`
@@ -313,6 +317,8 @@
## Change Log
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 데이터 모델 보강: Portfolio.status 필드 신설 (`"enabled"` | `"disabled"` 의미, soft delete 정책); Portfolio.managed_by_daemon_id에 soft delete 시 NULL 설정 명시; tag_ids 소프트 삭제 보존 정책 추가; Portfolio State Rules에 disabled 상태 정책 + 의존 데이터 보존 + 상태 복원 규칙 추가; Source Discovery/Batch 헤더 갱신.
- 2026-05-21 KST: BAT-008 (REQ-019) — 토큰 surface 정합성 회복: AuthToken token_type "client" 제거 반영; idx_auth_tokens_service_name_unique에 AND revoked_at IS NULL 조건 추가(REQ-017); Daemon 비활성화 시 바인딩 서비스 토큰 자동 revoke 정책 반영(REQ-022); User/AuthToken Purpose 문구 갱신.
- 2026-05-20 KST: bat-005 — REQ-008 AC-1: added Entity: Tag (tags master table, BAT-003 migration 20260519_0017); updated Portfolio.tag_ids UUID[] field; removed portfolio_tags table note; updated Portfolio Relationships and State Rules; updated Model Summary.
- 2026-05-19 KST: bat-002 — REQ-015: added Entity: Asset (asset catalog with symbol UNIQUE, markets[], status); updated Portfolio.asset_id FK and symbol/market field notes.
- 2026-05-13 KST: bat-010 — REQ-009/023/028: added Entity: Daemon; added portfolios.managed_by_daemon_id; added auth_tokens.daemon_id; updated AuthToken State Rules (runtime_id vs daemon_id distinction, fail-closed policy); updated Portfolio Relationships; updated Model Summary.
+155 -6
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed
- Owner: repository-maintainers
- Last Updated (KST): 2026-05-20 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005
- Last Updated (KST): 2026-05-21 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004)
- Primary Runtime: mixed
## Project Summary
@@ -70,6 +70,8 @@
### Inputs
- pfhub subcommands :: include user, portfolio, strategy, tag, service-token, daemon, credential, health-check, init-db, and migrate-db command families
- `service-token` subcommands: `issue`, `rotate`, `rebind` (BAT-007: `--daemon-id` 변경), `revoke`, `list`, `migrate-null-daemon --dry-run` (BAT-007: NULL daemon_id 진단 CLI)
- `daemon` subcommands: `create`, `list`, `update` (BAT-007: `--deactivate --force` 시 바인딩 service token 자동 revoke)
- Flags and options :: include --format table|json, --force confirmations, and operation-specific mutation arguments
- Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution
- Portfolio identification (bat-006) :: all portfolio mutation commands accept `<name>` (positional) + `--user <username>` instead of raw portfolio_id; `comment` sub-commands accept portfolio_id (UUID) directly
@@ -105,20 +107,24 @@
- All /v1 endpoints require `Authorization: Bearer <token>` header.
- Token must be DB-issued (`token_source="db"`); env tokens are rejected with HTTP 403.
- Admin catalog endpoints (`POST /v1/assets`, `PATCH /v1/assets/{id}`, `PATCH /v1/assets/{id}/deactivate`) require admin permission.
- POST /v1/assets/{asset_id}/ledger/trades additionally requires `trade-write` permission.
- POST /v1/portfolios/{portfolio_id}/ledger/trades additionally requires `trade-write` permission and a DB service token with non-NULL `daemon_id`.
### Token Type Branching
- `token_type="service"`: daemon-scoped endpoints. `daemon_id` must be non-NULL (NULL → HTTP 403 fail-closed).
- `token_type="user"`: user-scoped. Endpoints return owner's own data.
- `token_type="admin"`: admin-scoped. May query other users' data via `?user_id=<id>` param.
- `token_type="session"`: user-scoped (login token). Endpoints return owner's own data.
- `token_type="client"`: **Deprecated/Removed** (REQ-016/BAT-006). 발급 경로 제거. 잔존 DB row는 읽을 수 있으나 신규 발급 불가. v1 API 호출 시 HTTP 403 fail-closed.
- env token (token_source="env"): admin-scoped. May query other users' data via `?user_id=<id>` param. DB /v1 endpoints에서는 HTTP 403 (DB token만 허용).
### Daemon Authorization Model
- Service tokens are bound to a daemon at issuance via `daemon_id` field. Legacy/unbound tokens have `daemon_id=NULL`.
- GET /v1/assets (service token) and POST /v1/assets/{asset_id}/ledger/trades are daemon-scoped. NULL token `daemon_id` → HTTP 403 (fail-closed).
- GET /v1/assets (service token) and POST /v1/portfolios/{portfolio_id}/ledger/trades are daemon-scoped. NULL token `daemon_id` → HTTP 403 (fail-closed).
- GET /v1/user_info is available to any service token; returns `daemon_id: null` as diagnostic for unbound tokens.
- Each asset (portfolio) may be assigned to exactly one daemon via `portfolios.managed_by_daemon_id`. Mismatch or unassigned asset → HTTP 403 on ledger writes.
- **Token rebind** (BAT-007/REQ-023): `pfhub service-token rebind <name> --daemon-id <id>` — daemon_id 변경, runtime_id 불변. 대상 daemon은 active이어야 하고 token 소유 user와 일치해야 한다.
- **Daemon deactivation** (BAT-007/REQ-022): `pfhub daemon update <daemon_id> --deactivate --force` — daemon is_active=false + 바인딩된 모든 service token 즉시 revoke.
- **NULL daemon migration CLI** (BAT-007/REQ-023): `pfhub service-token migrate-null-daemon --dry-run` — NULL daemon_id active service token inventory 출력 + user별 candidate daemon 목록. `--dry-run` 없이 실행 시 exit code 1 + 수동 안내.
### Endpoint: `GET /v1/assets`
@@ -275,6 +281,109 @@
- After BAT-006, all `asset_id` values in GET /v1/assets responses change from TEXT to UUID format.
- trex-basic consumers must handle UUID-format asset_id before the coordinated deployment is completed.
## Interface: `v1 Portfolio API (BAT-009/010)`
- Type: http-api
- Consumers / Producers: authenticated users and admin services
- Purpose: CRUD for user-owned portfolios; tag and comment sub-resources; ownership and disabled-state enforcement
- Stability: internal (versioned under /v1)
### Authentication
- All /v1/portfolios endpoints require `Authorization: Bearer <token>` header.
- `token_type ∈ {service, client}` → HTTP 403 fail-closed for all write operations and GET sub-resources.
- `permission='admin'` → access to all portfolios. `permission ∈ {read, trade-write}` → own portfolios only.
- Comment and portfolio CRUD routes: DB token or env token; POST/PATCH/DELETE require user or admin token type.
- Tag POST/DELETE routes additionally require `token_source="db"` (DB token).
### Endpoint: `POST /v1/portfolios`
- Auth: any valid token; service/client → HTTP 403
- Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_name": "<str|null>", "strategy_params": {<dict>}, "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}`
- Response: HTTP 201 `PortfolioItem`
- Error: HTTP 422 if asset not found or inactive; HTTP 403 if service/client token
### Endpoint: `PATCH /v1/portfolios/{portfolio_id}`
- Auth: any valid token; service/client → HTTP 403; ownership required
- Body: `{"broker": "<str|null>", "status": "<str|null>", "name": "<str|null>", "strategy_name": "<str|null>", "strategy_params": {<dict|null>}, "asset_price": <float|null>, "managed_by_daemon_id": "<str|null>"}`
- Response: HTTP 200 `PortfolioItem`
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if portfolio is disabled and non-status fields are provided
### Endpoint: `DELETE /v1/portfolios/{portfolio_id}` (Soft Delete)
- Auth: any valid token; service/client → HTTP 403; ownership required
- Body: none
- Response: HTTP 200 `PortfolioItem` with `status="disabled"`, `managed_by_daemon_id=null`
- Dependent data: portfolio_comments and tag_ids are preserved on soft delete; only status and daemon assignment change
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if not found
### Endpoint: `GET /v1/portfolios`
- Auth: any valid token; client → HTTP 403
- Query Params: `?include_disabled=true` (default false); `?market=<code>`; `?user_id=<str>` (admin only)
- Branching by token type:
- `service`: returns portfolios where `managed_by_daemon_id = token.daemon_id`; NULL daemon_id → HTTP 403
- `user/admin`: returns owner's own portfolios; admin may override with `?user_id=<id>`
- Response: HTTP 200 `PortfolioListResponse {"portfolios": [...], "total": <int>}`
### Endpoint: `GET /v1/portfolios/{portfolio_id}/tags`
- Auth: DB or env token; service/client → HTTP 403; ownership required
- Response: HTTP 200 `PortfolioTagListResponse {"tags": [TagItem], "total": <int>}`
- Note: disabled portfolio GET is allowed
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found
### Endpoint: `POST /v1/portfolios/{portfolio_id}/tags`
- Auth: DB token required; trade-write or admin; service/client → HTTP 403; ownership required
- Body: `{"tag_id": "<uuid>"}`
- Response: HTTP 200 `TagApplyResponse {"tag_id": "<uuid>", "status": "applied"}`
- Policy: tag must be is_active=true; non-admin must use own or admin tag; disabled portfolio → HTTP 409
- Error: HTTP 403 if policy violation, service/client, or ownership mismatch; HTTP 404 if portfolio or tag not found; HTTP 409 if disabled portfolio
### Endpoint: `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}`
- Auth: DB token required; trade-write or admin; service/client → HTTP 403; ownership required
- Response: HTTP 200 `TagApplyResponse {"tag_id": "<uuid>", "status": "removed"}`
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if disabled portfolio
### Endpoint: `POST /v1/portfolios/{portfolio_id}/comments`
- Auth: any valid token; service/client → HTTP 403; ownership required
- Body: `{"content": "<str>"}` (min_length=1)
- Response: HTTP 201 `PortfolioCommentItem {"comment_id": "<uuid>", "portfolio_id": "<uuid>", "content": "<str>", "created_at": "<ISO8601|null>", "updated_at": "<ISO8601|null>"}`
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if disabled portfolio
### Endpoint: `GET /v1/portfolios/{portfolio_id}/comments`
- Auth: any valid token; service/client → HTTP 403; ownership required
- Response: HTTP 200 `PortfolioCommentListResponse {"comments": [PortfolioCommentItem], "total": <int>}` (created_at desc from repo)
- Note: disabled portfolio GET is allowed
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found
### Endpoint: `PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}`
- Auth: any valid token; service/client → HTTP 403; portfolio ownership required (no per-comment author check)
- Body: `{"content": "<str>"}` (min_length=1)
- Response: HTTP 200 `PortfolioCommentItem`
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio or comment not found; HTTP 409 if disabled portfolio
### Endpoint: `DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}`
- Auth: any valid token; service/client → HTTP 403; portfolio ownership required
- Response: HTTP 204 (no content)
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio or comment not found; HTTP 409 if disabled portfolio
### Endpoint: `POST /v1/portfolios/{portfolio_id}/ledger/trades` (REQ-029, BAT-011)
- Auth: DB service token with `trade-write` permission; `daemon_id IS NULL` → HTTP 403 fail-closed
- Request: `LedgerTradeRequest` — `order_id`, `execution_id`, `side` (buy|sell), `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`
- Response: HTTP 201 + `LedgerTradeResponse` with `status="new"`; HTTP 200 + `status="duplicate"` for idempotent replay; HTTP 409 for conflicting payload
- Error: HTTP 403 (no daemon_id on token, portfolio unassigned, daemon mismatch); HTTP 404 (non-existent portfolio); HTTP 409 (disabled portfolio or payload conflict)
- Idempotency key: `(portfolio_id, order_id, execution_id)` — reuses `uq_ledger_trades_idempotency` constraint
- **구 path `POST /v1/assets/{asset_id}/ledger/trades`**: BAT-011에서 제거됨. 호출 시 HTTP 404.
## Interface: `Tag Management API (BAT-004)`
- Type: http-api
@@ -341,6 +450,43 @@
- Response: HTTP 200 `TagApplyResponse` `{"tag_id": "<uuid>", "status": "removed"}`
- Error: HTTP 404 if portfolio not found; HTTP 403 if caller is not owner or admin
## Deprecated Endpoints
### `POST /auth/tokens` (Client Token Issuance) — Removed
- **Status**: Removed as of BAT-006/REQ-016 (2026-05-20).
- Route `POST /auth/tokens` 및 `CreateClientTokenRequest` / `IssuedClientTokenResponse` 스키마 제거. 호출 시 HTTP 404 또는 405.
- `token_type="client"` 발급 경로는 코드에서 완전히 제거됨. 잔존 client token row는 DB에 읽기 전용으로 남을 수 있음.
### `POST /auth/service-tokens` Body — daemon_id 추가 (BAT-006/REQ-018)
- Body `daemon_id` 필드가 서비스 토큰 발급 시 라우트 핸들러로 정상 forwarding됨. (이전: 누락으로 인해 daemon_id가 항상 NULL로 저장)
- Body: `{"name": "<str>", "username": "<str>", "permission": "read|trade-write", "expires_in_days": <int|null>, "no_expiry": <bool>, "daemon_id": "<uuid|null>"}`
- Response: HTTP 201 `{"token": "<raw>", "runtime_id": "<str>", "daemon_id": "<str|null>", ...}`
## Token Issuance Authorization Matrix
(REQ-020/BAT-008 — dcy-004 FR-5, NFR-4)
매트릭스 위반 호출 → HTTP 403 fail-closed (모든 권한 미충족 케이스).
| Token Type | Verb | admin | trade-write | read | 비고 |
|------------|------|-------|-------------|------|------|
| session | issue (POST /auth/login) | ✓ | ✓ | ✓ | 유효 credential 필요 |
| session | revoke (DELETE /auth/tokens/{id}) | ✓ 본인 | ✓ 본인 | ✓ 본인 | 타 user token 폐기 불가 |
| session | list (GET /auth/tokens) | ✓ 본인 | ✓ 본인 | ✓ 본인 | — |
| service | issue (POST /auth/service-tokens) | ✓ | ✗ | ✗ | admin만 발급 가능 |
| service | rotate (pfhub service-token rotate) | ✓ | ✗ | ✗ | CLI only |
| service | rebind (pfhub service-token rebind) | ✓ | ✗ | ✗ | CLI only |
| service | revoke (pfhub service-token revoke) | ✓ | ✗ | ✗ | CLI only |
| service | list (pfhub service-token list) | ✓ | ✗ | ✗ | CLI only |
| env | issued/managed | infra operator | ✗ | ✗ | PORTFOLIOHUB_ADMIN_TOKENS 환경 변수 직접 편집 |
| client | — | ✗ | ✗ | ✗ | **폐지됨** (REQ-016/BAT-006) |
**Admin 자기 발급 정책 (dcy-004 OQ-4)**: admin은 본인 user_id 대상으로 daemon-bound 또는 unbound service token 발급 가능. 관행상 최소 권한 원칙에 따라 운영 daemon 대상 발급을 권장.
**코드 매핑**: `require_admin` → admin 전용 게이트. `require_read` → read 이상 허용. `AuthService.issue_service_token()` → admin 권한 필요 (라우트에서 `require_admin` 의존성). 불일치 발견 시 별도 follow-up REQ로 분리.
## Shared Constraints
- Token permission levels gate both HTTP and CLI mutations consistently.
@@ -361,6 +507,9 @@
## Change Log
- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 명세 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체; auth 섹션 + daemon authorization 갱신; 구 path HTTP 404 명시.
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface baseline 회복: "v1 Portfolio API" 섹션 신규 추가 (REQ-025~028 CRUD + REQ-030 tag 서브 라우트 + REQ-031 comment CRUD); POST/DELETE /v1/portfolios/{id}/tags에 disabled 보호(409) 및 service/client 403 명세 추가; GET /v1/portfolios/{id}/tags 신설; ledger path "pending REQ-029" 마킹; Source Discovery/Batch 헤더 갱신.
- 2026-05-21 KST: BAT-008 (REQ-019/020) — 토큰 surface 정합성 회복 + 권한 매트릭스: service-token rebind/migrate-null-daemon CLI 명세 추가; POST /auth/tokens 제거 명시(REQ-016); POST /auth/service-tokens daemon_id forward 명세 추가(REQ-018); Token Type Branching 갱신(client→Removed, session/env 명시); Daemon Authorization Model에 rebind/deactivate/null-daemon CLI 추가; "Token Issuance Authorization Matrix" 섹션 신규 추가(REQ-020).
- 2026-05-20 KST: bat-005 — REQ-008 AC-2: added Tag Management API section (POST/GET/PATCH /v1/tags, POST/DELETE /v1/assets/{id}/tags, POST/DELETE /v1/portfolios/{id}/tags); updated CLI surface (pfhub tag, removed pfhub portfolio tag); updated CLI inputs.
- 2026-05-19 KST: bat-002 — REQ-010/012/013/015: added admin catalog endpoints (POST/PATCH /v1/assets, PATCH /v1/assets/{id}/deactivate); rewrote GET /v1/assets authentication to support service/user/admin token branching; added market filter param; removed market field from AssetItem; added AssetCatalogResponse schema.
- 2026-05-13 KST: bat-010 — REQ-006/007/008/011/023: added daemon authorization model section; updated GET /v1/assets (daemon-scoped filter, NULL 403); updated GET /v1/user_info (daemon_id field, NULL runtime_id → 200+null diagnostic); updated POST /v1/assets/{asset_id}/ledger/trades (daemon assignment authorization, fail-closed policy); added daemon CLI subcommand to CLI surface; updated v1 purpose and source discovery.
+13 -6
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed
- Owner: repository-maintainers
- Last Updated (KST): 2026-05-20 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-002, bat-006, bat-007, bat-003, bat-004, bat-005
- Last Updated (KST): 2026-05-21 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-002, bat-006, bat-007, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004)
## Project Summary
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI.
@@ -83,12 +83,15 @@
- Hosts StagePilot skills, document templates, and helper scripts such as stagepilot-doctor and stagepilot-bootstrap-seed.
- app/api/
- Defines FastAPI route surfaces for health, authentication, users, strategy sets, portfolios, and tags.
- v1 route surfaces: `v1_assets.py` (GET /v1/assets token-branched, POST /v1/assets admin catalog, PATCH /v1/assets/{id}, PATCH /v1/assets/{id}/deactivate, POST/DELETE /v1/assets/{id}/tags), `v1_tags.py` (POST/GET /v1/tags, PATCH /v1/tags/{id}/deactivate), `v1_portfolios.py` (POST/DELETE /v1/portfolios/{id}/tags), `v1_user_info.py` (GET /v1/user_info), `v1_ledger.py` (POST /v1/assets/{asset_id}/ledger/trades).
- v1 route surfaces: `v1_assets.py` (GET /v1/assets token-branched, POST /v1/assets admin catalog, PATCH /v1/assets/{id}, PATCH /v1/assets/{id}/deactivate, POST/DELETE /v1/assets/{id}/tags), `v1_tags.py` (POST/GET /v1/tags, PATCH /v1/tags/{id}/deactivate), `v1_portfolios.py` (POST/PATCH/DELETE/GET /v1/portfolios; GET/POST/DELETE /v1/portfolios/{id}/tags; POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments; POST /v1/portfolios/{id}/ledger/trades), `v1_user_info.py` (GET /v1/user_info).
- `v1_ledger.py` 제거됨 (BAT-011/REQ-029): 구 path `POST /v1/assets/{asset_id}/ledger/trades` 삭제, 신 path는 `v1_portfolios.py`로 통합.
- GET /v1/assets supports service/user/admin token branching; admin catalog CRUD requires admin token; tag endpoints added by BAT-004.
- app/cli/
- Defines the Typer entrypoint, command groups, output helpers, and operator-facing command flows.
- Command groups: `asset` (catalog CRUD via admin), `portfolio`, `strategy`, `tag` (tag master CRUD and apply/remove; BAT-005), `user`, `service-token`, `credential` (broker credential management), and health commands (`health-check`, `init-db`, `migrate-db`).
- Command groups: `asset` (catalog CRUD via admin), `portfolio`, `strategy`, `tag` (tag master CRUD and apply/remove; BAT-005), `user`, `service-token`, `daemon`, `credential` (broker credential management), and health commands (`health-check`, `init-db`, `migrate-db`).
- `app/cli/commands/tag.py` (BAT-005): `pfhub tag create/list/deactivate/apply asset|portfolio/remove asset|portfolio`; direct repo method calls following existing AppContext pattern. `pfhub portfolio tag add/list/remove` removed (breaking change, BAT-005).
- `app/cli/commands/service_tokens.py` (BAT-006/BAT-007): `issue`, `rotate`, `rebind` (daemon_id 변경, runtime_id 불변), `revoke`, `list`, `migrate-null-daemon --dry-run` (NULL daemon_id active token 진단 CLI).
- `app/cli/commands/daemon.py` (BAT-007): `create`, `list`, `update` (`--deactivate --force` 시 바인딩 service token 즉시 revoke).
- app/core/
- Owns configuration loading, auth guards, security helpers, and shared error handling.
- `encryption.py`: Fernet-based credential payload encrypt/decrypt helpers. Reads `CREDENTIAL_ENCRYPTION_KEY` env var via `Settings.require_credential_encryption_key()`.
@@ -97,9 +100,10 @@
- app/repositories/
- Provides the repository protocol, repository factory, and concrete Postgres persistence implementation.
- Key methods added in bat-006: `get_portfolio_by_name_and_user(owner_user_id, name) -> Portfolio | None` for (owner_user_id, name) → portfolio_id (UUID) resolution used by CLI commands.
- Key methods added in BAT-007: `revoke_tokens_by_daemon_id(daemon_id) -> int` (UPDATE auth_tokens WHERE daemon_id=X AND revoked_at IS NULL); `list_service_tokens_with_null_daemon() -> list[AuthToken]` (NULL daemon_id active service token 조회); abstract methods added to `portfolio_repository.py`.
- app/schemas/
- Defines request and response schemas for HTTP contracts.
- `v1.py`: Contract schemas for v1 API (AssetItem, AssetsResponse, UserInfoResponse, BrokerItem, LedgerTradeRequest/Response, AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse, TagItem, TagCreateRequest, TagListResponse, TagApplyRequest, TagApplyResponse; tag schemas added by BAT-004).
- `v1.py`: Contract schemas for v1 API — TagItem, TagCreateRequest, TagListResponse, TagApplyRequest, TagApplyResponse (BAT-004); AssetItem, AssetsResponse, UserInfoResponse, BrokerItem, LedgerTradeRequest/Response (BAT-001/002); AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse (BAT-002); PortfolioCreateRequest, PortfolioUpdateRequest, PortfolioItem, PortfolioListResponse (BAT-009); PortfolioTagListResponse, PortfolioCommentItem, PortfolioCommentRequest, PortfolioCommentUpdateRequest, PortfolioCommentListResponse (BAT-010).
- app/services/
- Implements business logic for auth, portfolios, strategy sets, and users.
- migrations/
@@ -132,7 +136,7 @@
| 소유 경로 | 관리 객체 | 비고 |
|-----------|-----------|------|
| ensure_schema() 전용 | `idx_auth_tokens_service_name_unique` (partial index) | Alembic revision에 없음 |
| ensure_schema() 전용 | `idx_auth_tokens_service_name_unique` (partial index, BAT-006/REQ-017: `AND revoked_at IS NULL` 조건 추가) | Alembic revision 20260520_0022에도 CONCURRENTLY 버전 존재 |
| Alembic 전용 | `portfolios.market`, `auth_tokens.runtime_id`, `idx_auth_tokens_runtime_id_unique`, `broker_credentials`, `ledger_trades` | ensure_schema()에 없음 — v1 API 전제 조건 |
| 양쪽 공유 (IF NOT EXISTS 보호) | users, auth_tokens, strategy_sets, portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 대부분의 인덱스 | ensure_schema() 우선 실행 후 Alembic 실행 시 충돌 없음 |
@@ -162,6 +166,9 @@
## Change Log
- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: `v1_ledger.py` 제거; `v1_portfolios.py`에 `POST /{id}/ledger/trades` 흡수; `app/main.py` include 정리. Source Discovery/Batch 헤더 갱신.
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 반영: v1_portfolios.py 책임 갱신 (POST/PATCH/DELETE/GET /v1/portfolios + 태그/댓글 서브 라우트); v1.py schema 목록 갱신 (BAT-009/010 schema 5종 추가); Source Discovery/Batch 헤더 갱신.
- 2026-05-21 KST: BAT-008 (REQ-019) — 패키지 구조 갱신: service-token rebind/migrate-null-daemon CLI 서브커맨드 추가 반영; daemon command group 및 --deactivate auto-revoke 동작 반영; repositories에 revoke_tokens_by_daemon_id / list_service_tokens_with_null_daemon 메서드 추가 반영; Schema Ownership 표의 idx_auth_tokens_service_name_unique 조건 갱신(AND revoked_at IS NULL).
- 2026-05-20 KST: bat-005 — REQ-008 AC-3: added app/cli/commands/tag.py (pfhub tag command group); updated app/api/ to reflect v1_tags.py, v1_portfolios.py, v1_assets.py tag endpoints; updated app/schemas/v1.py tag schemas; updated pfhub entry point (tags); noted pfhub portfolio tag removal.
- 2026-05-11 KST: bat-007 — Added "Schema Ownership Boundary" section documenting ensure_schema() vs Alembic ownership zones, allowed/forbidden execution sequences, and legacy scope exclusion.
- 2026-05-11 KST: bat-006 — Added get_portfolio_by_name_and_user() to repositories section; noted that portfolio_id is now a server-generated UUID.
+194 -19
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed
- Owner: repository-maintainers
- Last Updated (KST): 2026-05-20 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010, bat-003, bat-004, bat-005
- Last Updated (KST): 2026-05-21 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004)
- Primary Runtime: mixed
## Project Summary
@@ -114,6 +114,43 @@
- If already inactive → return HTTP 200 immediately (idempotent).
- `repo.deactivate_tag(tag_id)` → HTTP 200 `TagItem`.
## Flow: `v1 API — Portfolio Mutation (POST/PATCH/DELETE /v1/portfolios)`
1. **POST /v1/portfolios** (create):
- Any valid token; `_require_write_token` blocks service/client (`token_type ∈ {service, client}` → HTTP 403).
- admin with `body.user_id` → creates portfolio for target user; otherwise uses `auth.user_id`.
- `repo.get_asset_by_id(asset_id)` → None or inactive → HTTP 422.
- `repo.get_or_create_strategy_set(strategy_name, strategy_params_json)` → resolves strategy.
- `repo.create_portfolio(...)` with `status="enabled"` → HTTP 201 `PortfolioItem`.
2. **PATCH /v1/portfolios/{portfolio_id}** (update):
- `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404(auth, portfolio_id, repo)` → checks ownership; non-owner non-admin → HTTP 403.
- If `portfolio.status == "disabled"`: non-status fields present → HTTP 409 (only status restoration allowed).
- `repo.update_portfolio(portfolio_id, **fields)` → HTTP 200 `PortfolioItem`.
3. **DELETE /v1/portfolios/{portfolio_id}** (soft delete):
- `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404` → ownership check.
- `repo.soft_delete_portfolio(portfolio_id)` → sets `status="disabled"`, `managed_by_daemon_id=NULL`.
- Dependent data (portfolio_comments, tag_ids) preserved. Portfolio still queryable with `?include_disabled=true`.
- Returns HTTP 200 `PortfolioItem` with `status="disabled"`.
## Flow: `v1 API — GET /v1/portfolios`
1. client token → HTTP 403. Any other valid token proceeds.
2. `service` token: `auth.daemon_id is None` → HTTP 403. `repo.list_portfolios(managed_by_daemon_id=auth.daemon_id, ...)`.
3. `user`/`admin` token: admin with `?user_id=<id>` → filter by that user; else filter by `auth.user_id`.
4. `?include_disabled=false` (default) → `status="enabled"` filter applied. `?include_disabled=true` → no status filter.
5. Returns HTTP 200 `PortfolioListResponse`.
## Flow: `v1 API — Soft Delete and Dependent Data Preservation`
1. `DELETE /v1/portfolios/{portfolio_id}` sets `portfolios.status="disabled"` and `portfolios.managed_by_daemon_id=NULL`.
2. `portfolio_comments` rows keyed by `portfolio_id` are NOT deleted; they remain retrievable via `GET /v1/portfolios/{id}/comments` even after soft delete.
3. `portfolios.tag_ids UUID[]` is NOT cleared; existing tag references are preserved.
4. Disabled portfolios are excluded from `GET /v1/portfolios` responses by default (`?include_disabled=false`).
5. Write mutations (PATCH non-status fields, POST/PATCH/DELETE tags, POST/PATCH/DELETE comments) on a disabled portfolio → HTTP 409. READ operations (GET tags, GET comments) remain allowed.
6. Status restoration: `PATCH /v1/portfolios/{id}` with `{"status": "enabled"}` is the only allowed mutation on a disabled portfolio; this re-enables the portfolio.
## Flow: `v1 API — Tag Apply/Remove (POST/DELETE /v1/assets/{id}/tags and /v1/portfolios/{id}/tags)`
1. **POST /v1/assets/{asset_id}/tags** (apply tag to asset — admin only):
@@ -127,18 +164,47 @@
- `repo.get(asset_id)` → ValueError → HTTP 404.
- `repo.remove_portfolio_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}`.
3. **POST /v1/portfolios/{portfolio_id}/tags** (apply tag to portfolio — owner or admin):
- DB token required; trade-write or admin permission.
- DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (`_require_write_token`).
- `repo.get(portfolio_id)` → ValueError → HTTP 404.
- Caller must be portfolio owner or admin; else → HTTP 403.
- `portfolio.status == "disabled"` → HTTP 409 (disabled portfolio write blocked).
- `repo.get_tag_by_id(tag_id)` → None → HTTP 404.
- `not tag.is_active` → HTTP 403.
- Policy: `tag.is_admin_tag OR tag.created_by_user_id == portfolio.owner_user_id`; else → HTTP 403.
- `repo.append_portfolio_tag_id(portfolio_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "applied"}`.
4. **DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}** (remove tag from portfolio — owner or admin):
- DB token required; trade-write or admin permission.
- DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (`_require_write_token`).
- `repo.get(portfolio_id)` → ValueError → HTTP 404.
- Caller must be portfolio owner or admin; else → HTTP 403.
- `portfolio.status == "disabled"` → HTTP 409.
- `repo.remove_portfolio_tag_id(portfolio_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}`.
5. **GET /v1/portfolios/{portfolio_id}/tags** (list applied tags — REQ-030, BAT-010):
- `require_read`; service/client → HTTP 403 (inline).
- `_get_portfolio_or_404(auth, portfolio_id, repo)` → ownership check; disabled portfolio allowed (GET).
- `repo.get_tags_by_ids(portfolio.tag_ids)` → dict; empty tag_ids → `PortfolioTagListResponse(tags=[], total=0)`.
- Returns HTTP 200 `PortfolioTagListResponse {"tags": [TagItem], "total": <int>}`.
## Flow: `v1 API — Portfolio Comment CRUD (REQ-031, BAT-010)`
1. **POST /v1/portfolios/{portfolio_id}/comments** (create):
- `require_read`; `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409.
- `repo.create_comment(portfolio_id, content=body.content)` → HTTP 201 `PortfolioCommentItem`.
2. **GET /v1/portfolios/{portfolio_id}/comments** (list):
- `require_read`; service/client → HTTP 403 (inline).
- `_get_portfolio_or_404` → ownership check; disabled portfolio GET is allowed.
- `repo.list_comments(portfolio_id)` → list in `created_at desc` order.
- Returns HTTP 200 `PortfolioCommentListResponse {"comments": [...], "total": <int>}`.
3. **PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}** (update):
- `require_read`; `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409.
- `repo.update_comment(portfolio_id, comment_id, content=body.content)` → ValueError → HTTP 404.
- Returns HTTP 200 `PortfolioCommentItem`.
4. **DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}** (delete):
- `require_read`; `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409.
- `repo.delete_comment(portfolio_id, comment_id)` → ValueError → HTTP 404.
- Returns HTTP 204 (no content).
## Flow: `v1 API — DB Token Authentication`
@@ -146,6 +212,8 @@
2. `require_read` / `require_admin` resolves the token: checks env tokens first, then DB lookup via `get_token_principal()`.
3. Env tokens → HTTP 403 (all /v1 endpoints require DB tokens).
4. `AuthContext` is populated with `user_id`, `token_type`, `permission`, `runtime_id`, and `daemon_id`.
- `token_type`: "session" (login) | "service" (daemon-bound) — "client" type 발급 제거됨 (REQ-016/BAT-006)
- `daemon_id`: service token에만 존재; NULL이면 daemon-scoped endpoint에서 HTTP 403 fail-closed
## Flow: `v1 API — GET /v1/assets`
@@ -175,25 +243,28 @@
4. For each credential, Fernet-decrypts `encrypted_payload` using `CREDENTIAL_ENCRYPTION_KEY`; decryption failures log a warning and return `credential_payload: null`.
5. Returns `UserInfoResponse` with `trading_enabled` (from `is_active`), `disabled_reason`, `brokers[]`, `runtime_id` (null if unbound), and `daemon_id` (null for legacy/unbound tokens — diagnostic, not an error).
## Flow: `v1 API — POST /v1/assets/{asset_id}/ledger/trades`
## Flow: `v1 API — POST /v1/portfolios/{portfolio_id}/ledger/trades` (REQ-029, BAT-011)
1. Service-token auth required with `trade-write` permission.
2. Validates asset existence via `repo.get(asset_id)`; HTTP 404 if missing.
3. Daemon assignment authorization (fail-closed, three checks in order):
a. `auth.daemon_id is None` → HTTP 403 ("daemon_id not assigned to this token").
b. `portfolio.managed_by_daemon_id is None` → HTTP 403 ("Asset is not assigned to any daemon").
c. `portfolio.managed_by_daemon_id != auth.daemon_id` → HTTP 403 ("Token daemon does not match asset daemon assignment").
4. Calls `insert_ledger_trade()` with idempotency key `(asset_id, order_id, execution_id)`.
5. If new record: returns HTTP 201 with `status="new"`.
6. If duplicate key with identical payload: returns HTTP 200 with `status="duplicate"`.
7. If duplicate key with different payload: returns HTTP 409 with conflict detail.
1. Service-token auth required with `trade-write` permission (`_require_service_token` dependency).
2. `token_source != "db"` or `token_type != "service"` → HTTP 403.
3. `auth.daemon_id is None` → HTTP 403 fail-closed ("daemon_id not assigned to this token").
4. `repo.get(portfolio_id)` → exception → HTTP 404.
5. `portfolio.status == "disabled"` → HTTP 409 (disabled write 차단, REQ-027 정합).
6. `portfolio.managed_by_daemon_id is None` → HTTP 403 ("Asset is not assigned to any daemon").
7. `portfolio.managed_by_daemon_id != auth.daemon_id` → HTTP 403 ("Token daemon does not match asset daemon assignment").
8. `insert_ledger_trade(asset_id=portfolio_id, ...)` with idempotency key `(portfolio_id, order_id, execution_id)`.
9. New record → HTTP 201 + `status="new"`.
10. Duplicate key + identical payload → HTTP 200 + `status="duplicate"`.
11. Duplicate key + different payload → HTTP 409.
- 구 path `POST /v1/assets/{asset_id}/ledger/trades`: BAT-011에서 제거. 호출 시 HTTP 404.
## Flow: `v1 API — POST /auth/service-tokens`
1. Admin token auth required (`require_admin` dependency).
2. Validates request body: `name`, `username`, `permission`, `expires_in_days` / `no_expiry`.
3. Calls `AuthService.issue_service_token()` which creates a new DB token with `runtime_id`.
4. Returns HTTP 201 with `token` (raw value), `runtime_id`, and `auth_token` metadata.
2. Validates request body: `name`, `username`, `permission`, `expires_in_days` / `no_expiry`, `daemon_id` (optional; BAT-006/REQ-018 forward 보정).
3. If `daemon_id` provided: `get_daemon_by_id()` → 존재하지 않거나 inactive → HTTP 422. daemon.owner_user_id ≠ 발급 대상 user → HTTP 422.
4. Calls `AuthService.issue_service_token()` which creates a new DB token with `runtime_id` and `daemon_id`.
5. Returns HTTP 201 with `token` (raw value), `runtime_id`, `daemon_id`, and `auth_token` metadata.
## Flow: `Daemon Registry — Create and Bind Service Token`
@@ -206,7 +277,25 @@
- `create_auth_token()` persists the token with `daemon_id` set.
3. Operator assigns an asset to the daemon via portfolio update (`managed_by_daemon_id` column set).
4. `GET /v1/assets` with the bound token returns only assets where `portfolios.managed_by_daemon_id = token.daemon_id`.
5. `POST /v1/assets/{asset_id}/ledger/trades` with the bound token passes daemon assignment authorization and records the trade.
5. `POST /v1/portfolios/{portfolio_id}/ledger/trades` with the bound token passes daemon assignment authorization and records the trade (신 path, REQ-029).
## Flow: `pfhub service-token rebind` (BAT-007/REQ-023)
1. Operator calls `pfhub service-token rebind <name> --daemon-id <daemon_id>`.
2. `AuthService.rebind_service_token(name, daemon_id)`:
a. `get_service_token_by_name(name)` → None → ValueError.
b. `get_daemon_by_id(daemon_id)` → None 또는 is_active=false → ValueError.
c. `daemon.owner_user_id ≠ token.user_id` → ValueError ("해당 토큰 사용자에 속하지 않습니다").
d. `repository.rebind_service_token(name, daemon_id)` → token.daemon_id 갱신, runtime_id 불변.
3. Returns `(previous_daemon_id, rebound_token)`.
## Flow: `pfhub daemon update --deactivate` (BAT-007/REQ-022)
1. Operator calls `pfhub daemon update <daemon_id> --deactivate [--force]`.
2. `--force` 없으면 확인 프롬프트; 거절 시 exit 0.
3. `repo.update_daemon(daemon_id, is_active=False)` — daemon is_active=false 설정.
4. `repo.revoke_tokens_by_daemon_id(daemon_id)` — 바인딩된 모든 active service token에 `revoked_at=NOW()` 설정.
5. Console에 revoked count 출력.
## Flow: `Service-Token Rotate — daemon_id Invariant`
@@ -273,6 +362,89 @@
4. **허용 실행 순서**: `init-db` 선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시 `migrate-db` 후속 실행.
5. **금지**: `alembic_version` 테이블 수동 삭제 후 migrate-db 재실행 (legacy stamp 오판).
## Token Expiry and Rotation Policy
(REQ-021/BAT-008 — dcy-004 FR-6, OQ-5 결정: 현행 TTL 유지)
### Type별 기본 TTL
| Token Type | 기본 만료 | no_expiry 허용 | 비고 |
|------------|-----------|----------------|------|
| session | `NOW() + settings.session_token_ttl_hours` | ✗ (거부) | `AuthService.login()` |
| service | `NOW() + 365일` (기본) | ✓ | `AuthService.issue_service_token(no_expiry=True)` |
| env | 없음 (TTL 미적용) | N/A | `PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수 |
### no_expiry 허용 범위
- service token: `pfhub service-token issue <name> --no-expiry` 또는 POST /auth/service-tokens body `"no_expiry": true` → expires_at=NULL 발급.
- session: no_expiry 요청 불가. 요청 시 → ValueError / HTTP 422 거부.
- ⚠ 현행 코드에서 session 경로에 no_expiry 파라미터가 없어 코드 level 거부가 미구현. 정책은 거부이며 follow-up으로 코드 enforcement 추가 예정.
- `no_expiry`와 `expires_in_days`를 동시에 지정하면 ValueError ("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다").
### 강제 회전 주기
- session / service: 강제 회전 주기 없음.
- env admin token: 분기(3개월) 권장 회전, 강제 없음 (아래 SOP 참조).
### 정책 위반 처리
- no_expiry를 session에 요청 → 정책상 HTTP 422 또는 ValueError 거부 (미구현 — follow-up 필요).
- 코드 reference: `app/services/auth_service.py` `AuthService.issue_service_token()` no_expiry + expires_in_days 분기 검증.
## env admin token 운영 SOP
(REQ-024/BAT-008 — dcy-004 FR-9, OQ-6 결정: 분기 권장 회전, 강제 없음)
### 적용 범위
`PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수로 주입되는 admin token. dev/test/prod 모든 환경에 적용. prod 환경 약한 placeholder 발견 시 즉시 적용.
### 권장 회전 주기
분기(3개월). 강제 회전·만료 알람 없음. 운영자 자율 결정.
### 회전 트리거 조건
- (a) 정기: 분기마다
- (b) token leak 의심
- (c) 운영자 교체·퇴사
- (d) 보안 사고 대응
### 다단계 회전 절차 (7단계)
1. 강한 무작위 token 생성:
```
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
```
2. `PORTFOLIOHUB_ADMIN_TOKENS`에 신규 token을 콤마 분리로 **추가** (다중 token 동시 운영):
```
PORTFOLIOHUB_ADMIN_TOKENS=<구_token>,<신규_token>
```
3. service rolling restart (구 token 계속 유효).
4. 신규 token으로 admin 호출 검증 (예: `GET /health`, admin 작업 sanity check).
5. 구 token을 `PORTFOLIOHUB_ADMIN_TOKENS`에서 **제거**:
```
PORTFOLIOHUB_ADMIN_TOKENS=<신규_token>
```
6. service rolling restart (구 token 무효화).
7. evidence 기록: 회전 일자, 운영자, 신규 token 강도 확인, sanity check 결과.
### rollback 절차
- 회전 실패 시: 환경 변수에 구 token 재추가 후 rolling restart.
- fallback admin 접근 경로: DB 직접 접근으로 임시 token 재생성 (비상 시).
### Token 강도 가이드
- 최소 48-byte URL-safe 무작위 (`secrets.token_urlsafe(48)` 이상).
- 평문 단어, 예측 가능한 패턴 (예: `admin-token-789`) 금지.
- dev/test 환경에서도 약한 placeholder 사용 금지.
### 운영자 권한
- `PORTFOLIOHUB_ADMIN_TOKENS` 추가/제거는 인프라 운영자 권한.
- 현행 코드(`app/core/auth.py:_resolve_permission`)가 set 기반으로 다중 token 처리 → 추가 코드 변경 없이 다중 token 동시 운영 가능.
## Current Gaps / Planned Changes
- End-to-end validation against a real PostgreSQL or TimescaleDB deployment is still pending.
@@ -281,6 +453,9 @@
## Change Log
- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 흐름 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체 (disabled 차단 + 인가 순서 명확화); Daemon Registry flow의 구 path 참조 갱신.
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 흐름 추가: "Portfolio Mutation" 흐름 신규 추가(REQ-025~028; ownership + disabled 보호 + write token 차단); "GET /v1/portfolios" token 분기 흐름 신규 추가(REQ-028); "Soft Delete and Dependent Data Preservation" 흐름 신규 추가(REQ-027; disabled 의미 + 의존 데이터 보존); POST/DELETE /v1/portfolios/{id}/tags에 disabled(409) + service/client(403) 보강(REQ-030); "GET /v1/portfolios/{id}/tags" 흐름 추가(REQ-030); "Portfolio Comment CRUD" 흐름 신규 추가(REQ-031); Source Discovery/Batch 헤더 갱신.
- 2026-05-21 KST: BAT-008 (REQ-019/021/024) — 토큰 surface 정합성 회복 + 신규 정책 섹션: DB Token Auth flow에 AuthContext token_type/daemon_id 설명 보강(REQ-019); POST /auth/service-tokens daemon_id forward 흐름 갱신(REQ-018/019); service-token rebind 흐름 추가(REQ-023); daemon update --deactivate 자동 revoke 흐름 추가(REQ-022); "Token Expiry and Rotation Policy" 섹션 신규 추가(REQ-021); "env admin token 운영 SOP" 섹션 신규 추가(REQ-024).
- 2026-05-20 KST: bat-005 — REQ-008 AC-4: added pfhub tag CLI flow; added v1 tag management flow (POST/GET/PATCH /v1/tags); added v1 tag apply/remove flow (/v1/assets/{id}/tags, /v1/portfolios/{id}/tags); updated Covered Entry Points (tags).
- 2026-05-13 KST: bat-010 — REQ-006/007/008/009/023/005/012: updated GET /v1/assets flow (daemon-id guard, daemon-scoped filter); updated GET /v1/user_info flow (daemon_id diagnostic, NULL → 200+null); updated POST ledger/trades flow (3-step daemon assignment auth, fail-closed); added "Daemon Registry — Create and Bind Service Token" flow; added "Service-Token Rotate — daemon_id Invariant" flow; updated Alembic/ensure_schema boundary (BAT-010 migration 20260513_0016); updated Covered Entry Points (daemon subcommand).
- 2026-05-11 KST: bat-008 — REQ-011 AC-3 confirmed: v1 API flows and runtime_id issuance flow already present from bat-001; Source Discovery/Batch header updated.
@@ -1,6 +1,6 @@
# REQ-019: 토큰 surface baseline 문서 정합성 회복
- Status: Approved
- Status: Implemented
- Type: Documentation
- Priority: Medium
- Owner: Justin Ko
@@ -51,6 +51,32 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-008 confirm-batch-verification 완료: 모든 AC(AC-1~5) 검증됨(grep + 사람 리뷰). verification.md Status: release-candidate.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-008 (release-candidate)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: 없음
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-020: 토큰 발급 권한 매트릭스 정의
- Status: Approved
- Status: Implemented
- Type: Documentation
- Priority: Medium
- Owner: Justin Ko
@@ -34,6 +34,32 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-008 confirm-batch-verification 완료: AC-1~4 모두 검증됨(grep + 사람 리뷰). docs/interface-contract.md에 "Token Issuance Authorization Matrix" 섹션 신규 추가 확인.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-008 (release-candidate)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: 없음
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-021: 토큰 만료·회전 정책 정의
- Status: Approved
- Status: Implemented
- Type: Documentation
- Priority: Medium
- Owner: Justin Ko
@@ -39,6 +39,32 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-008 confirm-batch-verification 완료: AC-1~5 검증됨(grep + 사람 리뷰). docs/runtime-flows.md에 "Token Expiry and Rotation Policy" 섹션 신규 추가 확인. AC-4(session no_expiry 코드 enforcement) 미구현 → follow-up 기록됨.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-008 (release-candidate)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: session no_expiry 코드 enforcement 미구현 — 별도 follow-up REQ 또는 다음 batch에서 처리
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-024: env admin token 운영 SOP 정의
- Status: Approved
- Status: Implemented
- Type: Documentation
- Priority: Medium
- Owner: Justin Ko
@@ -48,6 +48,32 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-008 confirm-batch-verification 완료: AC-1~4 검증됨(grep + 사람 리뷰). docs/runtime-flows.md에 "env admin token 운영 SOP" 섹션 신규 추가 확인. AC-5(staging dry-run)는 release plan verification 영역으로 분리됨.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-008 (release-candidate)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: AC-5(staging dry-run)는 release plan verification 단계에서 수행
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-032: portfolio v1 baseline 문서 갱신
- Status: Approved
- Status: Implemented
- Type: Documentation
- Priority: Medium
- Owner: Justin Ko
@@ -57,6 +57,33 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-010 구현 완료: baseline 4종 일괄 갱신 — `docs/interface-contract.md` (v1 Portfolio API 섹션 신설; tag/comment 서브 라우트 명세; ledger "pending REQ-029" 마킹); `docs/runtime-flows.md` (portfolio mutation flow, GET 분기 flow, soft delete flow, comment CRUD flow 추가); `docs/data-model.md` (Portfolio.status='disabled' 의미 + 의존 데이터 보존); `docs/project-structure.md` (v1_portfolios.py + v1.py 갱신)
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-010 (release-candidate)
- Releases: pending
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: BAT-010 release candidate → draft-release
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-025: POST /v1/portfolios 신설 (토큰 분기 + catalog 검증)
- Status: Approved
- Status: Implemented
- Type: Interface
- Priority: High
- Owner: Justin Ko
@@ -54,6 +54,32 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-009 confirm-batch-verification 완료. tests/test_v1_portfolios_crud.py AC-1~7 전 케이스 pytest 통과 (2026-05-21, 298 PASSED).
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-009 (release-candidate)
- Releases: TBD
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: 없음
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-026: PATCH /v1/portfolios/{portfolio_id} 신설
- Status: Approved
- Status: Implemented
- Type: Interface
- Priority: High
- Owner: Justin Ko
@@ -51,6 +51,32 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-009 confirm-batch-verification 완료. tests/test_v1_portfolios_crud.py AC-1~9 전 케이스 pytest 통과 (2026-05-21, 298 PASSED).
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-009 (release-candidate)
- Releases: TBD
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: 없음
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-027: DELETE /v1/portfolios/{portfolio_id} 신설 (soft delete)
- Status: Approved
- Status: Implemented
- Type: Interface
- Priority: High
- Owner: Justin Ko
@@ -62,6 +62,32 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-009 confirm-batch-verification 완료. tests/test_v1_portfolios_crud.py AC-1~10 전 케이스 pytest 통과 (2026-05-21, 298 PASSED). soft_delete_portfolio: status='disabled', managed_by_daemon_id=NULL, 의존 데이터 보존.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-009 (release-candidate)
- Releases: TBD
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: 없음
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-028: GET /v1/portfolios 신설 (토큰 분기 조회)
- Status: Approved
- Status: Implemented
- Type: Interface
- Priority: High
- Owner: Justin Ko
@@ -57,6 +57,32 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-009 confirm-batch-verification 완료. tests/test_v1_portfolios_list.py AC-1~11 전 케이스 pytest 통과 (2026-05-21, 298 PASSED). 토큰 분기 + status filter + daemon filter 모두 검증.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-009 (release-candidate)
- Releases: TBD
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: 없음
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-029: POST /v1/portfolios/{portfolio_id}/ledger/trades 이전
- Status: Approved
- Status: Implemented
- Type: Interface
- Priority: High
- Owner: Justin Ko
@@ -53,6 +53,32 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-011 구현 완료: `app/api/v1_portfolios.py`에 `POST /{portfolio_id}/ledger/trades` 신설 (daemon 인가 3단계 + disabled 409 + idempotency 보존); `app/api/v1_ledger.py` 삭제; `app/main.py` include 정리; `tests/test_v1_portfolios_ledger.py` AC-1~10 신규; `tests/test_v1.py` + `tests/test_daemon_assignment.py` 구 path 갱신
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-011 (release-candidate)
- Releases: pending
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: BAT-011 release candidate → draft-release; trex-basic 동시 배포 순서 잠금 필수
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
@@ -70,14 +96,14 @@
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Batches: BAT-011 (in-delivery)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
- Follow-up Action: BAT-011 draft-batch-planning 완료 → draft-batch-design 진행
### CHG-20260520-01
- Date: 2026-05-20
@@ -1,6 +1,6 @@
# REQ-030: portfolio tag 적용/해제/조회 v1 라우트
- Status: Approved
- Status: Implemented
- Type: Interface
- Priority: Medium
- Owner: Justin Ko
@@ -57,6 +57,32 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-010 구현 완료: `app/api/v1_portfolios.py`에 GET /v1/portfolios/{id}/tags 신설 + POST/DELETE 라우트에 disabled 409 + service/client 403 보강; `app/schemas/v1.py`에 `PortfolioTagListResponse` 추가; `tests/test_v1_portfolios_tags_comments.py` 32 tests (AC-1~11 커버); baseline 4종 갱신(REQ-032)
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-010 (release-candidate)
- Releases: pending
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: BAT-010 release candidate → draft-release
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-031: portfolio comment CRUD v1 라우트
- Status: Approved
- Status: Implemented
- Type: Interface
- Priority: Medium
- Owner: Justin Ko
@@ -54,6 +54,32 @@
## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-010 구현 완료: `app/api/v1_portfolios.py`에 POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments 4종 신설; `app/schemas/v1.py`에 comment schema 4종 추가; `tests/test_v1_portfolios_tags_comments.py` AC-1~11 커버; comment 작성자 ownership은 portfolio ownership만 적용(design decision #3)
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-010 (release-candidate)
- Releases: pending
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: BAT-010 release candidate → draft-release
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
+15 -12
View File
@@ -99,23 +99,26 @@
| REQ-016 | client token 발급 경로 코드 폐지 | Interface | Approved | High | Justin Ko | [req-016](./Interface/req-016_client-token-issuance-deprecation.md) |
| REQ-017 | service-token rotate unique index 보강 | Migration | Approved | High | Justin Ko | [req-017](./Migration/req-017_service-token-rotate-unique-index-fix.md) |
| REQ-018 | POST /auth/service-tokens body daemon_id forward 보정 | Interface | Approved | High | Justin Ko | [req-018](./Interface/req-018_service-token-http-daemon-id-forward.md) |
| REQ-019 | 토큰 surface baseline 문서 정합성 회복 | Documentation | Approved | Medium | Justin Ko | [req-019](./Documentation/req-019_token-surface-baseline-docs-consistency.md) |
| REQ-020 | 토큰 발급 권한 매트릭스 정의 | Documentation | Approved | Medium | Justin Ko | [req-020](./Documentation/req-020_token-issuance-authorization-matrix.md) |
| REQ-021 | 토큰 만료·회전 정책 정의 | Documentation | Approved | Medium | Justin Ko | [req-021](./Documentation/req-021_token-expiry-and-rotation-policy.md) |
| REQ-019 | 토큰 surface baseline 문서 정합성 회복 | Documentation | Implemented | Medium | Justin Ko | [req-019](./Documentation/req-019_token-surface-baseline-docs-consistency.md) |
| REQ-020 | 토큰 발급 권한 매트릭스 정의 | Documentation | Implemented | Medium | Justin Ko | [req-020](./Documentation/req-020_token-issuance-authorization-matrix.md) |
| REQ-021 | 토큰 만료·회전 정책 정의 | Documentation | Implemented | Medium | Justin Ko | [req-021](./Documentation/req-021_token-expiry-and-rotation-policy.md) |
| REQ-022 | daemon deactivation 시 바인딩 service token 자동 revoke | Interface | Implemented | High | Justin Ko | [req-022](./Interface/req-022_daemon-deactivation-auto-revoke.md) |
| REQ-023 | NULL daemon_id legacy token 마이그레이션 CLI | Interface | Implemented | Medium | Justin Ko | [req-023](./Interface/req-023_null-daemon-token-migration-cli.md) |
| REQ-024 | env admin token 운영 SOP 정의 | Documentation | Approved | Medium | Justin Ko | [req-024](./Documentation/req-024_env-admin-token-rotation-sop.md) |
| REQ-025 | POST /v1/portfolios 신설 (토큰 분기 + catalog 검증) | Interface | Approved | High | Justin Ko | [req-025](./Interface/req-025_v1-portfolios-create.md) |
| REQ-026 | PATCH /v1/portfolios/{portfolio_id} 신설 | Interface | Approved | High | Justin Ko | [req-026](./Interface/req-026_v1-portfolios-update.md) |
| REQ-027 | DELETE /v1/portfolios/{portfolio_id} 신설 (soft delete) | Interface | Approved | High | Justin Ko | [req-027](./Interface/req-027_v1-portfolios-soft-delete.md) |
| REQ-028 | GET /v1/portfolios 신설 (토큰 분기 조회) | Interface | Approved | High | Justin Ko | [req-028](./Interface/req-028_v1-portfolios-list.md) |
| REQ-029 | POST /v1/portfolios/{portfolio_id}/ledger/trades 이전 | Interface | Approved | High | Justin Ko | [req-029](./Interface/req-029_v1-portfolios-ledger-trades-migration.md) |
| REQ-030 | portfolio tag 적용/해제/조회 v1 라우트 | Interface | Approved | Medium | Justin Ko | [req-030](./Interface/req-030_v1-portfolios-tags-crud.md) |
| REQ-031 | portfolio comment CRUD v1 라우트 | Interface | Approved | Medium | Justin Ko | [req-031](./Interface/req-031_v1-portfolios-comments-crud.md) |
| REQ-032 | portfolio v1 baseline 문서 갱신 | Documentation | Approved | Medium | Justin Ko | [req-032](./Documentation/req-032_portfolio-v1-baseline-docs-update.md) |
| REQ-024 | env admin token 운영 SOP 정의 | Documentation | Implemented | Medium | Justin Ko | [req-024](./Documentation/req-024_env-admin-token-rotation-sop.md) |
| REQ-025 | POST /v1/portfolios 신설 (토큰 분기 + catalog 검증) | Interface | Implemented | High | Justin Ko | [req-025](./Interface/req-025_v1-portfolios-create.md) |
| REQ-026 | PATCH /v1/portfolios/{portfolio_id} 신설 | Interface | Implemented | High | Justin Ko | [req-026](./Interface/req-026_v1-portfolios-update.md) |
| REQ-027 | DELETE /v1/portfolios/{portfolio_id} 신설 (soft delete) | Interface | Implemented | High | Justin Ko | [req-027](./Interface/req-027_v1-portfolios-soft-delete.md) |
| REQ-028 | GET /v1/portfolios 신설 (토큰 분기 조회) | Interface | Implemented | High | Justin Ko | [req-028](./Interface/req-028_v1-portfolios-list.md) |
| REQ-029 | POST /v1/portfolios/{portfolio_id}/ledger/trades 이전 | Interface | Implemented | High | Justin Ko | [req-029](./Interface/req-029_v1-portfolios-ledger-trades-migration.md) |
| REQ-030 | portfolio tag 적용/해제/조회 v1 라우트 | Interface | Implemented | Medium | Justin Ko | [req-030](./Interface/req-030_v1-portfolios-tags-crud.md) |
| REQ-031 | portfolio comment CRUD v1 라우트 | Interface | Implemented | Medium | Justin Ko | [req-031](./Interface/req-031_v1-portfolios-comments-crud.md) |
| REQ-032 | portfolio v1 baseline 문서 갱신 | Documentation | Implemented | Medium | Justin Ko | [req-032](./Documentation/req-032_portfolio-v1-baseline-docs-update.md) |
## Recent Change Log Summary
- 2026-05-21: REQ-030/031/032 Approved → Implemented — BAT-010 confirm-batch-verification 완료. 330/330 PASSED (32 신규 tests in test_v1_portfolios_tags_comments.py). GET /v1/portfolios/{id}/tags 신설; POST/DELETE tag disabled 409 + service/client 403 보강; comment CRUD 4종 신설; baseline 4종 갱신(interface-contract/runtime-flows/data-model/project-structure).
- 2026-05-21: REQ-025/026/027/028 Approved → Implemented — BAT-009 confirm-batch-verification 완료. 298 PASSED (0 failed). test_v1_portfolios_crud.py 22건(REQ-025/026/027), test_v1_portfolios_list.py 10건(REQ-028) 전 AC 통과.
- 2026-05-21: REQ-019/020/021/024 Approved → Implemented — BAT-008 confirm-batch-verification 완료. 모든 grep AC 통과, 사람 리뷰 완료. REQ-021 follow-up: session no_expiry 코드 enforcement 미구현 → 별도 처리. REQ-024 AC-5: staging dry-run은 release plan 단계로 이관.
- 2026-05-20: REQ-016~032 (17건) 일괄 Proposed → Approved 전환 — confirm-req 무인자 일괄 모드 실행, 승인 게이트(필수 필드/AC 구체성/Change Log) 모두 충족, blocker 없음. 각 REQ에 CHG-20260520-02 entry 추가
- 2026-05-20: REQ-025~032 최초 생성 — dcy-003 (User Portfolio Mutation v1 API 이전, confirmed 2026-05-20) 후 draft-req 실행. POST/PATCH/DELETE/GET /v1/portfolios + ledger path 이전 + tag/comment v1 + baseline 정합성 (FR-1~10 분해, FR-9는 REQ-013 Deprecated로 이미 처리됨)
- 2026-05-20: REQ-016~024 최초 생성 — dcy-004 (토큰 발급 및 관리 전면 정비, confirmed 2026-05-20) 후 draft-req 실행. client token 폐지 / rotate index 보강 / HTTP daemon_id forward / baseline 정합성 / 발급 권한 매트릭스 / TTL·회전 정책 / daemon deactivation 자동 revoke / NULL daemon 마이그레이션 CLI / env admin SOP