diff --git a/.claude/commands/bootstrap-baseline.md b/.claude/commands/bootstrap-baseline.md deleted file mode 100644 index 2e6e18f..0000000 --- a/.claude/commands/bootstrap-baseline.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/bootstrap-baseline/SKILL.md` exactly. diff --git a/.claude/commands/capture-release-feedback.md b/.claude/commands/capture-release-feedback.md deleted file mode 100644 index 0604399..0000000 --- a/.claude/commands/capture-release-feedback.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/capture-release-feedback/SKILL.md` exactly. diff --git a/.claude/commands/change-req.md b/.claude/commands/change-req.md deleted file mode 100644 index 743e120..0000000 --- a/.claude/commands/change-req.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/change-req/SKILL.md` exactly. diff --git a/.claude/commands/confirm-batch-verification.md b/.claude/commands/confirm-batch-verification.md deleted file mode 100644 index 286652b..0000000 --- a/.claude/commands/confirm-batch-verification.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/confirm-batch-verification/SKILL.md` exactly. diff --git a/.claude/commands/confirm-discovery.md b/.claude/commands/confirm-discovery.md deleted file mode 100644 index f5767be..0000000 --- a/.claude/commands/confirm-discovery.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/confirm-discovery/SKILL.md` exactly. diff --git a/.claude/commands/confirm-release.md b/.claude/commands/confirm-release.md deleted file mode 100644 index 6b7b2a0..0000000 --- a/.claude/commands/confirm-release.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/confirm-release/SKILL.md` exactly. diff --git a/.claude/commands/confirm-req-implemented.md b/.claude/commands/confirm-req-implemented.md deleted file mode 100644 index 7ac5496..0000000 --- a/.claude/commands/confirm-req-implemented.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/confirm-req-implemented/SKILL.md` exactly. diff --git a/.claude/commands/confirm-req.md b/.claude/commands/confirm-req.md deleted file mode 100644 index c55db18..0000000 --- a/.claude/commands/confirm-req.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/confirm-req/SKILL.md` exactly. diff --git a/.claude/commands/draft-batch-design.md b/.claude/commands/draft-batch-design.md deleted file mode 100644 index b300379..0000000 --- a/.claude/commands/draft-batch-design.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/draft-batch-design/SKILL.md` exactly. diff --git a/.claude/commands/draft-batch-planning.md b/.claude/commands/draft-batch-planning.md deleted file mode 100644 index 92f505e..0000000 --- a/.claude/commands/draft-batch-planning.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/draft-batch-planning/SKILL.md` exactly. diff --git a/.claude/commands/draft-batch-verification.md b/.claude/commands/draft-batch-verification.md deleted file mode 100644 index 34cd2c2..0000000 --- a/.claude/commands/draft-batch-verification.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/draft-batch-verification/SKILL.md` exactly. diff --git a/.claude/commands/draft-batch.md b/.claude/commands/draft-batch.md deleted file mode 100644 index cf0e34a..0000000 --- a/.claude/commands/draft-batch.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/draft-batch/SKILL.md` exactly. diff --git a/.claude/commands/draft-release.md b/.claude/commands/draft-release.md deleted file mode 100644 index b8a0266..0000000 --- a/.claude/commands/draft-release.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/draft-release/SKILL.md` exactly. diff --git a/.claude/commands/draft-req.md b/.claude/commands/draft-req.md deleted file mode 100644 index 373dccd..0000000 --- a/.claude/commands/draft-req.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/draft-req/SKILL.md` exactly. diff --git a/.claude/commands/new-discovery.md b/.claude/commands/new-discovery.md deleted file mode 100644 index 8e73514..0000000 --- a/.claude/commands/new-discovery.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/new-discovery/SKILL.md` exactly. diff --git a/.claude/commands/review-discovery.md b/.claude/commands/review-discovery.md deleted file mode 100644 index e459ab1..0000000 --- a/.claude/commands/review-discovery.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/review-discovery/SKILL.md` exactly. diff --git a/.claude/commands/run-batch-implementation.md b/.claude/commands/run-batch-implementation.md deleted file mode 100644 index fa4fe14..0000000 --- a/.claude/commands/run-batch-implementation.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/run-batch-implementation/SKILL.md` exactly. diff --git a/.claude/commands/run-sdlc.md b/.claude/commands/run-sdlc.md deleted file mode 100644 index 7519b14..0000000 --- a/.claude/commands/run-sdlc.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/run-sdlc/SKILL.md` exactly. diff --git a/.claude/commands/suggest-batch-reqs.md b/.claude/commands/suggest-batch-reqs.md deleted file mode 100644 index 5f1c5f0..0000000 --- a/.claude/commands/suggest-batch-reqs.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/suggest-batch-reqs/SKILL.md` exactly. diff --git a/.claude/commands/suggest-next-discovery.md b/.claude/commands/suggest-next-discovery.md deleted file mode 100644 index b6da810..0000000 --- a/.claude/commands/suggest-next-discovery.md +++ /dev/null @@ -1 +0,0 @@ -Follow the instructions in `.github/skills/suggest-next-discovery/SKILL.md` exactly. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 00ef918..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,80 +0,0 @@ -# Copilot Instructions - -이 파일은 호스트 프로젝트 고유 규칙과 StagePilot 규칙을 함께 담는다. - -## Host Project Rules - -호스트 프로젝트 전용 규칙은 이 섹션에 작성한다. - -## StagePilot - -# SDLC Governance Instructions - -이 워크스페이스의 AI 에이전트는 Agile 중심 반복형 SDLC를 따른다. -모든 작업은 3-phase 구조와 그 안의 단위별 게이트를 기준으로 수행하며, 임의 단계 스킵은 허용되지 않는다(긴급 핫픽스 예외는 별도 규칙 적용). - -## Lifecycle Phases - -1. Requirements -2. Delivery -3. Release & Feedback - -## Governing Units - -1. Discovery: 문제 정의 단위 -2. REQ: 승인 가능한 요구사항 단위 -3. Batch: 함께 계획, 설계, 구현, 검증하는 delivery 단위 -4. Release: 함께 배포하고 운영 환류를 기록하는 단위 - -## Bootstrap Baseline Path - -- 프로젝트 시작 시 baseline 문서와 active index는 `bootstrap-baseline`으로 먼저 초기화한다. -- `bootstrap-baseline`은 3-phase lifecycle 밖의 bootstrap 단계이며 별도 governance unit가 아니다. -- 첫 real Discovery는 baseline 초기화 이후 실제 제품/서비스 변경 주제로 시작한다. - -## Default Flow - -1. Bootstrap: `bootstrap-baseline` (baseline 문서와 active index가 비어 있을 때만) -2. Requirements: `new-discovery` -> `confirm-discovery` -> `draft-req` -> `confirm-req` -> `suggest-batch-reqs` -> `draft-batch` -3. Delivery: `draft-batch-planning` -> `draft-batch-design` -> `run-batch-implementation` -> `draft-batch-verification` -> `confirm-batch-verification` -> `confirm-req-implemented` -4. Release & Feedback: `draft-release` -> `confirm-release` -> `capture-release-feedback` - -## Lightweight Change Path - -- 단일 저위험 `Approved` REQ이고 구조/인터페이스/런타임 흐름 영향이 없으면 `minor-change` fast path를 사용할 수 있다. -- 이 경로는 `suggest-batch-reqs`를 생략하고 `draft-batch`로 바로 `batch-lite` batch를 만든다. -- `batch-lite`는 planning부터 시작하고, design은 필요할 때만 생성한다. -- release는 `docs-only`, `tooling`, `app-service` profile 중 하나를 선택해 검증 강도를 조정한다. - -## Change Management Extension - -- 기존 REQ 본문을 수정할 때는 `change-req`를 사용해 Change Log 근거를 먼저 남긴다. -- release feedback의 후속 입력은 `Discovery Input`, `REQ Input`, `Change Request Input`으로 분리해 기록한다. -- 다음 반복 후보만 먼저 정리하려면 `suggest-next-discovery`를 사용하고, 실제 문서 생성은 `new-discovery`로 이어 간다. - -## Stage Mapping - -- Discovery는 Requirements phase 안에서 유지한다. -- Planning, Design, Implementation, Verification은 Batch 내부 stage로 다룬다. -- Release와 Operations는 Release & Feedback phase 내부에서 다룬다. -- `review-*` stage command는 더 이상 active flow에 포함하지 않고 대응 `confirm-*` 절차에 흡수한다. -- prompt 기반 stage command는 active 경로에서 사용하지 않고 `.github/skills/` 아래 skill entrypoint만 사용한다. - -## Global Rules - -1. 증거 기반: 요구사항, 가정, 리스크, 결정 근거를 문서에 남긴다. -2. 게이트 준수: 각 phase와 단위의 DoR(진입 조건)과 DoD(완료 조건)을 충족해야 다음 단계로 이동한다. -3. 테스트 우선 검증: 구현 결과는 테스트 또는 검증 근거 없이 완료로 간주하지 않는다. -4. 변경 영향도 기록: 코드/문서/운영 영향 범위를 명시한다. -5. 불확실성 우선 해소: 차단 이슈가 있으면 임의 추정 대신 질문 또는 추가 탐색을 수행한다. -6. 운영 환류: 운영 단계 인사이트는 다음 Discovery의 입력으로 반드시 반영한다. - -## Hotfix Exception Path - -긴급 핫픽스는 Requirements/Delivery 설계를 경량화할 수 있다. 단, 아래는 필수다. - -1. 최소 요구사항 정의(증상, 영향, 성공 조건) -2. 최소 검증(재현 테스트 또는 핵심 시나리오 확인) -3. 배포 전 롤백 계획 -4. 배포 후 Postmortem 작성 및 다음 스프린트 환류 - diff --git a/.github/instructions/placeholder-guide.md b/.github/instructions/placeholder-guide.md deleted file mode 100644 index 73f6b82..0000000 --- a/.github/instructions/placeholder-guide.md +++ /dev/null @@ -1,239 +0,0 @@ -# 플레이스홀더 가이드 - -이 파일은 active workspace 기준으로 `.github/templates/`와 `.github/skills/`에서 사용하는 플레이스홀더의 문법과 처리 원칙을 정의한다. - -기본 전제는 아래와 같다. - -- active SDLC 진입점은 skill-only 체계다. -- baseline 문서와 active index 초기화는 `bootstrap-baseline`이 담당하며, 이 단계는 Discovery unit를 만들지 않는다. -- `.github/prompts/`는 active 기준 문서가 아니다. -- active 산출물 경로는 `docs/discovery/`, `docs/srs/`, `docs/batches/`, `docs/releases/`다. - -## 1. 문법 규칙 - -| 형식 | 의미 | 예시 | -| --- | --- | --- | -| `{{KEY}}` | 단순 치환 대상 | `{{DISCOVERY_ID}}` | -| `{{KEY:hint}}` | 힌트 포함 치환 대상 | `{{CONFIRMED_AT_KST:TBD}}` | -| `{{KEY:opt1\|opt2}}` | 열거형 선택 값 | `{{DOC_STATUS:draft\|confirmed}}` | - -규칙: - -- 최종 문서에는 `{{...}}` 문법이 남지 않아야 한다. -- 힌트 텍스트는 치환 시 함께 제거한다. -- 사람 승인 없이는 확정할 수 없는 값은 본문에 무리하게 확정하지 말고 Notes 또는 결정 필요 항목으로 남긴다. - -## 2. 처리 주체 - -플레이스홀더는 아래 세 가지 처리 주체 중 하나에 속한다. - -### A. 자동 치환 - -- ID, 생성 시각, 갱신 시각, 상태 기본값처럼 실행 시점에 자동으로 정해지는 값 -- 예: - - `{{DISCOVERY_ID}}` - - `{{CONFIRMED_AT_KST}}` - - `{{BAT_ID}}` - - `{{REL_ID}}` - -### B. AI 근거 기반 치환 - -- 현재 저장소 상태, 상위 문서, 이미 확정된 값으로 채울 수 있는 항목 -- 예: - - Discovery의 현재 상태 요약 - - REQ의 Impacted Area 초안 - - Batch planning의 dependency 정리 - - Release rollout checklist 초안 - -### C. 사람 결정 필요 - -- 정책 선택, 승인자 지정, 우선순위 확정, 외부 환경 정보처럼 사람 판단이 필요한 값 -- 이런 항목은 무리하게 확정하지 않는다. - -## 3. 경로별 플레이스홀더 원칙 - -### 3.0 Bootstrap baseline 템플릿 - -대상 경로: - -- `.github/templates/bootstrap/baseline-seed.yaml` -- `.github/templates/project-structure.md` -- `.github/templates/runtime-flows.md` -- `.github/templates/discovery/index.md` -- `.github/templates/srs/index.md` -- `.github/templates/batches/index.md` -- `.github/templates/releases/index.md` - -처리 원칙: - -- `bootstrap-baseline`은 먼저 `.stagepilot/bootstrap/baseline.yaml` seed를 만들거나 보강한다. -- greenfield 저장소처럼 관찰 가능한 정보가 부족하면 최소 질문 세트로 seed를 채운다. -- `bootstrap-baseline`은 baseline 문서와 index를 초기화하지만 Discovery 문서를 만들지 않는다. -- baseline 문서는 seed를 source of truth로 렌더링하고, 필요하면 저장소 관찰값으로 보강한다. -- index 문서는 빈 register 골격만 준비하고, 실제 unit 행은 이후 skill이 추가한다. - -주요 키: - -- `{{BASELINE_MODE}}` -- `{{BASELINE_SEED_PATH}}` -- `{{PROJECT_SUMMARY}}` -- `{{PRIMARY_DOMAIN}}` -- `{{TECH_STACK_BULLETS}}` -- `{{PRIMARY_RUNTIME}}` -- `{{PRIMARY_ENTRYPOINT_BULLETS}}` -- `interfaces.contracts[*]` -- `data_model.entities[*]` - -### 3.0.1 선택 cross-cutting 템플릿 - -대상 경로: - -- `.github/templates/interface-contract.md` -- `.github/templates/data-model.md` - -처리 원칙: - -- 이 문서들은 optional cross-cutting reference다. -- active unit의 source of truth를 대체하지 않고, 여러 REQ와 batch에 공통으로 반복되는 계약 정보를 모아 둔다. -- `bootstrap-baseline`은 seed의 `interfaces`와 `data_model` 섹션을 우선 읽고, 값이 없으면 `runtime`, `structure`, `notes`를 바탕으로 기본 초안을 구체적으로 유도한다. -- baseline 수준의 변경이 있을 때만 갱신하고, REQ별 acceptance criteria를 그대로 복사하지 않는다. - -주요 키: - -- `{{BASELINE_MODE}}` -- `{{BASELINE_SEED_PATH}}` -- `{{PROJECT_SUMMARY}}` -- `{{PRIMARY_DOMAIN}}` -- `{{PRIMARY_RUNTIME}}` -- `{{INTERFACE_NAME_1}}` -- `{{INTERFACE_PURPOSE_1}}` -- `{{INTERFACE_SUMMARY_BULLETS}}` -- `{{INTERFACE_INPUTS_1}}` -- `{{INTERFACE_OUTPUTS_1}}` -- `{{INTERFACE_ERRORS_1}}` -- `{{INTERFACE_COMPATIBILITY_1}}` -- `{{INTERFACE_SHARED_CONSTRAINTS_BULLETS}}` -- `{{INTERFACE_CURRENT_GAPS_BULLETS}}` -- `{{PERSISTENCE_BACKEND}}` -- `{{ENTITY_NAME_1}}` -- `{{ENTITY_PURPOSE_1}}` -- `{{ENTITY_SOURCE_1}}` -- `{{ENTITY_LIFECYCLE_1}}` -- `{{MODEL_SUMMARY_BULLETS}}` -- `{{ENTITY_FIELDS_1}}` -- `{{ENTITY_RELATIONSHIPS_1}}` -- `{{CONSISTENCY_RULE_BULLETS}}` -- `{{PERSISTENCE_NOTES_BULLETS}}` -- `{{MODEL_CURRENT_GAPS_BULLETS}}` - -### 3.1 Discovery 템플릿 - -대상 경로: - -- `.github/templates/discovery/discovery.md` -- `docs/discovery/*.md` - -주요 키: - -- `{{DISCOVERY_ID}}` -- `{{ISSUE_NAME}}` -- `{{CREATED_AT_KST}}` -- `{{UPDATED_AT_KST}}` -- `{{FREEZE_HANDOFF_DECISION}}` -- `{{READY_FOR_REQ_DRAFTING}}` -- `{{CONFIRMED_BY}}` -- `{{CONFIRMED_AT_KST}}` - -처리 원칙: - -- Discovery는 REQ 초안 작성으로 넘길 수 있을 때만 freeze handoff를 완료한다. -- Discovery 문서의 용어는 `Planning` 직결이 아니라 `REQ Drafting` 기준으로 유지한다. - -### 3.2 SRS 템플릿 - -대상 경로: - -- `docs/srs/req-template.md` -- `docs/srs/index.md` - -주요 키: - -- `REQ-XXX` -- `Status` -- `Type` -- `Priority` -- `Owner` - -처리 원칙: - -- REQ는 `Proposed -> Approved -> Implemented -> Deprecated` 상태를 따른다. -- `draft-req`는 초안을 만들고, `confirm-req`는 `Proposed -> Approved`, `confirm-req-implemented`는 `Approved -> Implemented` 전환을 담당한다. - -### 3.3 Batch 템플릿 - -대상 경로: - -- `.github/templates/batches/*` -- `docs/batches/*` - -주요 키: - -- `{{BAT_ID}}` -- `Profile` -- Included REQ 목록 -- Source Discovery - -처리 원칙: - -- batch는 Approved REQ만 포함한다. -- batch는 `standard` 또는 `batch-lite` profile을 가진다. -- `batch-lite`는 `minor-change` fast path의 기본 delivery profile이며, 처음에는 `index.md`와 `planning.md`만 만들 수 있다. -- planning, design, implementation, verification 문서는 같은 batch 폴더 아래에서 관리한다. 단, `batch-lite`는 design/implementation/verification을 단계 진입 시점에 생성할 수 있다. -- `confirm-batch-verification` 성공 전에는 `release-candidate`로 올리지 않는다. - -### 3.4 Release 템플릿 - -대상 경로: - -- `.github/templates/releases/*` -- `docs/releases/*` - -주요 키: - -- `{{REL_ID}}` -- `Profile` -- `Change Request Input` -- Included Batch -- Rollout Plan -- Rollback Plan - -처리 원칙: - -- release는 `release-candidate` batch만 포함한다. -- release는 `docs-only`, `tooling`, `app-service` profile 중 하나를 가진다. -- feedback handoff에는 `Discovery Input`, `REQ Input`, `Change Request Input`을 구분해 기록한다. -- `confirm-release`는 release 승인, `capture-release-feedback`는 운영 환류 기록을 담당한다. - -## 4. Active Skill Summary - -| 목적 | Skill | 주요 입력 | 주요 출력 | -| --- | --- | --- | --- | -| Bootstrap baseline 초기화 | `.github/skills/bootstrap-baseline/SKILL.md` | 저장소 루트, baseline/index 존재 여부 | baseline 문서와 active index | -| Discovery 초안 생성 | `.github/skills/new-discovery/SKILL.md` | 이슈/요청 요약 | `docs/discovery/*.md` | -| Discovery 확정 | `.github/skills/confirm-discovery/SKILL.md` | `DISCOVERY_ID` | confirmed Discovery | -| REQ 초안 생성 | `.github/skills/draft-req/SKILL.md` | `DISCOVERY_ID` | `docs/srs//req-*.md` | -| REQ 확정 | `.github/skills/confirm-req/SKILL.md` | `REQ-ID` | Approved REQ | -| REQ 변경 관리 | `.github/skills/change-req/SKILL.md` | `REQ-ID` | Change Log 기반 REQ 갱신 | -| REQ 구현 완료 전환 | `.github/skills/confirm-req-implemented/SKILL.md` | `REQ-ID` 또는 `BAT-ID` | Implemented REQ | -| Batch 추천 | `.github/skills/suggest-batch-reqs/SKILL.md` | `DISCOVERY_ID` 또는 REQ 목록 | batch 후보안 | -| Batch 생성 | `.github/skills/draft-batch/SKILL.md` | Approved REQ 목록 | `docs/batches//` | -| Delivery 진행 | `.github/skills/draft-batch-planning/SKILL.md`, `.github/skills/draft-batch-design/SKILL.md`, `.github/skills/run-batch-implementation/SKILL.md`, `.github/skills/draft-batch-verification/SKILL.md`, `.github/skills/confirm-batch-verification/SKILL.md` | `BAT-ID` | batch 문서와 상태 | -| Release 진행 | `.github/skills/draft-release/SKILL.md`, `.github/skills/confirm-release/SKILL.md`, `.github/skills/capture-release-feedback/SKILL.md` | `BAT-ID` 또는 `REL-ID` | release 문서와 운영 환류 | -| 다음 Discovery 추천 | `.github/skills/suggest-next-discovery/SKILL.md` | `REL-ID` 또는 없음 | follow-up 후보안 | -| 상위 orchestration | `.github/skills/run-sdlc/SKILL.md` | Discovery/REQ/Batch/Release 식별자 | 다음 skill 라우팅 | - -## 5. 금지 사항 - -- active 설명 문서에서 `.github/prompts/`를 기준 경로로 안내하지 않는다. -- `review-*`를 별도 active 단계로 서술하지 않는다. -- `docs/sdlc/`를 신규 active 산출물 경로로 사용하지 않는다. \ No newline at end of file diff --git a/.github/scripts/stagepilot-bootstrap-seed.py b/.github/scripts/stagepilot-bootstrap-seed.py deleted file mode 100644 index 2937b72..0000000 --- a/.github/scripts/stagepilot-bootstrap-seed.py +++ /dev/null @@ -1,913 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import argparse -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from pathlib import Path -import re -import sys - -try: - from zoneinfo import ZoneInfo -except ImportError: # pragma: no cover - ZoneInfo = None - - -RUNTIME_CHOICES = ( - "cli", - "api-service", - "web-app", - "worker", - "library", - "mixed", - "other", -) - -INTERFACE_TYPE_CHOICES = ( - "http-api", - "cli", - "event", - "file", - "internal-service", - "other", -) - -UPPERCASE_TOKENS = { - "api": "API", - "http": "HTTP", - "cli": "CLI", - "id": "ID", - "io": "I/O", - "json": "JSON", - "sql": "SQL", - "ui": "UI", - "ux": "UX", - "url": "URL", -} - -STORAGE_BACKEND_HINTS = ( - (("postgresql", "postgres", "psql"), "PostgreSQL"), - (("mysql", "mariadb"), "MySQL"), - (("sqlite",), "SQLite"), - (("mongodb", "mongo"), "MongoDB"), - (("dynamodb",), "DynamoDB"), - (("redis",), "Redis"), - (("s3", "blob storage", "object storage"), "Object Storage"), -) - - -@dataclass(frozen=True) -class Entrypoint: - name: str - purpose: str - - -@dataclass(frozen=True) -class TopLevelArea: - path: str - responsibility: str - - -@dataclass(frozen=True) -class InterfaceContractHint: - name: str - interface_type: str - actors: str - purpose: str - - -@dataclass(frozen=True) -class EntityHint: - name: str - purpose: str - - -@dataclass(frozen=True) -class CompatibilityRuleHint: - interface_name: str - rule: str - - -@dataclass(frozen=True) -class InterfaceDetailHint: - interface_name: str - value: str - - -@dataclass(frozen=True) -class SeedAnswers: - repository_name: str - display_name: str - summary: str - primary_domain: str - tech_stack: list[str] - primary_runtime: str - primary_entrypoints: list[Entrypoint] - planned_top_level_areas: list[TopLevelArea] - interface_contracts: list[InterfaceContractHint] - interface_inputs: list[InterfaceDetailHint] - interface_outputs: list[InterfaceDetailHint] - interface_errors: list[InterfaceDetailHint] - core_entities: list[EntityHint] - persistence_backend: str - compatibility_rules: list[CompatibilityRuleHint] - known_unknowns: list[str] - capture_mode: str - - -def fail(message: str) -> int: - print(f"[bootstrap-seed] ERROR: {message}", file=sys.stderr) - return 1 - - -def log(message: str) -> None: - print(f"[bootstrap-seed] {message}") - - -def default_display_name(repository_name: str) -> str: - words = re.sub(r"[-_]+", " ", repository_name.strip()).split() - if not words: - return "Project" - return " ".join(word.capitalize() for word in words) - - -def kst_timestamp() -> str: - if ZoneInfo is not None: - current = datetime.now(ZoneInfo("Asia/Seoul")) - else: # pragma: no cover - current = datetime.now(timezone(timedelta(hours=9))) - return current.strftime("%Y-%m-%d %H:%M") - - -def yaml_scalar(value: str) -> str: - escaped = value.replace("'", "''") - return f"'{escaped}'" - - -def parse_csv_list(raw_value: str) -> list[str]: - return [item.strip() for item in raw_value.split(",") if item.strip()] - - -def parse_pair(raw_value: str, label: str) -> tuple[str, str]: - if "::" not in raw_value: - raise ValueError(f"{label} entries must use 'name :: purpose' format: {raw_value}") - - left, right = raw_value.split("::", 1) - left = left.strip() - right = right.strip() - if not left or not right: - raise ValueError(f"{label} entries must include values on both sides of '::': {raw_value}") - return left, right - - -def parse_parts(raw_value: str, label: str, expected_parts: int) -> tuple[str, ...]: - parts = tuple(part.strip() for part in raw_value.split("::")) - if len(parts) != expected_parts or any(not part for part in parts): - raise ValueError( - f"{label} entries must use exactly {expected_parts} parts separated by '::': {raw_value}" - ) - return parts - - -def prompt_value(question: str, *, default: str | None = None, choices: tuple[str, ...] | None = None) -> str: - while True: - choice_hint = f" ({', '.join(choices)})" if choices else "" - default_hint = f" [{default}]" if default else "" - raw_value = input(f"{question}{choice_hint}{default_hint}: ").strip() - - if not raw_value and default is not None: - return default - if not raw_value: - print("Value is required.") - continue - if choices and raw_value not in choices: - print(f"Choose one of: {', '.join(choices)}") - continue - return raw_value - - -def prompt_lines(question: str, *, example: str, required: bool = False, max_items: int | None = None) -> list[str]: - print(question) - print(f"Format: {example}") - print("Press Enter on an empty line to finish.") - - values: list[str] = [] - while True: - raw_value = input("> ").strip() - if not raw_value: - if required and not values: - print("At least one line is required.") - continue - break - - values.append(raw_value) - if max_items is not None and len(values) >= max_items: - break - - return values - - -def title_case_phrase(text: str) -> str: - tokens = [token for token in re.split(r"[\s/_-]+", text.strip()) if token] - formatted: list[str] = [] - for token in tokens: - lowered = token.lower() - if lowered in UPPERCASE_TOKENS: - formatted.append(UPPERCASE_TOKENS[lowered]) - else: - formatted.append(token.capitalize()) - return " ".join(formatted) - - -def compact_label(text: str, *, max_words: int = 5) -> str: - cleaned = re.sub(r"[^A-Za-z0-9\s/_-]+", " ", text).strip() - label = title_case_phrase(cleaned) - words = label.split() - if len(words) > max_words: - return " ".join(words[:max_words]) - return label - - -def infer_interface_type(primary_runtime: str, entrypoint_name: str, index: int) -> str: - lowered = entrypoint_name.lower() - if primary_runtime in {"api-service", "web-app"} and index == 0: - return "http-api" - if primary_runtime == "library": - return "internal-service" - if primary_runtime in {"cli", "worker"}: - return "cli" - if any(token in lowered for token in ("uvicorn", "gunicorn", ":app")): - return "http-api" - if lowered.startswith(("python ", "python3 ", "node ")) or " -m " in lowered: - return "cli" - return "other" - - -def default_interface_actors(interface_type: str) -> str: - actor_map = { - "http-api": "clients and downstream services", - "cli": "operators and automation", - "event": "publishers and subscribers", - "file": "file producers and consumers", - "internal-service": "internal services", - "other": "project operators and integrators", - } - return actor_map[interface_type] - - -def default_interface_stability(interface_type: str, capture_mode: str) -> str: - if interface_type == "cli": - return "internal" - if capture_mode == "declared": - return "experimental" - return "internal" - - -def derive_interface_name(entrypoint: Entrypoint, interface_type: str, index: int) -> str: - purpose_label = compact_label(entrypoint.purpose) - if interface_type == "http-api": - return "Primary HTTP API" if index == 0 else f"{purpose_label} API" - if interface_type == "cli": - return f"{purpose_label} Command" - if interface_type == "event": - return f"{purpose_label} Event" - if interface_type == "file": - return f"{purpose_label} File Contract" - if interface_type == "internal-service": - return f"{purpose_label} Service" - return f"{purpose_label} Interface" - - -def build_interface_hints(answers: SeedAnswers) -> list[InterfaceContractHint]: - hints = list(answers.interface_contracts[:2]) - for index, entrypoint in enumerate(answers.primary_entrypoints): - if len(hints) >= 2: - break - interface_type = infer_interface_type(answers.primary_runtime, entrypoint.name, index) - hints.append( - InterfaceContractHint( - name=derive_interface_name(entrypoint, interface_type, index), - interface_type=interface_type, - actors=default_interface_actors(interface_type), - purpose=entrypoint.purpose, - ) - ) - - while len(hints) < 2: - index = len(hints) - fallback_type = "http-api" if index == 0 and answers.primary_runtime in {"api-service", "web-app"} else "internal-service" - hints.append( - InterfaceContractHint( - name="Primary HTTP API" if fallback_type == "http-api" else "Internal Service Interface", - interface_type=fallback_type, - actors=default_interface_actors(fallback_type), - purpose=f"support the primary {answers.primary_domain} workflow", - ) - ) - return hints[:2] - - -def default_entity_name(primary_domain: str, index: int) -> str: - domain_label = compact_label(primary_domain, max_words=4) or "Domain" - if index == 0: - return f"{domain_label} Record" - return f"{domain_label} Snapshot" - - -def default_entity_purpose(primary_domain: str, index: int) -> str: - if index == 0: - return f"represent a core business record in the {primary_domain} domain" - return f"represent a derived or supporting artifact in the {primary_domain} domain" - - -def build_entity_hints(answers: SeedAnswers) -> list[EntityHint]: - hints = list(answers.core_entities[:2]) - while len(hints) < 2: - index = len(hints) - hints.append( - EntityHint( - name=default_entity_name(answers.primary_domain, index), - purpose=default_entity_purpose(answers.primary_domain, index), - ) - ) - return hints[:2] - - -def detect_storage_backend(tech_stack: list[str]) -> str: - lowered_stack = [item.lower() for item in tech_stack] - for keywords, label in STORAGE_BACKEND_HINTS: - if any(keyword in item for item in lowered_stack for keyword in keywords): - return label - return "application data store" - - -def normalize_storage_backend(raw_value: str) -> str: - lowered_value = raw_value.strip().lower() - if not lowered_value: - return "application data store" - for keywords, label in STORAGE_BACKEND_HINTS: - if any(keyword in lowered_value for keyword in keywords): - return label - return raw_value.strip() - - -def derive_interface_inputs(contract: InterfaceContractHint) -> list[str]: - if contract.interface_type == "http-api": - return [f"service request handled by {contract.name} :: {contract.purpose}"] - if contract.interface_type == "cli": - return [f"command invocation :: {contract.purpose}"] - if contract.interface_type == "event": - return [f"event publication or subscription :: {contract.purpose}"] - if contract.interface_type == "file": - return [f"file input or output trigger :: {contract.purpose}"] - return [f"interface invocation :: {contract.purpose}"] - - -def derive_interface_outputs(contract: InterfaceContractHint) -> list[str]: - if contract.interface_type == "http-api": - return [f"structured service response :: result of {contract.purpose}"] - if contract.interface_type == "cli": - return [f"console output or generated artifact :: result of {contract.purpose}"] - if contract.interface_type == "event": - return [f"published event payload :: result of {contract.purpose}"] - if contract.interface_type == "file": - return [f"generated or updated file artifact :: result of {contract.purpose}"] - return [f"observable interface result :: outcome of {contract.purpose}"] - - -def derive_interface_errors(contract: InterfaceContractHint) -> list[str]: - if contract.interface_type == "http-api": - return ["4xx/5xx response :: the request could not be completed successfully"] - if contract.interface_type == "cli": - return ["non-zero exit code :: the command failed for operators or automation"] - return ["reported interface failure :: the contract could not be completed successfully"] - - -def derive_interface_compatibility(contract: InterfaceContractHint) -> list[str]: - if contract.interface_type == "cli": - return ["arguments and exit-code semantics should remain stable for automation callers"] - return ["payload shape and externally visible behavior should remain backward compatible for stable consumers"] - - -def resolve_interface_compatibility_rules( - answers: SeedAnswers, - contract: InterfaceContractHint, -) -> list[str]: - shared_rules = [ - rule.rule for rule in answers.compatibility_rules if rule.interface_name.strip() == "*" - ] - named_rules = [ - rule.rule - for rule in answers.compatibility_rules - if rule.interface_name.strip().casefold() == contract.name.casefold() - ] - if shared_rules or named_rules: - return shared_rules + named_rules - return derive_interface_compatibility(contract) - - -def resolve_interface_detail_hints( - detail_hints: list[InterfaceDetailHint], - contract: InterfaceContractHint, -) -> list[str]: - return [ - hint.value - for hint in detail_hints - if hint.interface_name.strip().casefold() == contract.name.casefold() - ] - - -def derive_interface_shared_constraints(answers: SeedAnswers, contracts: list[InterfaceContractHint]) -> list[str]: - constraints: list[str] = [] - if any(contract.interface_type == "http-api" for contract in contracts): - constraints.append("request validation should happen before persistence or response rendering") - if any(contract.interface_type == "cli" for contract in contracts): - constraints.append("automation-facing commands should communicate failure through stable exit semantics") - if not constraints: - constraints.append("externally visible interfaces should document compatibility expectations before release") - return constraints[:2] - - -def derive_interface_current_gaps(answers: SeedAnswers, contracts: list[InterfaceContractHint]) -> list[str]: - if answers.known_unknowns: - return answers.known_unknowns[:2] - gaps: list[str] = [] - if any(contract.interface_type == "http-api" for contract in contracts): - gaps.append("authentication and API versioning strategy are not defined yet") - if any(contract.interface_type == "cli" for contract in contracts): - gaps.append("automation caller expectations are not fully documented yet") - return gaps[:2] or ["consumer-specific compatibility expectations are not defined yet"] - - -def derive_entity_source(storage_backend: str, index: int) -> str: - if storage_backend == "PostgreSQL": - return "PostgreSQL primary tables" if index == 0 else "PostgreSQL derived tables" - if storage_backend == "MySQL": - return "MySQL primary tables" if index == 0 else "MySQL derived tables" - if storage_backend == "SQLite": - return "SQLite primary tables" if index == 0 else "SQLite derived tables" - if storage_backend == "MongoDB": - return "MongoDB primary collections" if index == 0 else "MongoDB derived collections" - if storage_backend == "Object Storage": - return "object storage manifests" if index == 0 else "object storage derived artifacts" - return "primary application records" if index == 0 else "derived application records" - - -def derive_entity_lifecycle(index: int) -> str: - if index == 0: - return "created -> updated -> archived" - return "derived -> published -> superseded" - - -def derive_entity_fields(index: int, entity: EntityHint, source_entity: EntityHint | None = None) -> list[str]: - if index == 0: - return [ - "id :: string or uuid :: stable identifier for the entity", - "status :: enum :: current lifecycle state for the entity", - "created_at :: timestamp :: when the entity was first recorded", - ] - source_name = source_entity.name if source_entity is not None else "the primary entity" - return [ - "id :: string or uuid :: stable identifier for the derived entity", - f"source_id :: string or uuid :: reference to {source_name}", - "updated_at :: timestamp :: when the derived state was last recomputed or refreshed", - ] - - -def derive_entity_relationships(index: int, entity: EntityHint, other_entity: EntityHint) -> list[str]: - if index == 0: - return [f"{other_entity.name} :: derived from or associated with {entity.name}"] - return [f"{other_entity.name} :: source record or upstream entity for {entity.name}"] - - -def derive_entity_state_rules(index: int, entity: EntityHint, other_entity: EntityHint) -> list[str]: - if index == 0: - return [ - "only one current active record should be treated as authoritative per business key", - "archived records should remain available for audit or replay needs when retained", - ] - return [ - f"{entity.name} should not exist without a corresponding {other_entity.name}", - f"{entity.name} should be refreshed whenever the authoritative {other_entity.name} changes", - ] - - -def derive_consistency_rules(entities: list[EntityHint]) -> list[str]: - return [ - "stable identifiers must remain consistent across related entities", - f"derived records must not outlive their source relationship to {entities[0].name}", - ] - - -def derive_persistence_notes(storage_backend: str) -> list[str]: - return [ - f"{storage_backend} is expected to store the current cross-cutting records and derived artifacts", - "migration, indexing, and retention strategy should be updated when schema assumptions change", - ] - - -def derive_model_current_gaps(answers: SeedAnswers) -> list[str]: - if answers.known_unknowns: - return answers.known_unknowns[:2] - return [ - "exact schema and indexing strategy are not defined yet", - "retention and archival policy are not defined yet", - ] - - -def collect_required_args(args: argparse.Namespace) -> tuple[str, str, list[str], str, list[str]]: - missing: list[str] = [] - if not args.project_summary: - missing.append("--project-summary") - if not args.primary_domain: - missing.append("--primary-domain") - if not args.tech_stack: - missing.append("--tech-stack") - if not args.primary_runtime: - missing.append("--primary-runtime") - if not args.primary_entrypoint: - missing.append("--primary-entrypoint") - if missing: - raise ValueError("Missing required arguments for --non-interactive: " + ", ".join(missing)) - - tech_stack = parse_csv_list(args.tech_stack) - if not tech_stack: - raise ValueError("--tech-stack must include at least one comma-separated item") - - return ( - args.project_summary, - args.primary_domain, - tech_stack, - args.primary_runtime, - args.primary_entrypoint, - ) - - -def collect_answers(args: argparse.Namespace, host_root: Path) -> SeedAnswers: - repository_name = args.repository_name or host_root.resolve().name or "project" - display_name = args.display_name or default_display_name(repository_name) - - if args.non_interactive: - summary, primary_domain, tech_stack, primary_runtime, primary_entrypoints = collect_required_args(args) - top_level_area_values = args.top_level_area - interface_contract_values = args.interface_contract - interface_input_values = args.interface_input - interface_output_values = args.interface_output - interface_error_values = args.interface_error - core_entity_values = args.core_entity - persistence_backend = normalize_storage_backend(args.persistence_backend) if args.persistence_backend else detect_storage_backend(tech_stack) - compatibility_rule_values = args.compatibility_rule - known_unknowns = args.known_unknown - else: - print("[bootstrap-seed] Answer the minimum bootstrap questions for baseline.yaml.") - print(f"[bootstrap-seed] Host root: {host_root}") - display_name = prompt_value("Display name", default=display_name) - summary = args.project_summary or prompt_value("project-summary | 이 프로젝트를 한 문장으로 설명하면 무엇인가?") - primary_domain = args.primary_domain or prompt_value("primary-domain | 이 프로젝트의 주요 도메인은 무엇인가?") - tech_stack = parse_csv_list(args.tech_stack) if args.tech_stack else parse_csv_list( - prompt_value("tech-stack | 계획 중인 주 언어, 프레임워크, 핵심 인프라는 무엇인가? (comma-separated)") - ) - primary_runtime = args.primary_runtime or prompt_value( - "primary-runtime | 계획 중인 주 실행 형태는 무엇인가?", - choices=RUNTIME_CHOICES, - ) - primary_entrypoints = args.primary_entrypoint or prompt_lines( - "primary-entrypoints | 대표 진입점 1~3개를 적어 달라.", - example="name :: purpose", - required=True, - max_items=3, - ) - top_level_area_values = args.top_level_area or prompt_lines( - "planned-top-level-areas | 이미 알고 있는 top-level 경로가 있으면 적어 달라.", - example="path :: responsibility", - ) - interface_contract_values = args.interface_contract or prompt_lines( - "interface-contracts | 대표 인터페이스 1~2개가 있으면 적어 달라.", - example="name :: type :: actors :: purpose", - max_items=2, - ) - interface_input_values = args.interface_input or prompt_lines( - "interface-inputs | 각 인터페이스의 대표 input을 적어 달라.", - example="interface-name :: input contract or invocation note", - ) - interface_output_values = args.interface_output or prompt_lines( - "interface-outputs | 각 인터페이스의 대표 output을 적어 달라.", - example="interface-name :: output contract or artifact", - ) - interface_error_values = args.interface_error or prompt_lines( - "interface-errors | 각 인터페이스의 대표 error를 적어 달라.", - example="interface-name :: error code or failure behavior", - ) - core_entity_values = args.core_entity or prompt_lines( - "core-entities | 핵심 엔티티 1~2개가 있으면 적어 달라.", - example="name :: purpose", - max_items=2, - ) - persistence_backend = normalize_storage_backend(args.persistence_backend) if args.persistence_backend else prompt_value( - "persistence-backend | 주 persistence backend가 있으면 적어 달라.", - default=detect_storage_backend(tech_stack), - ) - compatibility_rule_values = args.compatibility_rule or prompt_lines( - "compatibility-rules | 인터페이스 호환성 규칙이 있으면 적어 달라.", - example="interface-name :: rule 또는 * :: rule", - ) - known_unknowns = args.known_unknown or prompt_lines( - "known-unknowns | 아직 미정인 핵심 항목이 있으면 적어 달라.", - example="one unresolved item per line", - ) - - if primary_runtime not in RUNTIME_CHOICES: - raise ValueError(f"primary runtime must be one of: {', '.join(RUNTIME_CHOICES)}") - if not tech_stack: - raise ValueError("tech-stack must include at least one item") - if not primary_entrypoints: - raise ValueError("at least one primary-entrypoint is required") - if len(primary_entrypoints) > 3: - raise ValueError("primary-entrypoint supports at most 3 values") - if len(interface_contract_values) > 2: - raise ValueError("interface-contract supports at most 2 values") - if len(core_entity_values) > 2: - raise ValueError("core-entity supports at most 2 values") - - entrypoints = [Entrypoint(*parse_pair(value, "primary-entrypoint")) for value in primary_entrypoints] - top_level_areas = [TopLevelArea(*parse_pair(value, "top-level-area")) for value in top_level_area_values] - interface_contracts = [ - InterfaceContractHint(*parse_parts(value, "interface-contract", 4)) for value in interface_contract_values - ] - for contract in interface_contracts: - if contract.interface_type not in INTERFACE_TYPE_CHOICES: - raise ValueError( - "interface-contract type must be one of: " + ", ".join(INTERFACE_TYPE_CHOICES) - ) - interface_inputs = [InterfaceDetailHint(*parse_pair(value, "interface-input")) for value in interface_input_values] - interface_outputs = [InterfaceDetailHint(*parse_pair(value, "interface-output")) for value in interface_output_values] - interface_errors = [InterfaceDetailHint(*parse_pair(value, "interface-error")) for value in interface_error_values] - core_entities = [EntityHint(*parse_parts(value, "core-entity", 2)) for value in core_entity_values] - compatibility_rules = [ - CompatibilityRuleHint(*parse_pair(value, "compatibility-rule")) for value in compatibility_rule_values - ] - - return SeedAnswers( - repository_name=repository_name, - display_name=display_name, - summary=summary, - primary_domain=primary_domain, - tech_stack=tech_stack, - primary_runtime=primary_runtime, - primary_entrypoints=entrypoints, - planned_top_level_areas=top_level_areas, - interface_contracts=interface_contracts, - interface_inputs=interface_inputs, - interface_outputs=interface_outputs, - interface_errors=interface_errors, - core_entities=core_entities, - persistence_backend=persistence_backend, - compatibility_rules=compatibility_rules, - known_unknowns=known_unknowns, - capture_mode=args.capture_mode or "declared", - ) - - -def render_seed(answers: SeedAnswers) -> str: - updated_from = { - "declared": "user-answer", - "observed": "repo-observation", - "mixed": "mixed", - }[answers.capture_mode] - interface_contracts = build_interface_hints(answers) - entities = build_entity_hints(answers) - storage_backend = answers.persistence_backend - interface_shared_constraints = derive_interface_shared_constraints(answers, interface_contracts) - interface_current_gaps = derive_interface_current_gaps(answers, interface_contracts) - consistency_rules = derive_consistency_rules(entities) - persistence_notes = derive_persistence_notes(storage_backend) - model_current_gaps = derive_model_current_gaps(answers) - - lines = [ - "schema_version: 1", - f"capture_mode: {answers.capture_mode}", - "", - "project:", - f" repository_name: {yaml_scalar(answers.repository_name)}", - f" display_name: {yaml_scalar(answers.display_name)}", - f" summary: {yaml_scalar(answers.summary)}", - f" primary_domain: {yaml_scalar(answers.primary_domain)}", - "", - "stack:", - " tech_stack:", - ] - - for item in answers.tech_stack: - lines.append(f" - {yaml_scalar(item)}") - - lines.extend( - [ - "", - "runtime:", - f" primary_runtime: {answers.primary_runtime}", - " primary_entrypoints:", - ] - ) - for entrypoint in answers.primary_entrypoints: - lines.append(f" - name: {yaml_scalar(entrypoint.name)}") - lines.append(f" purpose: {yaml_scalar(entrypoint.purpose)}") - - lines.extend(["", "interfaces:", " contracts:"]) - for contract in interface_contracts: - resolved_inputs = resolve_interface_detail_hints(answers.interface_inputs, contract) - resolved_outputs = resolve_interface_detail_hints(answers.interface_outputs, contract) - resolved_errors = resolve_interface_detail_hints(answers.interface_errors, contract) - lines.append(f" - name: {yaml_scalar(contract.name)}") - lines.append(f" type: {yaml_scalar(contract.interface_type)}") - lines.append(f" actors: {yaml_scalar(contract.actors)}") - lines.append(f" purpose: {yaml_scalar(contract.purpose)}") - lines.append( - f" stability: {yaml_scalar(default_interface_stability(contract.interface_type, answers.capture_mode))}" - ) - lines.append(" inputs:") - for item in resolved_inputs or derive_interface_inputs(contract): - lines.append(f" - {yaml_scalar(item)}") - lines.append(" outputs:") - for item in resolved_outputs or derive_interface_outputs(contract): - lines.append(f" - {yaml_scalar(item)}") - lines.append(" errors:") - for item in resolved_errors or derive_interface_errors(contract): - lines.append(f" - {yaml_scalar(item)}") - lines.append(" compatibility_rules:") - for item in resolve_interface_compatibility_rules(answers, contract): - lines.append(f" - {yaml_scalar(item)}") - lines.append(" shared_constraints:") - for item in interface_shared_constraints: - lines.append(f" - {yaml_scalar(item)}") - lines.append(" current_gaps:") - for item in interface_current_gaps: - lines.append(f" - {yaml_scalar(item)}") - - lines.extend(["", "structure:"]) - if answers.planned_top_level_areas: - lines.append(" planned_top_level_areas:") - for area in answers.planned_top_level_areas: - lines.append(f" - path: {yaml_scalar(area.path)}") - lines.append(f" responsibility: {yaml_scalar(area.responsibility)}") - else: - lines.append(" planned_top_level_areas: []") - - lines.extend(["", "data_model:", f" persistence_backend: {yaml_scalar(storage_backend)}", " entities:"]) - for index, entity in enumerate(entities): - other_entity = entities[1 - index] - source_entity = entities[0] if index == 1 else None - lines.append(f" - name: {yaml_scalar(entity.name)}") - lines.append(f" purpose: {yaml_scalar(entity.purpose)}") - lines.append(f" source_of_truth: {yaml_scalar(derive_entity_source(storage_backend, index))}") - lines.append(f" lifecycle: {yaml_scalar(derive_entity_lifecycle(index))}") - lines.append(" key_fields:") - for item in derive_entity_fields(index, entity, source_entity): - lines.append(f" - {yaml_scalar(item)}") - lines.append(" relationships:") - for item in derive_entity_relationships(index, entity, other_entity): - lines.append(f" - {yaml_scalar(item)}") - lines.append(" state_rules:") - for item in derive_entity_state_rules(index, entity, other_entity): - lines.append(f" - {yaml_scalar(item)}") - lines.append(" consistency_rules:") - for item in consistency_rules: - lines.append(f" - {yaml_scalar(item)}") - lines.append(" persistence_notes:") - for item in persistence_notes: - lines.append(f" - {yaml_scalar(item)}") - lines.append(" current_gaps:") - for item in model_current_gaps: - lines.append(f" - {yaml_scalar(item)}") - - lines.extend(["", "notes:"]) - if answers.known_unknowns: - lines.append(" known_unknowns:") - for item in answers.known_unknowns: - lines.append(f" - {yaml_scalar(item)}") - else: - lines.append(" known_unknowns: []") - - lines.extend( - [ - "", - "metadata:", - f" captured_at_kst: {yaml_scalar(kst_timestamp())}", - " source: stagepilot-bootstrap-seed.py", - f" updated_from: {updated_from}", - "", - ] - ) - return "\n".join(lines) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Create .stagepilot/bootstrap/baseline.yaml from StagePilot bootstrap answers.", - ) - parser.add_argument("host_root", nargs="?", default=".", help="Host repository root. Defaults to the current directory.") - parser.add_argument("--output", help="Explicit output path. Defaults to HOST_ROOT/.stagepilot/bootstrap/baseline.yaml.") - parser.add_argument("--force", action="store_true", help="Overwrite an existing seed file.") - parser.add_argument("--dry-run", action="store_true", help="Print the generated seed to stdout without writing a file.") - parser.add_argument("--non-interactive", action="store_true", help="Require explicit CLI answers instead of interactive prompts.") - parser.add_argument("--capture-mode", choices=("declared", "observed", "mixed"), help="Seed capture mode.") - parser.add_argument("--repository-name", help="Repository name stored in the seed. Defaults to the host root folder name.") - parser.add_argument("--display-name", help="Display name stored in the seed. Defaults to a title-cased repository name.") - parser.add_argument("--project-summary", help="One-sentence project summary.") - parser.add_argument("--primary-domain", help="Primary domain noun phrase.") - parser.add_argument("--tech-stack", help="Comma-separated technology list.") - parser.add_argument("--primary-runtime", choices=RUNTIME_CHOICES, help="Primary runtime type.") - parser.add_argument( - "--primary-entrypoint", - action="append", - default=[], - help="Primary entrypoint line in 'name :: purpose' format. Repeat up to 3 times.", - ) - parser.add_argument( - "--top-level-area", - action="append", - default=[], - help="Optional top-level area in 'path :: responsibility' format.", - ) - parser.add_argument( - "--interface-contract", - action="append", - default=[], - help="Optional interface contract in 'name :: type :: actors :: purpose' format. Repeat up to 2 times.", - ) - parser.add_argument( - "--interface-input", - action="append", - default=[], - help="Optional interface input in 'interface-name :: input contract or invocation note' format.", - ) - parser.add_argument( - "--interface-output", - action="append", - default=[], - help="Optional interface output in 'interface-name :: output contract or artifact' format.", - ) - parser.add_argument( - "--interface-error", - action="append", - default=[], - help="Optional interface error in 'interface-name :: error code or failure behavior' format.", - ) - parser.add_argument( - "--core-entity", - action="append", - default=[], - help="Optional core entity in 'name :: purpose' format. Repeat up to 2 times.", - ) - parser.add_argument("--persistence-backend", help="Optional primary persistence backend or storage system.") - parser.add_argument( - "--compatibility-rule", - action="append", - default=[], - help="Optional compatibility rule in 'interface-name :: rule' format. Use '* :: rule' for a shared rule.", - ) - parser.add_argument( - "--known-unknown", - action="append", - default=[], - help="Optional unresolved bootstrap note. Repeat for multiple items.", - ) - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - - host_root = Path(args.host_root).resolve() - if not host_root.exists() or not host_root.is_dir(): - return fail(f"host root not found: {host_root}") - - output_path = Path(args.output).resolve() if args.output else host_root / ".stagepilot/bootstrap/baseline.yaml" - - try: - answers = collect_answers(args, host_root) - content = render_seed(answers) - except ValueError as error: - return fail(str(error)) - except EOFError: - return fail("input ended before the questionnaire was completed") - - if args.dry_run: - print(content, end="") - return 0 - - if output_path.exists() and not args.force: - return fail(f"seed file already exists: {output_path}. Use --force to overwrite.") - - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(content, encoding="utf-8") - - log(f"wrote {output_path}") - log("Next: run /bootstrap-baseline in Copilot Chat to render baseline docs from the seed.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/stagepilot-doctor.py b/.github/scripts/stagepilot-doctor.py deleted file mode 100644 index 44c68c2..0000000 --- a/.github/scripts/stagepilot-doctor.py +++ /dev/null @@ -1,1289 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import argparse -from datetime import datetime -import re -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Iterable - - -PLACEHOLDER_PATTERN = re.compile(r"\{\{[^{}]+\}\}") -LINK_PATTERN = re.compile(r"\[[^\]]+\]\(([^)]+)\)") -REQ_ID_PATTERN = re.compile(r"req-(\d+)", re.IGNORECASE) -BATCH_ID_PATTERN = re.compile(r"bat-(\d+)", re.IGNORECASE) -DISCOVERY_ID_PATTERN = re.compile(r"dcy-(\d+)", re.IGNORECASE) -RELEASE_ID_PATTERN = re.compile(r"rel-(\d+)", re.IGNORECASE) -STATUS_PATTERN = re.compile(r"^-\s*(?:Status|상태):\s*(.+?)\s*$", re.MULTILINE) -BATCH_PROFILE_PATTERN = re.compile(r"^-\s*Profile:\s*(.+?)\s*$", re.MULTILINE) -RELEASE_PROFILE_PATTERN = re.compile(r"^-\s*Profile:\s*(.+?)\s*$", re.MULTILINE) -REQ_TYPE_LINE_PATTERN = re.compile(r"^-\s*Type:\s*(.+)$", re.MULTILINE) -INLINE_CODE_PATTERN = re.compile(r"`([^`]+)`") - -BATCH_PROFILES = {"standard", "batch-lite"} -RELEASE_PROFILES = {"docs-only", "tooling", "app-service"} - -FINALIZED_STATUSES = { - "approved", - "archived", - "completed", - "confirmed", - "deprecated", - "feedback-captured", - "implemented", - "in-delivery", - "release-candidate", - "released", -} - -ACTIVE_INDEX_FILES = [ - Path("docs/discovery/index.md"), - Path("docs/srs/index.md"), - Path("docs/batches/index.md"), - Path("docs/releases/index.md"), -] - -BASELINE_DOC_FILES = [ - Path("docs/project-structure.md"), - Path("docs/runtime-flows.md"), -] - -OPTIONAL_CROSS_CUTTING_DOC_FILES = [ - Path("docs/interface-contract.md"), - Path("docs/data-model.md"), -] - -BOOTSTRAP_REQUIRED_FILES = ACTIVE_INDEX_FILES + BASELINE_DOC_FILES - - -@dataclass(frozen=True) -class Finding: - level: str - code: str - path: Path - message: str - - -@dataclass(frozen=True) -class TraceabilityRow: - req_id: str - status: str - discovery_ids: tuple[str, ...] - batch_ids: tuple[str, ...] - release_ready_batch_ids: tuple[str, ...] - release_ids: tuple[str, ...] - flags: tuple[str, ...] - - -@dataclass(frozen=True) -class FeedbackHandoffRow: - release_id: str - status: str - discovery_inputs: tuple[str, ...] - req_inputs: tuple[str, ...] - change_req_inputs: tuple[str, ...] - flags: tuple[str, ...] - - -@dataclass(frozen=True) -class ChangeReqImpact: - change_id: str - status_recommendation: str - reverification_needed: str - implementation_invalidated: str - - -class Doctor: - def __init__(self, workspace_root: Path, package_root: Path, strict_missing_docs: bool, report_path: Path | None = None) -> None: - self.workspace_root = workspace_root - self.package_root = package_root - self.strict_missing_docs = strict_missing_docs - self.report_path = report_path - self.report_written = False - self.findings: list[Finding] = [] - self.traceability_rows: list[TraceabilityRow] = [] - self.feedback_handoff_rows: list[FeedbackHandoffRow] = [] - - def add(self, level: str, code: str, path: Path, message: str) -> None: - self.findings.append(Finding(level=level, code=code, path=path, message=message)) - - def run(self) -> int: - self.check_allowlist_targets() - self.check_req_type_contract() - self.check_design_template_contract() - self.check_markdown_links(self.package_markdown_files(), relative_to=self.package_root) - - workspace_mode = self.check_active_workspace_roots() - if workspace_mode: - self.check_discovery_index() - self.check_srs_index() - self.check_batch_index() - self.check_release_index() - self.build_traceability_matrix() - self.build_feedback_handoff_summary() - self.check_placeholders(self.workspace_markdown_files()) - self.check_markdown_links(self.workspace_markdown_files(), relative_to=self.workspace_root) - - if self.report_path is not None: - self.write_report() - self.print_report() - return 1 if any(f.level == "ERROR" for f in self.findings) else 0 - - def package_markdown_files(self) -> list[Path]: - patterns = [ - Path("README.md"), - Path(".github/copilot-instructions.md"), - Path(".github/instructions"), - Path(".github/skills"), - Path(".github/templates"), - ] - files: list[Path] = [] - for pattern in patterns: - full_path = self.package_root / pattern - if full_path.is_file(): - files.append(full_path) - continue - if full_path.is_dir(): - files.extend(sorted(full_path.rglob("*.md"))) - return files - - def workspace_markdown_files(self) -> list[Path]: - docs_root = self.workspace_root / "docs" - if not docs_root.exists(): - return [] - return sorted(docs_root.rglob("*.md")) - - def check_active_workspace_roots(self) -> bool: - existing = [path for path in ACTIVE_INDEX_FILES if (self.workspace_root / path).exists()] - missing_bootstrap_files = [path for path in BOOTSTRAP_REQUIRED_FILES if not (self.workspace_root / path).exists()] - if not existing: - level = "ERROR" if self.strict_missing_docs else "WARN" - self.add( - level, - "missing-active-docs", - self.workspace_root, - "No active SDLC docs were found under docs/. Workspace-specific checks were skipped.", - ) - self.add_bootstrap_required_hint(missing_bootstrap_files) - return False - - for path in ACTIVE_INDEX_FILES: - if not (self.workspace_root / path).exists(): - self.add( - "ERROR", - "missing-root-index", - self.workspace_root / path, - "Missing active index file.", - ) - - if missing_bootstrap_files: - self.add_bootstrap_required_hint(missing_bootstrap_files) - else: - self.check_optional_cross_cutting_docs() - return True - - def check_optional_cross_cutting_docs(self) -> None: - if self.is_package_repo_self_check(): - return - - for path in OPTIONAL_CROSS_CUTTING_DOC_FILES: - if (self.workspace_root / path).exists(): - continue - self.add( - "WARN", - "missing-cross-cutting-baseline-doc", - self.workspace_root / path, - "Missing optional cross-cutting baseline doc. Run /bootstrap-baseline to backfill it or add the document manually if the project needs this shared contract.", - ) - - def add_bootstrap_required_hint(self, missing_files: list[Path]) -> None: - if not missing_files or self.is_package_repo_self_check(): - return - - missing_list = ", ".join(str(path) for path in missing_files[:6]) - if len(missing_files) > 6: - missing_list += ", ..." - - self.add( - "INFO", - "bootstrap-required", - self.workspace_root, - "Fresh host repos should run /bootstrap-baseline in Copilot Chat before the first real Discovery. " - f"Missing bootstrap files: {missing_list}", - ) - - def is_package_repo_self_check(self) -> bool: - if self.workspace_root.resolve() != self.package_root.resolve(): - return False - - required_paths = [ - Path("bootstrap/install.sh"), - Path("bootstrap/source-allowlist.txt"), - Path("examples/p3-change-management"), - ] - return all((self.workspace_root / path).exists() for path in required_paths) - - def check_allowlist_targets(self) -> None: - allowlist_path = self.package_root / "bootstrap/source-allowlist.txt" - if not allowlist_path.exists(): - self.add("ERROR", "missing-allowlist", allowlist_path, "source-allowlist.txt is missing.") - return - - github_root = self.package_root / ".github" - for raw_line in allowlist_path.read_text(encoding="utf-8").splitlines(): - line = raw_line.split("#", 1)[0].strip() - if not line: - continue - target = github_root / line.rstrip("/") - if line.endswith("/"): - if not target.is_dir(): - self.add("ERROR", "allowlist-missing-dir", allowlist_path, f"Allowlist entry '{line}' does not exist under .github/.") - elif not target.is_file(): - self.add("ERROR", "allowlist-missing-file", allowlist_path, f"Allowlist entry '{line}' does not exist under .github/.") - - def check_req_type_contract(self) -> None: - skill_path = self.package_root / ".github/skills/draft-req/SKILL.md" - template_path = self.package_root / ".github/templates/srs/req-template.md" - index_path = self.package_root / ".github/templates/srs/index.md" - - if not skill_path.exists() or not template_path.exists() or not index_path.exists(): - self.add("ERROR", "missing-req-contract-files", self.package_root, "REQ type contract files are missing.") - return - - skill_types = self.parse_req_types_from_skill(skill_path) - template_types = self.parse_req_types_from_template(template_path) - index_types = self.parse_req_types_from_index(index_path) - - if not skill_types or not template_types or not index_types: - self.add("ERROR", "unparseable-req-types", skill_path, "Could not parse REQ types from skill/template/index files.") - return - - normalized_sets = { - tuple(sorted(skill_types)), - tuple(sorted(template_types)), - tuple(sorted(index_types)), - } - if len(normalized_sets) != 1: - self.add( - "ERROR", - "req-type-mismatch", - skill_path, - "REQ type taxonomy differs across draft-req skill, req-template, and srs index.", - ) - - def check_design_template_contract(self) -> None: - skill_path = self.package_root / ".github/skills/draft-batch-design/SKILL.md" - template_path = self.package_root / ".github/templates/batches/design.md" - if not skill_path.exists() or not template_path.exists(): - self.add("ERROR", "missing-design-contract-files", self.package_root, "Batch design contract files are missing.") - return - - required_sections = self.parse_design_required_sections(skill_path) - template_sections = self.parse_markdown_headings(template_path) - missing = [section for section in required_sections if section not in template_sections] - for section in missing: - self.add( - "ERROR", - "design-template-missing-section", - template_path, - f"Missing required section '{section}' declared by draft-batch-design.", - ) - - def check_markdown_links(self, files: Iterable[Path], relative_to: Path) -> None: - for file_path in files: - text = file_path.read_text(encoding="utf-8") - for target in LINK_PATTERN.findall(text): - target = target.strip() - if not target or target.startswith(("http://", "https://", "mailto:")): - continue - if target.startswith("#"): - continue - if any(token in target for token in ("<", ">", "{{", "}}")): - continue - link_target = target.split("#", 1)[0] - resolved = (file_path.parent / link_target).resolve() - if not resolved.exists(): - self.add( - "ERROR", - "broken-link", - file_path, - f"Link target '{target}' does not exist.", - ) - - def check_placeholders(self, files: Iterable[Path]) -> None: - for file_path in files: - text = file_path.read_text(encoding="utf-8") - matches = sorted(set(PLACEHOLDER_PATTERN.findall(text))) - if not matches: - continue - status = self.parse_status(text) - level = "ERROR" if status in FINALIZED_STATUSES else "WARN" - preview = ", ".join(matches[:3]) - if len(matches) > 3: - preview += ", ..." - self.add(level, "placeholder-found", file_path, f"Found unresolved placeholders: {preview}") - - def check_discovery_index(self) -> None: - index_path = self.workspace_root / "docs/discovery/index.md" - if not index_path.exists(): - return - - for row in self.parse_markdown_table(index_path, "## Discovery 목록"): - doc_target = self.extract_link_target(row.get("문서", "")) - doc_path = index_path.parent / doc_target if doc_target else None - if not doc_target or not doc_path or not doc_path.exists(): - self.add("ERROR", "discovery-index-link", index_path, f"Discovery row has a missing document target: {row}") - continue - doc_text = doc_path.read_text(encoding="utf-8") - doc_status = self.parse_status(doc_text) - row_status = row.get("상태", "").strip().lower() - if doc_status and row_status and doc_status != row_status: - self.add("ERROR", "discovery-status-mismatch", doc_path, f"Index status '{row_status}' does not match document status '{doc_status}'.") - - def check_srs_index(self) -> None: - index_path = self.workspace_root / "docs/srs/index.md" - if not index_path.exists(): - return - - index_text = index_path.read_text(encoding="utf-8") - req_files = sorted((self.workspace_root / "docs/srs").glob("*/req-*.md")) - register = self.parse_markdown_table(index_path, "## Requirement Register") - register_map = {self.normalize_req_id(row.get("ID", "")): row for row in register if row.get("ID")} - - max_req_number = 0 - for req_path in req_files: - req_id = self.normalize_req_id(req_path.stem.split("_", 1)[0]) - max_req_number = max(max_req_number, self.req_id_number(req_id)) - text = req_path.read_text(encoding="utf-8") - req_status = self.parse_status(text) - req_type = self.parse_req_type(text) - latest_change_req_impact = self.parse_latest_change_req_impact(text) - expected_type = req_path.parent.name - if req_type and expected_type and req_type != expected_type: - self.add("ERROR", "req-folder-type-mismatch", req_path, f"REQ type '{req_type}' does not match folder '{expected_type}'.") - self.check_req_change_rollback(req_path, req_status, latest_change_req_impact) - if req_id not in register_map: - self.add("ERROR", "req-missing-register-row", req_path, "REQ file is missing from Requirement Register.") - continue - row = register_map[req_id] - row_status = row.get("Status", "").strip().lower() - row_type = row.get("Type", "").strip() - row_link = self.extract_link_target(row.get("Link", "")) - if req_status and row_status and req_status != row_status: - self.add("ERROR", "req-status-mismatch", req_path, f"Register status '{row_status}' does not match REQ status '{req_status}'.") - if req_type and row_type and req_type != row_type: - self.add("ERROR", "req-type-register-mismatch", req_path, f"Register type '{row_type}' does not match REQ type '{req_type}'.") - if row_link: - resolved = (index_path.parent / row_link).resolve() - if resolved != req_path.resolve(): - self.add("ERROR", "req-link-mismatch", req_path, f"Register link points to '{row_link}', expected '{req_path.relative_to(index_path.parent)}'.") - - for req_id, row in register_map.items(): - link_target = self.extract_link_target(row.get("Link", "")) - if not link_target: - self.add("ERROR", "req-register-link-missing", index_path, f"Requirement Register row for '{req_id}' is missing a link target.") - continue - linked_path = (index_path.parent / link_target).resolve() - if not linked_path.exists(): - self.add("ERROR", "req-register-link-broken", index_path, f"Requirement Register link '{link_target}' does not exist.") - - next_req = self.parse_next_requirement_id(index_text) - if next_req is not None: - expected_next = max_req_number + 1 if max_req_number else 1 - if next_req != expected_next: - self.add( - "ERROR", - "next-req-id-mismatch", - index_path, - f"Next Requirement ID is REQ-{next_req:03d}, expected REQ-{expected_next:03d}.", - ) - - def check_batch_index(self) -> None: - index_path = self.workspace_root / "docs/batches/index.md" - if not index_path.exists(): - return - - rows = self.parse_markdown_table(index_path, "## Batch Register") - row_map = {row.get("BAT ID", "").strip().lower(): row for row in rows if row.get("BAT ID")} - batch_dirs = sorted(path for path in (self.workspace_root / "docs/batches").glob("bat-*") if path.is_dir()) - for batch_dir in batch_dirs: - batch_id = batch_dir.name.split("_", 1)[0].lower() - batch_index = batch_dir / "index.md" - if not batch_index.exists(): - self.add("ERROR", "batch-index-missing", batch_dir, "Batch folder is missing index.md.") - continue - batch_index_text = batch_index.read_text(encoding="utf-8") - batch_status = self.parse_status(batch_index_text) - batch_profile = self.parse_batch_profile(batch_index_text) - if batch_profile and batch_profile not in BATCH_PROFILES: - self.add( - "ERROR", - "batch-profile-invalid", - batch_index, - f"Unsupported batch profile '{batch_profile}'. Expected 'standard' or 'batch-lite'.", - ) - for doc_name in self.required_batch_docs(batch_profile, batch_status): - if not (batch_dir / doc_name).exists(): - self.add("ERROR", "batch-doc-missing", batch_dir / doc_name, "Batch folder is missing a required document.") - if batch_id not in row_map: - self.add("ERROR", "batch-missing-register-row", batch_index, "Batch folder is missing from Batch Register.") - continue - row = row_map[batch_id] - row_profile = row.get("Profile", "").strip().lower() - row_status = row.get("Status", "").strip().lower() - if row_profile and batch_profile and row_profile != batch_profile: - self.add("ERROR", "batch-profile-mismatch", batch_index, f"Register profile '{row_profile}' does not match batch profile '{batch_profile}'.") - if batch_status and row_status and batch_status != row_status: - self.add("ERROR", "batch-status-mismatch", batch_index, f"Register status '{row_status}' does not match batch status '{batch_status}'.") - - for batch_id, row in row_map.items(): - folder_target = self.extract_link_target(row.get("Folder", "")) or row.get("Folder", "").strip() - if not folder_target: - self.add("ERROR", "batch-register-folder-missing", index_path, f"Batch Register row for '{batch_id}' is missing a folder path.") - continue - linked_path = (index_path.parent / folder_target).resolve() - if not linked_path.exists(): - self.add("ERROR", "batch-register-folder-broken", index_path, f"Batch Register folder '{folder_target}' does not exist.") - - def check_release_index(self) -> None: - index_path = self.workspace_root / "docs/releases/index.md" - if not index_path.exists(): - return - - rows = self.parse_markdown_table(index_path, "## Release Register") - row_map = {row.get("REL ID", "").strip().lower(): row for row in rows if row.get("REL ID")} - release_files = sorted((self.workspace_root / "docs/releases").glob("rel-*.md")) - for release_path in release_files: - rel_id = release_path.stem.split("_", 1)[0].lower() - if rel_id not in row_map: - self.add("ERROR", "release-missing-register-row", release_path, "Release document is missing from Release Register.") - continue - row = row_map[rel_id] - release_text = release_path.read_text(encoding="utf-8") - release_status = self.parse_status(release_text) - release_profile = self.parse_release_profile(release_text) - if release_profile and release_profile not in RELEASE_PROFILES: - self.add( - "ERROR", - "release-profile-invalid", - release_path, - f"Unsupported release profile '{release_profile}'. Expected 'docs-only', 'tooling', or 'app-service'.", - ) - row_profile = row.get("Profile", "").strip().lower() - row_status = row.get("Status", "").strip().lower() - if row_profile and release_profile and row_profile != release_profile: - self.add("ERROR", "release-profile-mismatch", release_path, f"Register profile '{row_profile}' does not match release profile '{release_profile}'.") - if release_status and row_status and release_status != row_status: - self.add("ERROR", "release-status-mismatch", release_path, f"Register status '{row_status}' does not match release status '{release_status}'.") - - for rel_id, row in row_map.items(): - link_target = self.extract_link_target(row.get("Link", "")) - if not link_target: - self.add("ERROR", "release-register-link-missing", index_path, f"Release Register row for '{rel_id}' is missing a link target.") - continue - linked_path = (index_path.parent / link_target).resolve() - if not linked_path.exists(): - self.add("ERROR", "release-register-link-broken", index_path, f"Release Register link '{link_target}' does not exist.") - - def build_traceability_matrix(self) -> None: - req_files = sorted((self.workspace_root / "docs/srs").glob("*/req-*.md")) - if not req_files: - return - - discovery_refs = self.parse_discovery_traceability() - batch_refs, batch_sources, batch_statuses = self.parse_batch_traceability() - release_refs, release_statuses, release_feedback_states = self.parse_release_traceability() - - for release_id, release_status in release_statuses.items(): - feedback_state = release_feedback_states.get(release_id, "missing") - release_path = self.resolve_release_path(release_id) - if release_status == "released": - if feedback_state != "resolved": - self.add( - "WARN", - "release-feedback-unlinked", - release_path, - "Released release has no resolved Feedback Handoff content.", - ) - else: - self.add( - "WARN", - "release-feedback-pending", - release_path, - "Released release has Feedback Handoff content but is not yet closed with feedback-captured status.", - ) - elif release_status == "feedback-captured" and feedback_state != "resolved": - self.add( - "ERROR", - "release-feedback-unresolved", - release_path, - "Release is marked feedback-captured but Feedback Handoff is still unresolved.", - ) - - rows: list[TraceabilityRow] = [] - for req_path in req_files: - req_id = self.normalize_req_id(req_path.stem.split("_", 1)[0]) - if not req_id: - continue - - req_text = req_path.read_text(encoding="utf-8") - req_status = self.parse_status(req_text) - req_batches = set(batch_refs.get(req_id, set())) - req_discoveries = set(discovery_refs.get(req_id, set())) - req_releases: set[str] = set() - - for batch_id in req_batches: - req_discoveries.update(batch_sources.get(batch_id, set())) - req_releases.update(release_refs.get(batch_id, set())) - - releasable_batches = { - batch_id - for batch_id in req_batches - if batch_statuses.get(batch_id) in {"release-candidate", "released"} - } - - flags: list[str] = [] - has_discovery = bool(req_discoveries) - has_batch = bool(req_batches) - - if req_status == "implemented" and not has_batch: - flags.append("implemented-without-batch") - self.add( - "ERROR", - "implemented-req-no-batch", - req_path, - "Implemented REQ has no batch traceability.", - ) - elif req_status in {"proposed", "approved", "implemented"} and not has_discovery and not has_batch: - flags.append("orphan") - self.add( - "WARN", - "orphan-req", - req_path, - "REQ has no Discovery or Batch linkage.", - ) - elif req_status == "approved" and not has_batch: - flags.append("approved-without-batch") - self.add( - "WARN", - "orphan-approved-req", - req_path, - "Approved REQ is not included in any batch.", - ) - elif req_status == "approved" and not releasable_batches: - flags.append("approved-without-release-candidate-batch") - self.add( - "WARN", - "approved-req-no-release-candidate-batch", - req_path, - "Approved REQ is not included in any release-candidate or released batch.", - ) - - rows.append( - TraceabilityRow( - req_id=req_id, - status=req_status or "unknown", - discovery_ids=tuple(sorted(req_discoveries)), - batch_ids=tuple(sorted(req_batches)), - release_ready_batch_ids=tuple(sorted(releasable_batches)), - release_ids=tuple(sorted(req_releases)), - flags=tuple(flags), - ) - ) - - self.traceability_rows = sorted(rows, key=lambda row: self.req_id_number(row.req_id)) - - def parse_discovery_traceability(self) -> dict[str, set[str]]: - refs: dict[str, set[str]] = {} - discovery_dir = self.workspace_root / "docs/discovery" - if not discovery_dir.exists(): - return refs - - for discovery_path in sorted(discovery_dir.glob("dcy-*.md")): - discovery_id = self.normalize_discovery_id(discovery_path.stem) - if not discovery_id: - continue - for line in discovery_path.read_text(encoding="utf-8").splitlines(): - if "생성된 REQ 참조" not in line: - continue - for req_id in self.extract_req_ids(line): - refs.setdefault(req_id, set()).add(discovery_id) - return refs - - def parse_batch_traceability(self) -> tuple[dict[str, set[str]], dict[str, set[str]], dict[str, str]]: - req_to_batches: dict[str, set[str]] = {} - batch_to_discoveries: dict[str, set[str]] = {} - batch_statuses: dict[str, str] = {} - batches_dir = self.workspace_root / "docs/batches" - if not batches_dir.exists(): - return req_to_batches, batch_to_discoveries, batch_statuses - - for batch_dir in sorted(path for path in batches_dir.glob("bat-*") if path.is_dir()): - batch_index = batch_dir / "index.md" - if not batch_index.exists(): - continue - batch_id = self.normalize_batch_id(batch_dir.name) - if not batch_id: - continue - - batch_text = batch_index.read_text(encoding="utf-8") - batch_statuses[batch_id] = self.parse_status(batch_text) - - for line in batch_text.splitlines(): - if "Included REQ" in line: - for req_id in self.extract_req_ids(line): - req_to_batches.setdefault(req_id, set()).add(batch_id) - if "Source Discovery" in line: - for discovery_id in self.extract_discovery_ids(line): - batch_to_discoveries.setdefault(batch_id, set()).add(discovery_id) - - return req_to_batches, batch_to_discoveries, batch_statuses - - def parse_release_traceability(self) -> tuple[dict[str, set[str]], dict[str, str], dict[str, str]]: - batch_to_releases: dict[str, set[str]] = {} - release_statuses: dict[str, str] = {} - release_feedback_states: dict[str, str] = {} - releases_dir = self.workspace_root / "docs/releases" - if not releases_dir.exists(): - return batch_to_releases, release_statuses, release_feedback_states - - for release_path in sorted(releases_dir.glob("rel-*.md")): - release_id = self.normalize_release_id(release_path.stem) - if not release_id: - continue - release_text = release_path.read_text(encoding="utf-8") - release_statuses[release_id] = self.parse_status(release_text) - release_feedback_states[release_id] = self.parse_release_feedback_state(release_text) - for line in release_text.splitlines(): - if "Included Batch" not in line: - continue - for batch_id in self.extract_batch_ids(line): - batch_to_releases.setdefault(batch_id, set()).add(release_id) - - return batch_to_releases, release_statuses, release_feedback_states - - def write_report(self) -> None: - if self.report_path is None: - return - - try: - self.report_path.parent.mkdir(parents=True, exist_ok=True) - self.report_path.write_text(self.render_markdown_report(), encoding="utf-8") - self.report_written = True - except OSError as exc: - self.add( - "ERROR", - "report-write-failed", - self.report_path, - f"Failed to write Markdown report: {exc}", - ) - - def render_markdown_report(self) -> str: - error_count, warn_count, info_count = self.summarize_findings() - lines = [ - "# StagePilot Doctor Report", - "", - "## Metadata", - "", - f"- Generated At: `{datetime.now().astimezone().isoformat(timespec='seconds')}`", - f"- Workspace Root: `{self.workspace_root}`", - f"- Package Root: `{self.package_root}`", - f"- Strict Missing Docs: `{str(self.strict_missing_docs).lower()}`", - ] - if self.report_path is not None: - lines.append(f"- Requested Report Path: `{self.report_path}`") - - lines.extend( - [ - "", - "## Summary", - "", - f"- Errors: {error_count}", - f"- Warnings: {warn_count}", - f"- Info: {info_count}", - "", - "## Findings", - "", - ] - ) - - sorted_findings = self.sorted_findings() - if not sorted_findings: - lines.append("- None") - else: - for level in ("ERROR", "WARN", "INFO"): - level_findings = [finding for finding in sorted_findings if finding.level == level] - if not level_findings: - continue - lines.append(f"### {level}") - lines.append("") - for finding in level_findings: - lines.append( - f"- [{finding.code}] `{self.display_path(finding.path)}`: {finding.message}" - ) - lines.append("") - - lines.extend(["## Traceability Matrix", ""]) - if not self.traceability_rows: - lines.append("- No traceability rows generated.") - else: - lines.extend( - [ - "| REQ ID | Status | Discovery | Batch | Release-Ready Batch | Release | Flags |", - "| --- | --- | --- | --- | --- | --- | --- |", - ] - ) - for row in self.traceability_rows: - discovery = ", ".join(row.discovery_ids) if row.discovery_ids else "-" - batch = ", ".join(row.batch_ids) if row.batch_ids else "-" - release_ready_batch = ", ".join(row.release_ready_batch_ids) if row.release_ready_batch_ids else "-" - release = ", ".join(row.release_ids) if row.release_ids else "-" - flags = ", ".join(row.flags) if row.flags else "-" - lines.append( - f"| {row.req_id} | {row.status} | {discovery} | {batch} | {release_ready_batch} | {release} | {flags} |" - ) - - lines.extend(["", "## Feedback Loop Summary", ""]) - if not self.feedback_handoff_rows: - lines.append("- No feedback handoff rows generated.") - else: - lines.extend( - [ - "| Release | Status | Discovery Input | REQ Input | Change Request Input | Flags |", - "| --- | --- | --- | --- | --- | --- |", - ] - ) - for row in self.feedback_handoff_rows: - discovery_inputs = ", ".join(row.discovery_inputs) if row.discovery_inputs else "-" - req_inputs = ", ".join(row.req_inputs) if row.req_inputs else "-" - change_req_inputs = ", ".join(row.change_req_inputs) if row.change_req_inputs else "-" - flags = ", ".join(row.flags) if row.flags else "-" - lines.append( - f"| {row.release_id} | {row.status} | {discovery_inputs} | {req_inputs} | {change_req_inputs} | {flags} |" - ) - - return "\n".join(lines) + "\n" - - def sorted_findings(self) -> list[Finding]: - severity_order = {"ERROR": 0, "WARN": 1, "INFO": 2} - return sorted( - self.findings, - key=lambda item: (severity_order.get(item.level, 99), str(item.path), item.code), - ) - - def summarize_findings(self) -> tuple[int, int, int]: - error_count = sum(1 for finding in self.findings if finding.level == "ERROR") - warn_count = sum(1 for finding in self.findings if finding.level == "WARN") - info_count = sum(1 for finding in self.findings if finding.level == "INFO") - return error_count, warn_count, info_count - - def print_report(self) -> None: - if not self.findings: - print("stagepilot-doctor: no issues found") - else: - for finding in self.sorted_findings(): - display_path = self.display_path(finding.path) - print(f"{finding.level} [{finding.code}] {display_path}: {finding.message}") - - if self.traceability_rows: - print() - print("Traceability Matrix") - print("REQ ID | Status | Discovery | Batch | Release-Ready Batch | Release | Flags") - print("--- | --- | --- | --- | --- | --- | ---") - for row in self.traceability_rows: - discovery = ", ".join(row.discovery_ids) if row.discovery_ids else "-" - batch = ", ".join(row.batch_ids) if row.batch_ids else "-" - release_ready_batch = ", ".join(row.release_ready_batch_ids) if row.release_ready_batch_ids else "-" - release = ", ".join(row.release_ids) if row.release_ids else "-" - flags = ", ".join(row.flags) if row.flags else "-" - print(f"{row.req_id} | {row.status} | {discovery} | {batch} | {release_ready_batch} | {release} | {flags}") - - if self.feedback_handoff_rows: - print() - print("Feedback Loop Summary") - print("Release | Status | Discovery Input | REQ Input | Change Request Input | Flags") - print("--- | --- | --- | --- | --- | ---") - for row in self.feedback_handoff_rows: - discovery_inputs = ", ".join(row.discovery_inputs) if row.discovery_inputs else "-" - req_inputs = ", ".join(row.req_inputs) if row.req_inputs else "-" - change_req_inputs = ", ".join(row.change_req_inputs) if row.change_req_inputs else "-" - flags = ", ".join(row.flags) if row.flags else "-" - print(f"{row.release_id} | {row.status} | {discovery_inputs} | {req_inputs} | {change_req_inputs} | {flags}") - - error_count, warn_count, info_count = self.summarize_findings() - print(f"stagepilot-doctor: {error_count} error(s), {warn_count} warning(s), {info_count} info message(s)") - if self.report_written and self.report_path is not None: - print(f"stagepilot-doctor: wrote Markdown report to {self.report_path}") - - def display_path(self, path: Path) -> str: - for base in (self.workspace_root, self.package_root): - try: - return str(path.resolve().relative_to(base.resolve())) or "." - except ValueError: - continue - return str(path) - - def resolve_release_path(self, release_id: str) -> Path: - releases_dir = self.workspace_root / "docs/releases" - matches = sorted(releases_dir.glob(f"{release_id}_*.md")) - if matches: - return matches[0] - return releases_dir / f"{release_id}.md" - - def check_req_change_rollback(self, req_path: Path, req_status: str, latest_change_req_impact: ChangeReqImpact | None) -> None: - if latest_change_req_impact is None: - return - - recommendation = latest_change_req_impact.status_recommendation - implementation_invalidated = latest_change_req_impact.implementation_invalidated == "yes" - reverification_needed = latest_change_req_impact.reverification_needed == "yes" - - if recommendation == "revert-to-approved" and req_status == "implemented": - self.add( - "ERROR", - "req-change-rollback-missing", - req_path, - f"Latest change '{latest_change_req_impact.change_id}' recommends reverting to Approved, but REQ status is still Implemented.", - ) - elif recommendation == "revert-to-proposed" and req_status in {"approved", "implemented"}: - self.add( - "ERROR", - "req-change-rollback-missing", - req_path, - f"Latest change '{latest_change_req_impact.change_id}' recommends reverting to Proposed, but REQ status is still '{req_status}'.", - ) - elif implementation_invalidated and req_status == "implemented": - self.add( - "ERROR", - "req-change-rollback-missing", - req_path, - f"Latest change '{latest_change_req_impact.change_id}' invalidates existing implementation, but REQ status is still Implemented.", - ) - elif reverification_needed and req_status == "implemented": - self.add( - "WARN", - "req-change-reverification-pending", - req_path, - f"Latest change '{latest_change_req_impact.change_id}' requires reverification while REQ remains Implemented.", - ) - - def build_feedback_handoff_summary(self) -> None: - releases_dir = self.workspace_root / "docs/releases" - if not releases_dir.exists(): - self.feedback_handoff_rows = [] - return - - existing_req_ids = { - self.normalize_req_id(path.stem.split("_", 1)[0]) - for path in sorted((self.workspace_root / "docs/srs").glob("*/req-*.md")) - } - rows: list[FeedbackHandoffRow] = [] - - for release_path in sorted(releases_dir.glob("rel-*.md")): - release_id = self.normalize_release_id(release_path.stem) - if not release_id: - continue - - release_text = release_path.read_text(encoding="utf-8") - feedback_inputs = self.parse_release_feedback_inputs(release_text) - discovery_inputs = self.normalize_feedback_values(feedback_inputs.get("discovery", [])) - req_inputs = self.normalize_feedback_values(feedback_inputs.get("req", [])) - change_req_inputs = self.normalize_feedback_values(feedback_inputs.get("change-req", [])) - - flags: list[str] = [] - if not discovery_inputs and not req_inputs and not change_req_inputs: - flags.append("no-follow-up-input") - - referenced_change_req_ids = { - req_id - for value in change_req_inputs - for req_id in self.extract_req_ids(value) - } - missing_change_req_ids = sorted(req_id for req_id in referenced_change_req_ids if req_id not in existing_req_ids) - if missing_change_req_ids: - flags.append("unknown-change-req-target") - self.add( - "WARN", - "feedback-change-req-target-missing", - release_path, - f"Change Request Input references missing REQ IDs: {', '.join(missing_change_req_ids)}", - ) - - rows.append( - FeedbackHandoffRow( - release_id=release_id, - status=self.parse_status(release_text) or "unknown", - discovery_inputs=discovery_inputs, - req_inputs=req_inputs, - change_req_inputs=change_req_inputs, - flags=tuple(flags), - ) - ) - - self.feedback_handoff_rows = rows - - @staticmethod - def parse_req_types_from_skill(path: Path) -> list[str]: - lines = path.read_text(encoding="utf-8").splitlines() - collecting = False - types: list[str] = [] - for line in lines: - if "아래 폴더 중 정확히 하나를 선택한다." in line: - collecting = True - continue - if collecting and line.startswith("- 새 REQ 경로"): - break - if collecting: - match = INLINE_CODE_PATTERN.search(line) - if match: - types.append(match.group(1)) - return types - - @staticmethod - def parse_req_types_from_template(path: Path) -> list[str]: - text = path.read_text(encoding="utf-8") - match = REQ_TYPE_LINE_PATTERN.search(text) - if not match: - return [] - return [part.strip() for part in match.group(1).split("|") if part.strip()] - - @staticmethod - def parse_req_types_from_index(path: Path) -> list[str]: - rows = Doctor.parse_markdown_table(path, "## Requirement 타입 분류 도표") - types: list[str] = [] - for row in rows: - type_name = row.get("타입", "").strip().strip("*") - if type_name: - types.append(type_name) - return types - - @staticmethod - def parse_design_required_sections(path: Path) -> list[str]: - lines = path.read_text(encoding="utf-8").splitlines() - collecting = False - sections: list[str] = [] - for line in lines: - if "design.md에는 최소한 아래 내용을 포함해야 한다." in line: - collecting = True - continue - if collecting and not line.startswith(("\t-", " -", "- ")): - if line.strip(): - break - if collecting: - entry = line.strip().lstrip("-").strip() - if not entry: - continue - cleaned = entry.strip("`") - if "(" in cleaned: - cleaned = cleaned.split("(", 1)[0].strip() - sections.append(cleaned) - return sections - - @staticmethod - def parse_markdown_headings(path: Path) -> set[str]: - headings = set() - for line in path.read_text(encoding="utf-8").splitlines(): - if line.startswith("## "): - headings.add(line[3:].strip()) - return headings - - @staticmethod - def parse_markdown_table(path: Path, heading: str) -> list[dict[str, str]]: - lines = path.read_text(encoding="utf-8").splitlines() - start_index = None - for index, line in enumerate(lines): - if line.strip() == heading: - start_index = index + 1 - break - if start_index is None: - return [] - - table_lines: list[str] = [] - for line in lines[start_index:]: - if line.strip().startswith("|"): - table_lines.append(line) - continue - if table_lines: - break - - if len(table_lines) < 2: - return [] - - header = [cell.strip() for cell in table_lines[0].strip().strip("|").split("|")] - rows: list[dict[str, str]] = [] - for line in table_lines[2:]: - cells = [cell.strip() for cell in line.strip().strip("|").split("|")] - if len(cells) != len(header): - continue - rows.append(dict(zip(header, cells))) - return rows - - @staticmethod - def extract_link_target(cell: str) -> str | None: - match = LINK_PATTERN.search(cell) - if match: - return match.group(1).split("#", 1)[0] - return None - - @staticmethod - def parse_status(text: str) -> str: - match = STATUS_PATTERN.search(text) - if not match: - return "" - value = match.group(1).strip().strip("`") - return value.split("|")[0].strip().lower() - - @staticmethod - def parse_batch_profile(text: str) -> str: - match = BATCH_PROFILE_PATTERN.search(text) - if not match: - return "standard" - value = match.group(1).strip().strip("`") - return value.split("|")[0].strip().lower() - - @staticmethod - def parse_release_profile(text: str) -> str: - match = RELEASE_PROFILE_PATTERN.search(text) - if not match: - return "app-service" - value = match.group(1).strip().strip("`") - return value.split("|")[0].strip().lower() - - @staticmethod - def required_batch_docs(profile: str, status: str) -> tuple[str, ...]: - normalized_profile = profile or "standard" - normalized_status = status or "draft" - if normalized_profile == "batch-lite": - required_docs = ["planning.md"] - if normalized_status in {"in-delivery", "release-candidate", "released", "archived"}: - required_docs.append("implementation.md") - if normalized_status in {"release-candidate", "released", "archived"}: - required_docs.append("verification.md") - return tuple(required_docs) - return ("planning.md", "design.md", "implementation.md", "verification.md") - - @staticmethod - def parse_req_type(text: str) -> str: - match = REQ_TYPE_LINE_PATTERN.search(text) - if not match: - return "" - value = match.group(1).strip() - return value.split("|")[0].strip() - - @staticmethod - def parse_next_requirement_id(text: str) -> int | None: - match = re.search(r"Current:\s*`REQ-(\d+)`", text) - if not match: - return None - return int(match.group(1)) - - @staticmethod - def parse_release_feedback_state(text: str) -> str: - lines = text.splitlines() - in_section = False - values: list[str] = [] - for line in lines: - stripped = line.strip() - if stripped == "## Feedback Handoff": - in_section = True - continue - if in_section and stripped.startswith("## "): - break - if not in_section: - continue - if stripped.startswith("- "): - value = stripped[2:].strip() - if value.endswith(":"): - continue - values.append(value) - - if not in_section: - return "missing" - if not values: - return "missing" - - normalized_values = {value.strip().lower() for value in values} - unresolved_tokens = ( - "<", - ">", - "{{", - "}}", - "후속 discovery 후보", - "후속 req 후보", - "tbd", - "미정", - ) - if any(any(token in value for token in unresolved_tokens) for value in normalized_values): - return "unresolved" - if normalized_values.issubset({"없음", "none", "n/a", "na"}): - return "resolved" - return "resolved" - - @staticmethod - def parse_latest_change_req_impact(text: str) -> ChangeReqImpact | None: - sections = re.split(r"^###\s+(CHG-\d{8}-\d+)\s*$", text, flags=re.MULTILINE) - if len(sections) < 3: - return None - - latest_change_id = sections[-2].strip() - latest_change_body = sections[-1] - status_recommendation = Doctor.parse_change_log_field(latest_change_body, "Status Recommendation") - reverification_needed = Doctor.parse_change_log_field(latest_change_body, "Reverification Needed") - implementation_invalidated = Doctor.parse_change_log_field(latest_change_body, "Existing Implementation Invalidated") - - if not status_recommendation and not reverification_needed and not implementation_invalidated: - return None - - return ChangeReqImpact( - change_id=latest_change_id, - status_recommendation=status_recommendation, - reverification_needed=reverification_needed, - implementation_invalidated=implementation_invalidated, - ) - - @staticmethod - def parse_change_log_field(text: str, field_name: str) -> str: - pattern = rf"^-\s*{re.escape(field_name)}:\s*(.+?)\s*$" - match = re.search(pattern, text, flags=re.MULTILINE) - if not match: - return "" - return match.group(1).strip().strip("`").lower() - - @staticmethod - def parse_release_feedback_inputs(text: str) -> dict[str, list[str]]: - lines = text.splitlines() - in_section = False - current_group: str | None = None - groups: dict[str, list[str]] = { - "observation": [], - "discovery": [], - "req": [], - "change-req": [], - } - group_labels = { - "Observation Summary": "observation", - "Discovery Input": "discovery", - "REQ Input": "req", - "Change Request Input": "change-req", - } - - for line in lines: - stripped = line.strip() - if stripped == "## Feedback Handoff": - in_section = True - current_group = None - continue - if in_section and stripped.startswith("## "): - break - if not in_section or not stripped.startswith("- "): - continue - - value = stripped[2:].strip() - if value.endswith(":"): - current_group = group_labels.get(value[:-1].strip()) - continue - if current_group is not None: - groups[current_group].append(value) - - return groups - - @staticmethod - def normalize_feedback_values(values: Iterable[str]) -> tuple[str, ...]: - normalized: list[str] = [] - for value in values: - cleaned = value.strip() - if not cleaned or cleaned.lower() in {"없음", "none", "n/a", "na"}: - continue - normalized.append(cleaned) - return tuple(normalized) - - @staticmethod - def extract_req_ids(text: str) -> set[str]: - return {f"req-{int(match.group(1)):03d}" for match in REQ_ID_PATTERN.finditer(text)} - - @staticmethod - def extract_batch_ids(text: str) -> set[str]: - return {f"bat-{int(match.group(1)):03d}" for match in BATCH_ID_PATTERN.finditer(text)} - - @staticmethod - def extract_discovery_ids(text: str) -> set[str]: - return {f"dcy-{int(match.group(1)):03d}" for match in DISCOVERY_ID_PATTERN.finditer(text)} - - @staticmethod - def extract_release_ids(text: str) -> set[str]: - return {f"rel-{int(match.group(1)):03d}" for match in RELEASE_ID_PATTERN.finditer(text)} - - @staticmethod - def normalize_req_id(raw_value: str) -> str: - match = REQ_ID_PATTERN.search(raw_value or "") - if not match: - return "" - return f"req-{int(match.group(1)):03d}" - - @staticmethod - def normalize_batch_id(raw_value: str) -> str: - match = BATCH_ID_PATTERN.search(raw_value or "") - if not match: - return "" - return f"bat-{int(match.group(1)):03d}" - - @staticmethod - def normalize_discovery_id(raw_value: str) -> str: - match = DISCOVERY_ID_PATTERN.search(raw_value or "") - if not match: - return "" - return f"dcy-{int(match.group(1)):03d}" - - @staticmethod - def normalize_release_id(raw_value: str) -> str: - match = RELEASE_ID_PATTERN.search(raw_value or "") - if not match: - return "" - return f"rel-{int(match.group(1)):03d}" - - @staticmethod - def req_id_number(req_id: str) -> int: - match = REQ_ID_PATTERN.search(req_id) - return int(match.group(1)) if match else 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Validate StagePilot package contracts and active SDLC workspace documents.") - parser.add_argument("root", nargs="?", default=".", help="Workspace root to validate (default: current directory)") - parser.add_argument( - "--strict-missing-docs", - action="store_true", - help="Treat a missing active docs workspace as an error instead of a warning.", - ) - parser.add_argument( - "--report", - help="Write a Markdown report to the given path.", - ) - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - - workspace_root = Path(args.root).resolve() - package_root = Path(__file__).resolve().parents[2] - report_path = Path(args.report).resolve() if args.report else None - - if not workspace_root.exists(): - parser.error(f"Workspace root does not exist: {workspace_root}") - - doctor = Doctor( - workspace_root=workspace_root, - package_root=package_root, - strict_missing_docs=args.strict_missing_docs, - report_path=report_path, - ) - return doctor.run() - - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file diff --git a/.github/skills/bootstrap-baseline/SKILL.md b/.github/skills/bootstrap-baseline/SKILL.md deleted file mode 100644 index 9c7f34b..0000000 --- a/.github/skills/bootstrap-baseline/SKILL.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -name: bootstrap-baseline -description: "Use when: initializing StagePilot in a project before the first real Discovery, running /bootstrap-baseline to create baseline documents and active indexes, or backfilling missing docs/project-structure.md, docs/runtime-flows.md, docs/interface-contract.md, and docs/data-model.md without creating a Discovery unit." -argument-hint: "예: 신규 저장소 baseline 초기화, StagePilot init, baseline 문서/인덱스 생성" -user-invocable: true ---- - -# Purpose - -This skill initializes the StagePilot baseline outside the normal Discovery -> REQ -> Batch -> Release flow. - -`docs/project-structure.md`, `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/data-model.md`, 그리고 active index 문서는 첫 real Discovery 전에 준비하는 bootstrap 산출물이다. 이 skill은 baseline과 index를 먼저 만들고, 첫 Discovery가 실제 제품/서비스 변경 주제로 시작되도록 만든다. - -greenfield 저장소처럼 읽을 코드나 설정이 아직 없으면, 이 skill은 사용자에게 최소 질문 세트를 묻고 `.stagepilot/bootstrap/baseline.yaml` seed를 만든 뒤 baseline 문서를 렌더링한다. - -# When to use - -다음 상황에서 사용한다. - -- StagePilot을 새 프로젝트에 처음 적용한 직후 baseline 문서와 index를 만들 때 -- `/bootstrap-baseline` 또는 유사한 init 요청으로 baseline 초기화를 수행할 때 -- `docs/project-structure.md`, `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/data-model.md`, `docs/discovery/index.md`, `docs/srs/index.md`, `docs/batches/index.md`, `docs/releases/index.md` 중 일부가 없어서 SDLC를 시작하기 전에 뼈대를 복구해야 할 때 -- 첫 real Discovery를 기능, 운영 문제, 기술 결정 같은 실제 변경 주제로 시작하고 싶을 때 - -다음 상황에는 이 skill을 기본 경로로 사용하지 않는다. - -- 이미 진행 중인 Discovery/REQ/Batch/Release가 있고, baseline 변경 책임이 특정 active unit에 속하는 경우 -- 실제 제품/서비스 변경 주제가 이미 명확하여 Discovery 초안 생성이 우선인 경우 - -# Inputs - -이 skill은 아래 입력을 사용한다. - -- 현재 저장소 루트와 디렉터리 구조 -- `docs/discovery/`, `docs/srs/`, `docs/batches/`, `docs/releases/` 존재 여부 -- `.stagepilot/bootstrap/baseline.yaml` 존재 여부 -- `docs/project-structure.md`, `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/data-model.md` 존재 여부 -- 템플릿 파일 - - `.github/templates/bootstrap/baseline-seed.yaml` - - `.github/templates/discovery/index.md` - - `.github/templates/srs/index.md` - - `.github/templates/batches/index.md` - - `.github/templates/releases/index.md` - - `.github/templates/project-structure.md` - - `.github/templates/runtime-flows.md` - - `.github/templates/interface-contract.md` - - `.github/templates/data-model.md` -- 현재 시각 (KST) - -# Core Rules - -## 0. 단위 경계 - -- `bootstrap-baseline`은 Discovery, REQ, Batch, Release 중 어느 것도 아니다. -- 이 skill은 초기 운영 기준선을 만드는 bootstrap 단계다. -- 이 skill은 Discovery 문서를 생성하지 않는다. -- baseline 초기화가 끝난 뒤 첫 real Discovery는 실제 제품/서비스 변경, 운영 이슈, 기술 결정 같은 주제로 시작한다. - -## 1. 생성 대상 - -- 아래 경로가 없으면 생성한다. - - `.stagepilot/bootstrap/baseline.yaml` - - `docs/discovery/index.md` - - `docs/srs/index.md` - - `docs/batches/index.md` - - `docs/releases/index.md` - - `docs/project-structure.md` - - `docs/runtime-flows.md` - - `docs/interface-contract.md` - - `docs/data-model.md` -- 이미 존재하는 파일은 임의로 덮어쓰지 않는다. -- 일부만 없는 경우에는 누락된 파일만 생성한다. - -## 1.5 최소 질문 세트 - -- 저장소 관찰만으로 baseline 핵심 정보가 부족하면 먼저 질문을 수행한다. -- 질문은 아래 다섯 개를 기본 세트로 사용한다. - - `project-summary` - - 질문: `이 프로젝트를 한 문장으로 설명하면 무엇인가?` - - 답변 형식: 한 문장 자유 텍스트 - - `primary-domain` - - 질문: `이 프로젝트의 주요 도메인은 무엇인가?` - - 답변 형식: 짧은 명사구 또는 분야명 - - `tech-stack` - - 질문: `계획 중인 주 언어, 프레임워크, 핵심 인프라는 무엇인가?` - - 답변 형식: 쉼표로 구분한 목록 - - `primary-runtime` - - 질문: `계획 중인 주 실행 형태는 무엇인가?` - - 답변 형식: `cli` | `api-service` | `web-app` | `worker` | `library` | `mixed` | `other` - - `primary-entrypoints` - - 질문: `대표 진입점 1~3개를 적어 달라.` - - 답변 형식: 줄마다 `name :: purpose` -- 아래 질문은 선택 입력이다. - - `planned-top-level-areas` - - 질문: `이미 알고 있는 top-level 경로가 있으면 적어 달라.` - - 답변 형식: 줄마다 `path :: responsibility` - - `interface-contracts` - - 질문: `대표 인터페이스 1~2개가 있으면 적어 달라.` - - 답변 형식: 줄마다 `name :: type :: actors :: purpose` - - `interface-inputs` - - 질문: `각 인터페이스의 대표 input을 적어 달라.` - - 답변 형식: 줄마다 `interface-name :: input contract or invocation note` - - `interface-outputs` - - 질문: `각 인터페이스의 대표 output을 적어 달라.` - - 답변 형식: 줄마다 `interface-name :: output contract or artifact` - - `interface-errors` - - 질문: `각 인터페이스의 대표 error를 적어 달라.` - - 답변 형식: 줄마다 `interface-name :: error code or failure behavior` - - `core-entities` - - 질문: `핵심 엔티티 1~2개가 있으면 적어 달라.` - - 답변 형식: 줄마다 `name :: purpose` - - `persistence-backend` - - 질문: `주 persistence backend가 있으면 적어 달라.` - - 답변 형식: 짧은 backend 또는 storage 이름 - - `compatibility-rules` - - 질문: `인터페이스 호환성 규칙이 있으면 적어 달라.` - - 답변 형식: 줄마다 `interface-name :: rule` 또는 `* :: rule` - - `known-unknowns` - - 질문: `아직 미정인 핵심 항목이 있으면 적어 달라.` - - 답변 형식: 줄마다 항목 하나 -- 이미 읽을 수 있는 저장소 증거가 있으면 질문 수를 줄이고, seed의 비어 있는 값만 보충한다. -- `interface-contracts`, `interface-inputs`, `interface-outputs`, `interface-errors`, `core-entities`, `persistence-backend`, `compatibility-rules`가 비어 있더라도 bootstrap은 `runtime`, `primary-entrypoints`, `primary-domain`, `tech-stack`, `planned-top-level-areas`, `known-unknowns`를 바탕으로 기본 초안을 유도해 seed를 보강할 수 있다. - -## 1.6 seed 파일 source-of-truth 규칙 - -- bootstrap 질문 결과와 저장소 관찰 결과의 합성 source of truth는 `.stagepilot/bootstrap/baseline.yaml`이다. -- seed 파일은 active SDLC unit가 아니라 bootstrap 선언 파일이다. -- seed 파일이 있으면 baseline 문서 생성과 갱신 시 이를 우선 읽는다. -- seed 파일이 없고 저장소 증거가 충분하지 않으면 질문 없이 baseline 문서를 추정 생성하지 않는다. -- 저장소 관찰값과 사용자 답변이 함께 있으면 `capture_mode`는 `mixed`다. -- 저장소 관찰값만으로 충분하면 `capture_mode`는 `observed`다. -- 사용자 선언이 중심이고 저장소 관찰값은 보조면 `capture_mode`는 `declared`다. - -## 2. baseline 문서 작성 원칙 - -- `docs/project-structure.md`는 seed의 `project`, `stack`, `runtime`, `structure` 값을 먼저 반영하고, 저장소 관찰 결과가 있으면 보강한다. -- `docs/runtime-flows.md`는 seed의 `runtime`과 `notes` 값을 먼저 반영하고, 저장소 관찰 결과가 있으면 보강한다. -- `docs/interface-contract.md`는 seed의 `interfaces` 섹션을 먼저 반영하고, 값이 부족하면 `runtime`, `primary-entrypoints`, `known-unknowns`를 바탕으로 대표 외부 API, CLI, 이벤트, 파일 I/O 계약의 기본 초안을 구체화한다. -- `docs/data-model.md`는 seed의 `data_model` 섹션을 먼저 반영하고, 값이 부족하면 `primary-domain`, `tech-stack`, `planned-top-level-areas`, `known-unknowns`를 바탕으로 핵심 엔티티, 관계, 상태, persistence 규칙의 기본 초안을 구체화한다. -- 입력만으로 알 수 없는 항목은 의미 있는 플레이스홀더로 남기되, 추론 가능한 구조 정보는 실제 문장으로 채운다. -- baseline 문서의 `Source Discovery / Batch`는 bootstrap 산출물임을 드러내는 값으로 채운다. -- baseline 문서는 `declared`, `observed`, `mixed` 중 어느 모드로 렌더링되었는지와 seed 경로를 드러내야 한다. - -## 3. index 작성 원칙 - -- index 문서는 템플릿을 기반으로 생성한다. -- 초기화 시점에는 register 본문만 만들고 실제 Discovery/REQ/Batch/Release 항목은 비워 둔다. -- index 생성은 active unit 생성으로 간주하지 않는다. - -## 4. 진행 중 unit가 있는 경우 - -- 진행 중인 Discovery/REQ/Batch/Release가 이미 있고 baseline 문서만 일부 비어 있다면, 먼저 현재 active unit가 그 변경을 소유해야 하는지 판정한다. -- 특정 active unit의 요구사항이나 설계/검증 근거로 baseline 변경이 필요한 경우에는 그 unit 안에서 처리한다. -- 단순 bootstrap 누락 복구이고 active unit 책임이 아니라면 `bootstrap-baseline`으로 누락 파일만 보강할 수 있다. - -# Execution Procedure - -1. 저장소에서 active docs 루트와 baseline 파일 존재 여부를 확인한다. -2. `.stagepilot/bootstrap/baseline.yaml`이 없거나 핵심 필드가 비어 있으면 저장소 관찰로 채울 수 있는 값과 질문이 필요한 값을 분리한다. -3. 저장소 관찰만으로 부족하면 최소 질문 세트를 사용해 사용자 선언 입력을 수집한다. -4. `.github/templates/bootstrap/baseline-seed.yaml`를 기준으로 `.stagepilot/bootstrap/baseline.yaml`을 생성 또는 보강한다. -5. 누락된 디렉터리가 있으면 `docs/discovery`, `docs/srs`, `docs/batches`, `docs/releases`를 먼저 준비한다. -6. 누락된 index 파일을 각 템플릿으로 생성한다. -7. seed 파일과 저장소 상태를 반영해 `docs/project-structure.md`, `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/data-model.md`를 생성 또는 보강한다. -8. 이미 존재하는 파일은 보존하고, 필요한 경우에만 누락 복구 또는 seed 반영 사실을 결과에 기록한다. -9. 완료 후 다음 단계로 `new-discovery`를 사용해 첫 real Discovery를 시작하도록 안내한다. - -# Output Expectations - -- 생성 또는 복구한 파일 목록 -- seed 파일 상태 (`created` | `preserved` | `updated`) -- baseline 문서 상태 (`created` | `preserved` | `missing-information`) -- index 문서 상태 (`created` | `preserved`) -- 첫 real Discovery에서 다뤄야 할 주제 후보 또는 다음 action - -# Validation - -- 생성한 파일 경로가 active docs 구조와 일치하는지 확인한다. -- `.stagepilot/bootstrap/baseline.yaml`의 필수 필드가 비어 있지 않은지 확인한다. -- baseline 문서와 index에 남아 있는 플레이스홀더가 정말 사람 결정이 필요한 값인지 점검한다. -- baseline 문서의 `Project Summary`, `Primary Domain`, `Tech Stack`, `Primary Runtime`와 공통 계약 정보가 seed 및 저장소 관찰과 모순되지 않는지 확인한다. -- Discovery 문서를 생성하지 않았는지 확인한다. \ No newline at end of file diff --git a/.github/skills/capture-release-feedback/SKILL.md b/.github/skills/capture-release-feedback/SKILL.md deleted file mode 100644 index 43a0339..0000000 --- a/.github/skills/capture-release-feedback/SKILL.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: capture-release-feedback -description: "Use when: recording post-release observations, running /capture-release-feedback with a REL ID, appending feedback and follow-up inputs to docs/releases/rel-XXX_*.md, or marking included batches as released after deployment feedback is captured." -argument-hint: "예: rel-001 또는 docs/releases/rel-001_20260424_scaffold.md" -user-invocable: true ---- - -# Purpose - -This skill records post-release observations, converts them into follow-up Discovery or REQ inputs, and closes the release with captured feedback. - -# Inputs - -- `REL-ID` 또는 release 경로 -- 운영 관찰 결과 -- 대상 release 문서 -- 포함 batch 목록 -- `docs/releases/index.md` -- `docs/batches/index.md` - -# Core Rules - -- 운영 관찰 결과는 사실과 후속 입력으로 분리해 기록한다. -- 후속 입력은 `Discovery Input`, `REQ Input`, `Change Request Input`으로 구분한다. -- release feedback을 남긴 뒤 release 상태는 `feedback-captured`로 전환할 수 있다. -- 포함 batch 상태는 필요하면 `released`로 갱신한다. - -# Execution Procedure - -1. release 문서와 포함 batch를 읽는다. -2. 운영 관찰 결과를 release 문서의 Feedback Handoff 섹션에 반영한다. -3. 후속 Discovery 입력, 신규 REQ 입력, 기존 REQ 변경 후보를 분리해 기록한다. -4. release index 상태를 `feedback-captured`로 갱신하고, 포함 batch 상태를 `released`로 조정한다. - -# Validation - -- release 문서에 운영 관찰 결과와 `Discovery Input`, `REQ Input`, `Change Request Input`이 모두 남았는지 확인한다. -- release index 상태와 batch index 상태가 문서 본문과 일치하는지 확인한다. \ No newline at end of file diff --git a/.github/skills/change-req/SKILL.md b/.github/skills/change-req/SKILL.md deleted file mode 100644 index f65683f..0000000 --- a/.github/skills/change-req/SKILL.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: change-req -description: "Use when: updating an existing requirement after approval or implementation, running /change-req with a REQ ID or file path, recording a change-request entry before overwriting a REQ, analyzing impacted batches/releases, or deciding whether an Implemented REQ must be downgraded and reverified." -argument-hint: "예: req-001 또는 docs/srs/Documentation/req-001_minor-docs.md" -user-invocable: true ---- - -# Purpose - -This skill applies a controlled change to an existing REQ by recording a new change-request entry before editing the requirement body, analyzing downstream delivery impact, and updating status/index state when the prior implementation is no longer valid. - -# When to use - -- `/change-req req-001`처럼 기존 REQ의 Requirement, Acceptance Criteria, Impacted Area를 수정해야 할 때 -- 운영 피드백이나 release feedback에서 기존 REQ 수정 필요가 확인됐을 때 -- `Implemented` 또는 `Approved` REQ가 더 이상 현재 구현/검증 증거와 맞지 않는지 판단해야 할 때 -- `docs/srs/index.md`의 REQ 상태와 실제 변경 영향 범위를 다시 동기화해야 할 때 - -# Inputs - -- 선택 입력 - - `REQ-ID` prefix - - REQ 파일 경로 -- 변경 요청 근거 - - 사용자 요청 - - release feedback - - batch verification 또는 implementation 결과 -- 대상 REQ 문서 -- `docs/srs/index.md` -- 관련 `docs/batches/index.md` -- 관련 `docs/releases/index.md` -- 영향이 의심되는 batch/release 문서들 - -# Core Rules - -## 1. 입력 해석 - -- prefix 입력이면 `docs/srs/**/req-001_*.md` 형식으로 찾는다. -- 동일 prefix에 여러 문서가 매칭되면 임의 선택하지 않는다. -- REQ가 `Deprecated`면 기본적으로 새 변경 요청 대상으로 삼지 않는다. 복구가 필요하면 근거를 먼저 명시한다. - -## 2. 변경 요청 기록 규칙 - -- 기존 `Requirement`, `Acceptance Criteria`, `Impacted Area`를 덮어쓰기 전에 새 `Change Log` 항목을 추가한다. -- 새 변경 항목 ID는 `CHG-YYYYMMDD-NN` 형식을 사용하고 같은 날짜 내에서 순차 증가시킨다. -- 새 항목에는 최소한 아래를 모두 기록한다. - - `Change Summary` - - `Intent` - - `Acceptance Criteria Delta` - - `Impacted Area Delta` - - `Delivery Trace Delta` - - `Revalidation Impact` - - `Validation Plan` -- 과거 `Change Log` 항목은 삭제하거나 덮어쓰지 않는다. - -## 3. 상태 영향 규칙 - -- 변경이 기존 구현 또는 verification evidence를 무효화하지 않으면 현재 상태를 유지할 수 있다. -- `Implemented` REQ의 기존 구현/검증 근거가 깨지면 상태를 `Approved`로 되돌린다. -- 변경이 승인 전제 자체를 다시 검토하게 만들면 상태를 `Proposed`로 되돌릴 수 있다. -- 상태를 되돌릴 때는 이유를 `Revalidation Impact`와 `Change Log`에 함께 남긴다. -- 상태가 바뀌면 `docs/srs/index.md`의 Register와 `Recent Change Log Summary`도 함께 갱신한다. - -## 4. 영향 분석 규칙 - -- 관련 batch가 있으면 어떤 batch planning/design/verification이 다시 검토되어야 하는지 기록한다. -- 관련 release가 있으면 release 문서를 직접 덮어쓰지 말고 영향 범위와 후속 action을 REQ의 `Change Log`에 남긴다. -- 사람 판단이 필요한 범위 재정의, 우선순위 변경, 정책 변경은 임의 확정하지 않는다. - -# Execution Procedure - -1. 입력에서 대상 REQ 경로를 확정한다. -2. REQ 현재 상태와 최근 `Change Log`를 읽는다. -3. `docs/batches/index.md`, `docs/releases/index.md`, 관련 batch/release 문서를 읽어 현재 delivery trace를 요약한다. -4. 변경 요청 근거를 기존 구현/검증 상태와 비교한다. -5. 새 `Change Log` 항목을 먼저 추가하고 delta, trace, revalidation impact를 채운다. -6. 그 다음 REQ 본문(`Requirement`, `Acceptance Criteria`, `Impacted Area`, `Notes`)을 필요한 만큼 수정한다. -7. 상태 유지 또는 상태 되돌림 필요 여부를 결정해 REQ 본문과 `docs/srs/index.md`를 갱신한다. -8. `Recent Change Log Summary`에 최신 변경 항목을 반영한다. - -# Output Expectations - -- 대상 REQ 경로 -- 새 `Change Log` 항목 ID -- 변경 요약 -- 영향 받는 batch/release 목록 -- 재검증 필요 여부 -- 상태 유지 또는 상태 되돌림 결과 -- `docs/srs/index.md` 갱신 결과 - -# Validation - -- `Change Log` 새 항목이 REQ 본문 수정보다 앞선 시간순 근거로 남았는지 확인한다. -- `Acceptance Criteria Delta`, `Delivery Trace Delta`, `Revalidation Impact`가 모두 채워졌는지 확인한다. -- 상태 되돌림이 필요한 경우 REQ 문서와 `docs/srs/index.md` 상태가 함께 바뀌었는지 확인한다. -- 관련 batch/release 영향이 `Change Log`에 누락되지 않았는지 확인한다. \ No newline at end of file diff --git a/.github/skills/confirm-batch-verification/SKILL.md b/.github/skills/confirm-batch-verification/SKILL.md deleted file mode 100644 index 9a1bf40..0000000 --- a/.github/skills/confirm-batch-verification/SKILL.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: confirm-batch-verification -description: "Use when: approving a batch verification result, running /confirm-batch-verification with a BAT ID, promoting docs/batches/ to release-candidate, or updating docs/batches/index.md after verification passes." -argument-hint: "예: bat-001 또는 docs/batches/bat-001_20260424_scaffold" -user-invocable: true ---- - -# Purpose - -This skill checks whether a batch verification document has enough evidence to release the batch and, if so, promotes the batch to `release-candidate`. - -# Inputs - -- `BAT-ID` 또는 batch 경로 -- `docs/batches//verification.md` -- 포함된 REQ 문서들 -- `docs/batches/index.md` - -# Core Rules - -- verification에 미해결 blocker가 있으면 승인하지 않는다. -- 포함된 REQ의 acceptance criteria가 evidence와 연결돼야 한다. -- `batch-lite`는 design 문서 없이도 승인할 수 있지만, planning의 `Design Gate`가 design 불필요를 명시하고 verification이 구조 영향 없음 또는 baseline 영향 없음을 확인해야 한다. -- 승인 성공 시 batch status는 `release-candidate`가 된다. - -# Execution Procedure - -1. batch와 verification 문서를 읽는다. -2. 필요하면 planning과 design을 함께 읽어 profile과 구조 영향 여부를 확인한다. -3. Acceptance Mapping, Evidence, Blocking Issues를 점검한다. -4. 승인 가능하면 verification 상태와 batch index 상태를 갱신한다. -5. 승인 불가면 상태는 유지하고 blocker를 보고한다. - -# Validation - -- 승인 성공인 경우 batch index 상태가 `release-candidate`인지 확인한다. -- 승인 보류인 경우 blocker가 명시됐는지 확인한다. -- `batch-lite` 승인 성공인 경우 design 부재가 검증 근거와 모순되지 않는지 확인한다. \ No newline at end of file diff --git a/.github/skills/confirm-discovery/SKILL.md b/.github/skills/confirm-discovery/SKILL.md deleted file mode 100644 index 2b8824c..0000000 --- a/.github/skills/confirm-discovery/SKILL.md +++ /dev/null @@ -1,208 +0,0 @@ ---- -name: confirm-discovery -description: "Use when: running /confirm-discovery with dcy-xyz or a Discovery path, re-checking the current repository state against docs/discovery/*.md, filling AI-resolvable placeholders or inconsistencies, and promoting the Discovery to confirmed when it is ready for REQ drafting in the Requirements phase." -argument-hint: "예: dcy-001 또는 docs/discovery/dcy-001_20260424_krx-stock-picker-python-scaffold.md" -user-invocable: true ---- - -# Purpose - -This Skill revalidates an existing Discovery document against the current repository state, fills anything the AI can resolve from local evidence, and promotes the document to `confirmed` only when REQ drafting handoff has no remaining blockers. - -이 Skill의 결과는 아래 두 경우로 나뉜다. - -- 확인 결과 승인 가능이면 대상 Discovery 문서를 `confirmed`로 전환하고 `docs/discovery/index.md`의 해당 행 상태를 함께 갱신한다. -- 승인까지는 불가하더라도 AI가 근거 있게 채울 수 있는 항목이 있으면 문서를 먼저 보강한다. 단, 이 경우 `confirmed` 전환과 REQ drafting 준비 플래그 변경은 하지 않는다. - -# When to use - -다음 상황에서 사용한다. - -- `/confirm-discovery dcy-xyz` 형태로 기존 Discovery를 최종 확인해야 할 때 -- `docs/discovery/` 아래 Discovery 초안 문서를 REQ 초안 작성 가능 상태로 승격해야 할 때 -- 현재 저장소 구현 상태를 다시 확인해 Discovery 본문의 사실 관계를 정정하거나 보강해야 할 때 -- 문서 안의 플레이스홀더 중 AI가 근거 있게 채울 수 있는 항목을 먼저 정리한 뒤 승인 가능 여부를 판정해야 할 때 -- `docs/discovery/index.md`의 상태가 실제 Discovery 상태와 일치하는지 함께 맞춰야 할 때 - -# Inputs - -이 Skill은 아래 입력을 사용한다. - -- Discovery 식별자 - - `dcy-001` 같은 축약 ID - - `dcy-001_20260424_topic-slug` 같은 전체 Discovery ID - - `docs/discovery/.md` 같은 직접 경로 -- Discovery 문서 본문 -- `docs/discovery/index.md` -- 현재 저장소 구현 상태 - - 관련 소스 파일 - - 설정 파일 - - 관련 문서 -- 현재 시각 (KST) - -# Core Rules - -## 1. 입력 해석 규칙 - -- 입력이 `dcy-001`처럼 prefix만 주어지면 `docs/discovery/` 아래에서 해당 prefix로 시작하는 문서를 찾는다. -- 입력이 전체 Discovery ID면 `docs/discovery/.md`를 직접 찾는다. -- 입력이 파일 경로면 해당 경로를 그대로 사용한다. -- 매칭 결과가 0개면 자동 진행하지 않고 사용자에게 입력을 재확인하도록 알린다. -- 같은 prefix에 대해 여러 문서가 매칭되면 임의 선택하지 않고 후보 목록을 보여 준 뒤 사용자 확인을 요청한다. -- `.vendor/` 경로는 탐색 대상에서 제외한다. - -## 2. 현재 구현 상태 재확인 규칙 - -- 현재 구현 상태 확인은 요구사항이 이미 모두 구현되었는지 검사하는 절차가 아니다. -- 이 확인의 목적은 Discovery의 `현재 상태`, `영향 범위`, `성공 기준`, `체크 항목`, `사용자 결정 필요 항목`이 저장소 현실과 맞는지 검증하는 것이다. -- 구현 부재 자체는 차단 사유가 아니다. Discovery가 애초에 신규 작업을 정의하는 문서라면 코드가 없는 상태도 정상일 수 있다. -- 차단이 되는 경우는 아래와 같다. - - 문서가 이미 존재한다고 적은 구현이 실제 저장소에는 없어서 본문 사실 관계가 틀린 경우 - - 저장소 현실이 문서의 핵심 범위, 사용자 시나리오, 성공 기준을 다시 써야 할 정도로 바뀐 경우 - - 문서의 남은 결정 항목이 여전히 사람 판단을 필요로 하는데 승인 상태로 올리려는 경우 - -## 3. 승인 전 보강 규칙 - -승인 판정 전에 먼저 아래 보강 절차를 수행한다. - -### 3-1. 일관성 검토 - -- 용어 혼용, 범위 충돌, 중복 서술, 번호 누락을 확인한다. -- `# 5. 요구사항 목록`, `# 6. 리스크/가정 목록`, `# 7. 초기 성공 기준`, `# 8. REQ로 넘기기 전 확인 체크`, `# 10. 사용자 결정 필요 항목 요약`, `# 11. Discovery Freeze`의 연결 관계를 확인한다. -- 문장만 다르고 같은 의미를 반복하는 부분은 간결하게 정리한다. -- 문법, 오타, 링크 형식, 체크리스트 표현을 함께 정리한다. - -### 3-2. 증거 기반 보강 - -- Discovery 본문에서 이슈명, 핵심 변경 항목, FR/NFR, 범위 경계를 읽고 키워드를 추출한다. -- 키워드를 바탕으로 저장소에서 관련 파일, 설정, 문서를 탐색한다. -- 탐색 결과는 최소한 아래 판단으로 정리한다. - - 이미 존재함 - - 일부 존재함 - - 아직 없음 - - 문서와 모순됨 -- AI가 아래 근거로 확정 가능한 항목은 실제 문장으로 치환한다. - - 같은 Discovery 문서 안 다른 섹션에 이미 답이 있는 항목 - - 현재 저장소 파일 구조, 설정, 문서에서 직접 확인 가능한 항목 - - 승인 행위 시각처럼 현재 실행 시점에 자동 확정 가능한 항목 -- 아래 항목은 추론만으로 확정하지 않는다. - - 정책 선택 - - 우선순위 승인 - - 외부 시스템 또는 외부 데이터 확인 결과 - - 승인자 지명처럼 사람 책임을 수반하는 결정 - -### 3-3. 플레이스홀더 분류 기준 - -- A. 승인 시점 자동 채움 - - 예: `{{DATA_CONFIRMED_AT_KST:...}}`, `{{CONFIRMED_AT_KST:...}}` - - 승인 성공 시 현재 시각으로 채운다. -- B. 증거 기반 해소 가능 - - 문서 다른 섹션, 저장소 구조, 이미 확정된 값으로 해소 가능한 플레이스홀더다. - - 승인 성공 여부와 무관하게 먼저 채운다. -- C. 사람 결정 또는 외부 확인 필요 - - `{{DECIDE_*}}`, `{{CONFIRM_*}}`, `{{DATA_*}}` 중 아직 문서와 저장소 근거만으로 해소되지 않는 항목이다. - - 승인 차단 대상이다. - -- 승인 판정 직전에는 A와 B만 남아 있어야 한다. -- B로 분류한 항목을 채운 뒤에도 `# 1`부터 `# 10` 사이에 C 유형 플레이스홀더가 남아 있으면 승인하지 않는다. - -## 4. 최종 승인 게이트 - -아래 조건을 모두 만족해야 `confirmed`로 전환한다. - -- 문서 상태가 아직 `confirmed`가 아니어야 한다. -- B 유형 보강 이후 C 유형 플레이스홀더가 0개여야 한다. -- `# 6. 리스크/가정 목록`의 오픈 질문에 `상태: Open` 항목이 없어야 한다. -- `# 8. REQ로 넘기기 전 확인 체크`에 남아 있는 미완료 항목이 있다면, 그 항목이 REQ 초안 작성을 막지 않는다고 문서에 명시되어 있어야 한다. 그렇지 않으면 차단한다. -- `# 10. 사용자 결정 필요 항목 요약`에 남은 항목이 모두 `없음`이거나, 문서 안에서 이미 답이 반영되어 제거 가능해야 한다. -- `# 11. Discovery Freeze`에 `Confirmed By`가 비어 있지 않아야 한다. -- 현재 저장소 상태가 Discovery의 핵심 범위나 요구사항을 다시 정의해야 할 정도로 모순되지 않아야 한다. - -다음 경우는 `confirmed` 전환 대신 보류한다. - -- Discovery 초안이 아니라 사실상 새 Discovery 생성 또는 대규모 범위 재정의가 필요한 경우 -- 확인 과정에서 FR/NFR, In Scope/Out of Scope, 성공 기준을 실질적으로 다시 써야 하는 경우 -- 승인자 또는 필수 결정 사항이 여전히 문서 밖 사람 판단에 의존하는 경우 - -## 5. 허용되는 수정 범위 - -이 Skill은 아래 수정만 수행한다. - -- AI가 근거 있게 채울 수 있는 플레이스홀더 치환 -- 현재 저장소 상태에 맞춘 사실 관계 보정 -- 표현, 번호, 체크리스트, 링크의 일관성 정리 -- 실제 문서 변경이 있었다면 `마지막 갱신 시각(KST)` 갱신 -- 승인 가능 시 아래 상태 전환 수행 - - `# 0. 문서 상태`의 `상태`를 `confirmed`로 변경 - - `# 11. Discovery Freeze`의 `Handoff Decision`을 `승인`으로 변경 - - `# 11. Discovery Freeze`의 `Ready for REQ Drafting`을 `true`로 변경 - - `Confirmed At (KST)`를 현재 시각으로 채움 - - `docs/discovery/index.md`의 해당 Discovery 행 상태를 `confirmed`로 갱신 - -아래 상황이면 상태 전환 없이 보강만 하고 종료한다. - -- C 유형 플레이스홀더가 남아 있는 경우 -- 오픈 질문 또는 사용자 결정 항목이 남아 있는 경우 -- 승인 자체보다 먼저 Discovery 내용 재정비가 필요한 경우 - -# Repository Exploration - -현재 구현 상태를 다시 확인할 때는 아래 순서로 저장소를 탐색한다. - -1. Discovery 문서에서 이슈명, 핵심 변경 항목, FR/NFR, 범위 경계 키워드를 뽑는다. -2. 키워드 기준으로 관련 소스, 설정, 문서를 찾는다. -3. 아래 항목은 기본적으로 제외한다. - - 테스트 파일 - - 의존성 잠금 파일 - - `.vendor/` 하위 파일 -4. 탐색 결과를 아래 형태로 정리한다. - - 관련 파일 경로 목록 - - 이미 구현된 내용 - - 일부만 구현된 내용 - - 아직 없는 내용 - - 문서와 모순되는 내용 -5. 탐색 결과는 Discovery 보강과 승인 게이트 판단에만 사용한다. - -# Execution Procedure - -다음 순서대로 작업한다. - -1. 입력에서 Discovery 문서 경로를 확정한다. -2. 대상 Discovery 문서와 `docs/discovery/index.md`를 읽는다. -3. Discovery의 핵심 범위, FR/NFR, 오픈 질문, 체크리스트, 사용자 결정 필요 항목을 추출한다. -4. 위 `Repository Exploration` 규칙에 따라 현재 저장소 상태를 확인한다. -5. 문서 전체를 검토해 일관성 오류와 플레이스홀더를 나열한다. -6. B 유형 플레이스홀더와 명백한 사실 관계 오류를 먼저 수정한다. -7. 문서 내용이 바뀌었다면 `마지막 갱신 시각(KST)`를 현재 시각으로 갱신한다. -8. 다시 한 번 `최종 승인 게이트`를 평가한다. -9. 게이트를 통과하면 A 유형 플레이스홀더를 채우고 문서를 `confirmed`로 전환한다. -10. 게이트를 통과하면 `docs/discovery/index.md`의 해당 행 상태도 `confirmed`로 갱신한다. -11. `docs/discovery/index.md`에 해당 Discovery 행이 없으면 현재 문서 메타데이터를 기준으로 같은 테이블 형식의 행을 추가한다. -12. 게이트를 통과하지 못하면 상태는 유지하고, 보강된 내용과 남은 차단 항목만 보고한다. - -# Output Expectations - -작업이 끝나면 아래 내용을 보고할 수 있어야 한다. - -- 대상 `DISCOVERY_ID`와 문서 경로 -- 현재 저장소 상태 재확인 결과 요약 -- AI가 자동으로 채운 항목 목록 -- 남겨 둔 항목 목록과 남긴 이유 -- 최종 판정 - - `confirmed 전환 완료` - - `보강만 수행, 승인 보류` - - `문서 변경 없이 승인 보류` -- `docs/discovery/index.md` 갱신 결과 -- REQ 초안 작성으로 넘기기 전에 남은 작업 요약 - -# Validation - -완료 전에 아래를 확인한다. - -- 대상 경로가 `docs/discovery/.md` 형식을 만족하는지 -- 대상 Discovery 문서가 실제로 존재하는지 -- 보강 후에도 C 유형 플레이스홀더와 `상태: Open` 오픈 질문이 정확히 식별되었는지 -- 승인 완료인 경우 문서 상태와 `docs/discovery/index.md` 상태가 모두 `confirmed`인지 -- 승인 보류인 경우 `confirmed`, `승인`, `true` 같은 승인 전환 값이 잘못 들어가지 않았는지 -- 문서 본문 보강이 새로운 요구사항 생성이나 범위 재정의로 번지지 않았는지 -- 결과 보고에 자동 처리 항목과 차단 항목이 모두 포함되는지 \ No newline at end of file diff --git a/.github/skills/confirm-release/SKILL.md b/.github/skills/confirm-release/SKILL.md deleted file mode 100644 index 222e7e3..0000000 --- a/.github/skills/confirm-release/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: confirm-release -description: "Use when: approving a release plan, running /confirm-release with a REL ID or release path, promoting docs/releases/rel-XXX_*.md from draft to confirmed, or validating rollout and rollback readiness before deployment." -argument-hint: "예: rel-001 또는 docs/releases/rel-001_20260424_scaffold.md" -user-invocable: true ---- - -# Purpose - -This skill validates a release document and promotes it to `confirmed` only when rollout, rollback, and verification checks are ready. - -# Inputs - -- `REL-ID` 또는 release 경로 -- 대상 release 문서 -- `docs/releases/index.md` -- 포함 batch 문서들 - -# Core Rules - -- Included Batch가 모두 release-candidate여야 한다. -- Rollout Plan, Rollback Plan, Verification Checklist가 비어 있으면 승인하지 않는다. -- release profile에 맞는 검증 항목이 비어 있으면 승인하지 않는다. -- `docs-only`는 링크/렌더링/패키징 검증으로 충분하며 health endpoint를 요구하지 않는다. -- `tooling`은 설치/업데이트/CLI 또는 스크립트 smoke 결과가 있어야 한다. -- `app-service`는 health, 로그, smoke 또는 동등한 운영 확인 근거가 있어야 한다. -- 승인 성공 시 release 상태를 `confirmed`로 바꾸고 index를 갱신한다. - -# Execution Procedure - -1. release 문서와 포함 batch 상태를 확인한다. -2. rollout, rollback, verification 준비도와 release profile의 필수 검증 항목을 점검한다. -3. 게이트 통과 시 release 상태와 index를 `confirmed`로 갱신한다. -4. 미통과 시 상태는 유지하고 blocker를 보고한다. - -# Validation - -- 승인 성공인 경우 release 문서와 index 상태가 모두 `confirmed`인지 확인한다. -- 승인 보류인 경우 누락 항목이 명시됐는지 확인한다. -- profile별 검증 항목이 release 문서의 선택된 profile과 모순되지 않는지 확인한다. \ No newline at end of file diff --git a/.github/skills/confirm-req-implemented/SKILL.md b/.github/skills/confirm-req-implemented/SKILL.md deleted file mode 100644 index 42a480f..0000000 --- a/.github/skills/confirm-req-implemented/SKILL.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -name: confirm-req-implemented -description: "Use when: confirming that approved requirements have implementation and verification evidence, running /confirm-req-implemented with a REQ ID, BAT ID, or file path, promoting docs/srs//req-XXX_.md from Approved to Implemented, or updating docs/srs/index.md after batch verification passes." -argument-hint: "예: req-001 또는 bat-001 또는 docs/srs/Interface/req-001_picker-cli-run.md" -user-invocable: true ---- - -# Purpose - -This skill validates that an approved requirement has implementation and verification evidence, then promotes it to `Implemented` only when every acceptance criterion is traceable to delivery evidence. - -`Implemented`는 배포 완료 자체를 뜻하지 않는다. 기본 기준은 구현과 검증 증거가 확보된 상태이며, 일반적으로 `release-candidate` 또는 `released` batch의 verification 결과를 근거로 삼는다. - -# When to use - -- `/confirm-req-implemented req-001`처럼 특정 REQ를 `Implemented`로 전환해야 할 때 -- `/confirm-req-implemented bat-001`처럼 batch verification 이후 포함 REQ 상태를 동기화해야 할 때 -- `/confirm-req-implemented`처럼 입력 없이 현재 저장소의 `Approved` REQ 중 구현 완료로 승격 가능한 대상을 일괄 점검해야 할 때 -- `docs/srs/index.md`의 REQ 상태가 실제 batch verification evidence와 어긋나 있을 때 - -# Inputs - -- 선택 입력 - - `REQ-ID` prefix - - REQ 파일 경로 - - `BAT-ID` prefix - - batch 경로 -- 입력이 없으면 `docs/srs/**/req-*.md` 전체에서 `Status: Approved` REQ를 후보로 찾는다. -- 대상 REQ 문서 또는 대상 batch 문서 -- `docs/srs/index.md` -- `docs/batches/index.md` -- 관련 `docs/batches//index.md` -- 관련 `docs/batches//verification.md` -- 필요하면 관련 `docs/batches//implementation.md` - -# Core Rules - -## 1. 입력 해석 - -- `req-001` 같은 prefix가 주어지면 `docs/srs/**/req-001_*.md` 형식으로 찾는다. -- `bat-001` 같은 prefix가 주어지면 `docs/batches/` 아래 matching batch를 찾고, 포함 REQ 중 `Approved` 상태만 후보로 삼는다. -- 입력이 없으면 `docs/srs/` 아래에서 `req-template.md`를 제외한 모든 `req-*.md`를 읽고, 그중 `Status: Approved` 문서를 후보로 삼는다. -- 같은 prefix에 여러 문서가 매칭되면 임의 선택하지 않는다. -- 입력이 없는 일괄 모드에서는 여러 후보가 정상이며, 각 후보를 개별 게이트로 평가한다. - -## 2. 구현 완료 전환 게이트 - -- 아래 조건을 모두 만족해야 `Implemented`로 전환한다. - - 대상 REQ의 현재 `Status`가 `Approved`다. - - 연결된 batch가 `release-candidate` 또는 `released` 상태다. - - batch `verification.md`에 해당 REQ의 `Acceptance Mapping`이 존재한다. - - REQ의 모든 `Acceptance Criteria`가 verification evidence와 연결된다. - - batch verification의 `Blocking Issues`가 이 REQ를 미통과 상태로 남기지 않는다. -- release 배포 완료는 필수 조건이 아니다. 구현과 검증 evidence가 충분하면 `release-candidate` 상태에서도 `Implemented` 전환이 가능하다. -- 연결된 batch를 찾지 못하거나 evidence가 부족하면 상태를 바꾸지 않는다. -- 하나의 REQ가 여러 batch에 걸쳐 있고 어떤 batch를 근거로 삼아야 할지 명확하지 않으면 임의로 전환하지 않는다. - -## 3. 허용되는 수정 - -- AI가 근거 있게 정리할 수 있는 오타, 링크, 표현 불일치는 보강할 수 있다. -- 전환 성공 시 아래 변경을 수행한다. - - REQ 문서의 `Status`를 `Implemented`로 변경 - - `Change Log`에 구현 완료 전환 기록 추가 - - `docs/srs/index.md`의 해당 Register 행 상태를 `Implemented`로 갱신 - - 필요하면 `Recent Change Log Summary`를 갱신 -- evidence 자체를 새로 만들어 내거나, batch verification의 blocker를 임의 해소하지 않는다. -- 사람 판단이 필요한 범위 변경, acceptance criteria 재정의, batch 재매핑은 임의로 확정하지 않는다. - -# Execution Procedure - -1. 입력에서 대상 REQ 하나, REQ 목록, 또는 batch 경로를 확정한다. -2. batch 입력인 경우 batch `index.md`에서 Included REQ를 읽고 `Approved` REQ만 후보로 만든다. -3. 입력이 없는 경우 `docs/srs/**/req-*.md`를 스캔해 `Status: Approved` 문서를 후보 목록으로 만든다. -4. 각 후보 REQ에 대해 `docs/batches/index.md`와 batch 문서를 읽어 연결된 batch를 찾는다. -5. 연결된 batch의 `verification.md`에서 REQ acceptance mapping, evidence, blocking issue를 점검한다. -6. 게이트를 통과한 REQ만 `Implemented`로 전환하고 `Change Log`, `docs/srs/index.md`를 갱신한다. -7. 게이트를 통과하지 못한 REQ는 상태를 유지하고 남은 blocker를 수집한다. -8. 입력이 batch 또는 무인자 일괄 모드인 경우, 성공 목록과 보류 목록을 분리해 보고한다. - -# Output Expectations - -- 단일 입력 모드 - - 대상 REQ 또는 batch 경로 - - 구현 완료 전환 성공 여부 - - 사용한 근거 batch와 verification evidence 요약 - - 자동 보강한 항목 목록 - - 남은 blocker 또는 미해결 항목 - - `docs/srs/index.md` 갱신 결과 -- batch/무인자 일괄 모드 - - 검토한 REQ 전체 목록 - - `Implemented` 전환 성공한 REQ 목록 - - 전환 보류한 REQ 목록과 각 blocker - - `docs/srs/index.md` 갱신 결과 - -# Validation - -- 전환 성공인 경우 REQ 문서와 `docs/srs/index.md` 상태가 모두 `Implemented`인지 확인한다. -- 전환 보류인 경우 상태가 잘못 바뀌지 않았는지 확인한다. -- 사용한 verification evidence가 실제 REQ acceptance criteria와 연결되는지 확인한다. -- batch 입력 모드에서는 batch에 포함된 `Approved` REQ만 처리되었는지 확인한다. \ No newline at end of file diff --git a/.github/skills/confirm-req/SKILL.md b/.github/skills/confirm-req/SKILL.md deleted file mode 100644 index 6b0aef2..0000000 --- a/.github/skills/confirm-req/SKILL.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -name: confirm-req -description: "Use when: reviewing a proposed REQ for approval, running /confirm-req with a REQ ID or file path, running /confirm-req without arguments to review all unapproved REQs, promoting docs/srs//req-XXX_.md from Proposed to Approved, updating docs/srs/index.md after approval, or when the user asks in Korean to '요구 사항 명세를 검토하고 승인해줘', '요구사항 명세를 검토하고 승인해줘', 'REQ를 검토하고 승인해줘', or '승인되지 않은 전체 req를 검토하고 승인해줘'." -argument-hint: "예: req-001 또는 docs/srs/Interface/req-001_picker-cli-run.md 또는 인자 없이 /confirm-req" -user-invocable: true ---- - -# Purpose - -This skill validates a proposed requirement document and promotes it to `Approved` only when it is ready to be used as delivery input. - -# When to use - -- `/confirm-req req-001`처럼 REQ 승인 게이트를 통과시켜야 할 때 -- `/confirm-req`처럼 입력 없이 승인되지 않은 전체 REQ를 일괄 검토하고 승인 가능한 항목만 승격해야 할 때 -- REQ 문서의 필수 필드와 Acceptance Criteria 충족 여부를 다시 확인해야 할 때 -- `docs/srs/index.md` 상태를 REQ 문서와 맞춰야 할 때 - -# Inputs - -- 선택 입력: REQ 식별자 - - `req-001` 같은 prefix - - 전체 REQ 파일 경로 -- 입력이 없으면 `docs/srs/**/req-*.md` 전체를 스캔해 승인되지 않은 REQ를 대상으로 삼는다. -- 대상 REQ 문서 또는 대상 REQ 문서 목록 -- `docs/srs/index.md` - -# Core Rules - -## 1. 입력 해석 - -- prefix 입력이면 `docs/srs/**/req-001_*.md` 형식으로 찾는다. -- 입력이 없으면 `docs/srs/` 아래에서 `req-template.md`를 제외한 모든 `req-*.md`를 찾고, 그중 `Status: Proposed`인 문서를 승인 후보로 삼는다. -- 후보가 0개면 진행하지 않는다. -- 후보가 여러 개면 임의 선택하지 않는다. -- 입력이 없는 일괄 모드에서는 여러 후보가 정상이며, 각 후보를 개별 승인 게이트로 평가한다. - -## 2. 승인 게이트 - -- 아래 항목이 모두 있어야 승인 가능하다. - - `Status`, `Type`, `Priority`, `Owner` - - `Intent` - - `Requirement` - - 구체적인 `Acceptance Criteria` - - `Impacted Area` - - `Change Log` -- 미해결 placeholder, 사람 결정 필요 메모, blocker가 남아 있으면 승인하지 않는다. -- `Status`가 이미 `Approved`, `Implemented`, `Deprecated`이면 중복 승인하지 않는다. -- 일괄 모드에서는 승인 불가한 문서가 있더라도 전체 실행을 중단하지 않는다. 승인 가능한 문서만 승격하고, 나머지는 blocker와 함께 별도 보고한다. - -## 3. 허용되는 수정 - -- AI가 근거 있게 바로 고칠 수 있는 표현, 오타, 누락 제목 정리는 보강할 수 있다. -- 승인 성공 시 아래 변경을 수행한다. - - REQ 문서의 `Status`를 `Approved`로 변경 - - `Change Log`에 승인 기록 추가 - - `docs/srs/index.md`의 해당 Register 행 상태 갱신 - - 필요하면 `Recent Change Log Summary` 갱신 -- 사람 판단이 필요한 우선순위, 범위, Owner는 임의로 확정하지 않는다. -- 일괄 모드에서도 허용되는 수정 범위는 동일하며, 승인 불가 문서의 상태는 바꾸지 않는다. - -# Execution Procedure - -1. 입력에서 대상 REQ 경로 하나 또는 대상 REQ 문서 목록을 확정한다. -2. 입력이 없는 경우 `docs/srs/**/req-*.md`를 스캔해 `Status: Proposed` 문서를 승인 후보 목록으로 만든다. -3. 대상 REQ들와 `docs/srs/index.md`를 읽는다. -4. 각 REQ마다 필수 필드, Acceptance Criteria, Notes의 blocker 여부를 확인한다. -5. AI가 근거 있게 정리할 수 있는 사소한 일관성 문제를 먼저 고친다. -6. 각 REQ에 대해 승인 게이트를 다시 평가한다. -7. 게이트 통과한 REQ만 `Approved`로 전환하고 인덱스를 갱신한다. -8. 게이트 미통과한 REQ는 상태를 유지하고 남은 blocker를 수집한다. -9. 입력이 없는 경우 승인 성공 목록과 승인 불가 목록을 분리해 함께 보고한다. - -# Output Expectations - -- 단일 입력 모드: - - 대상 REQ 경로 - - 승인 성공 여부 - - 자동 보강한 항목 목록 - - 남은 blocker 또는 미해결 항목 - - `docs/srs/index.md` 갱신 결과 -- 무인자 일괄 모드: - - 검토한 REQ 전체 목록 - - 승인 성공한 REQ 목록 - - 승인 불가한 REQ 목록과 각 blocker - - 자동 보강한 항목 목록 - - `docs/srs/index.md` 갱신 결과 - -# Validation - -- 승인 성공인 경우 REQ 문서와 index 상태가 모두 `Approved`인지 확인한다. -- 승인 보류인 경우 상태가 잘못 바뀌지 않았는지 확인한다. -- 무인자 일괄 모드에서는 승인 성공한 REQ만 상태가 바뀌고, 승인 불가한 REQ는 기존 상태가 유지되는지 확인한다. \ No newline at end of file diff --git a/.github/skills/draft-batch-design/SKILL.md b/.github/skills/draft-batch-design/SKILL.md deleted file mode 100644 index 0d8aebc..0000000 --- a/.github/skills/draft-batch-design/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: draft-batch-design -description: "Use when: drafting the design document for an existing batch, running /draft-batch-design with a BAT ID, filling docs/batches//design.md, recording architecture and interface decisions before implementation, or planning updates to docs/project-structure.md and docs/runtime-flows.md." -argument-hint: "예: bat-001 또는 docs/batches/bat-001_20260424_scaffold" -user-invocable: true ---- - -# Purpose - -This skill writes the design document for a batch by summarizing architecture, changed areas, key decisions, and edge cases from the batch plan and included REQs. - -`docs/project-structure.md`와 `docs/runtime-flows.md`가 존재하면, 이 skill은 해당 baseline 문서를 읽고 이번 batch의 구조 영향과 흐름 영향을 비교해야 한다. - -# Inputs - -- `BAT-ID` 또는 batch 경로 -- `docs/batches//planning.md` -- 포함된 REQ 문서들 -- `docs/batches//design.md` -- `docs/project-structure.md` (존재하는 경우) -- `docs/runtime-flows.md` (존재하는 경우) - -# Core Rules - -- planning이 없거나 범위가 비어 있으면 설계를 진행하지 않는다. -- 설계는 구현 전에 결정해야 할 구조와 인터페이스를 중심으로 적는다. -- `batch-lite`이고 planning의 `Design Gate`가 `no`이며 구조/인터페이스/런타임 영향이 없다면 design 단계는 생략할 수 있다. -- REQ 간 공통 설계 전제가 있다면 batch design에 끌어올린다. -- baseline 문서가 있으면 이번 batch 설계는 baseline 대비 변경점과 유지점이 무엇인지 명시해야 한다. -- `design.md`에는 최소한 아래 내용을 포함해야 한다. - - Architecture Summary - - Changed Areas - - Key Decisions - - Edge Cases - - Architecture Impact (`none` | `project-structure` | `runtime-flows` | `both`) - - Reference Doc Update Plan -- 기존 템플릿에 위 섹션이 없으면 설계 문서에 추가해 채운다. - -# Execution Procedure - -1. batch 경로를 확정한다. -2. planning과 포함 REQ를 읽는다. -3. planning의 `Design Gate`가 `no`이고 batch profile이 `batch-lite`면 design 없이 진행 가능한지 다시 확인하고, 그대로면 design 생략 결정을 유지한다. -4. `docs/project-structure.md`와 `docs/runtime-flows.md`가 있으면 읽고 현재 baseline과 batch 범위를 비교한다. -5. `design.md`에 Architecture Summary, Changed Areas, Key Decisions, Edge Cases를 채운다. -6. Architecture Impact와 Reference Doc Update Plan을 명시한다. -7. 설계상 blocker가 있으면 문서에 명시하고 구현으로 넘기지 않는다. - -# Validation - -- design 문서에 Architecture Summary, Changed Areas, Key Decisions, Edge Cases가 모두 있는지 확인한다. -- planning에서 정의한 핵심 범위가 design에서 누락되지 않았는지 확인한다. -- 구조 또는 흐름 영향이 있는 batch라면 Architecture Impact와 Reference Doc Update Plan이 채워졌는지 확인한다. -- `batch-lite`에서 design을 생략한 경우 planning의 `Design Gate`와 batch index profile이 그 결정을 설명하는지 확인한다. \ No newline at end of file diff --git a/.github/skills/draft-batch-planning/SKILL.md b/.github/skills/draft-batch-planning/SKILL.md deleted file mode 100644 index 3cf5e1a..0000000 --- a/.github/skills/draft-batch-planning/SKILL.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: draft-batch-planning -description: "Use when: drafting the planning document for an existing batch, running /draft-batch-planning with a BAT ID, populating docs/batches//planning.md, or setting batch scope and dependency notes before design." -argument-hint: "예: bat-001 또는 docs/batches/bat-001_20260424_scaffold" -user-invocable: true ---- - -# Purpose - -This skill writes the planning document for a batch by consolidating included REQs, scope, dependencies, milestones, and key risks. - -# Inputs - -- `BAT-ID` 또는 batch 경로 -- `docs/batches//index.md` -- 포함된 REQ 문서들 -- `docs/batches//planning.md` - -# Core Rules - -- planning은 batch 수준 범위, 의존성, 순서를 명확히 해야 한다. -- REQ 문서를 다시 쓰지 말고 delivery 관점의 묶음 계획으로 요약한다. -- planning은 design이 구현 전에 필요한지 여부를 명시해야 한다. -- `batch-lite`라면 planning에 왜 design을 생략할 수 있는지 또는 언제 design이 필요해지는지 남긴다. -- planning 작성 후 batch 상태는 `in-delivery`로 올릴 수 있다. - -# Execution Procedure - -1. 입력에서 batch 경로를 확정한다. -2. batch index와 포함 REQ를 읽는다. -3. `planning.md`에 scope, out-of-scope, dependency, milestone, risk를 채운다. -4. `planning.md`의 `Design Gate`에 design 필요 여부와 근거를 적는다. -4. 필요하면 batch `index.md`의 Status를 `in-delivery`로 갱신한다. - -# Validation - -- planning 문서에 Included REQ, Delivery Plan, Dependencies, Design Gate, Milestones, Risks가 모두 존재하는지 확인한다. -- batch index와 planning의 포함 REQ 목록이 일치하는지 확인한다. \ No newline at end of file diff --git a/.github/skills/draft-batch-verification/SKILL.md b/.github/skills/draft-batch-verification/SKILL.md deleted file mode 100644 index 5d9ed16..0000000 --- a/.github/skills/draft-batch-verification/SKILL.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: draft-batch-verification -description: "Use when: drafting verification evidence for a batch, running /draft-batch-verification with a BAT ID, filling docs/batches//verification.md, mapping REQ acceptance criteria to evidence before release approval, or verifying sync with docs/project-structure.md and docs/runtime-flows.md." -argument-hint: "예: bat-001 또는 docs/batches/bat-001_20260424_scaffold" -user-invocable: true ---- - -# Purpose - -This skill drafts the verification document for a batch by mapping included REQ acceptance criteria to available evidence and identifying remaining blockers. - -batch design 또는 REQ가 baseline 문서 생성/갱신 또는 구조/흐름 영향이 있음을 나타내면, verification은 `docs/project-structure.md`와 `docs/runtime-flows.md`가 코드와 설계에 맞게 동기화되었는지도 확인해야 한다. - -# Inputs - -- `BAT-ID` 또는 batch 경로 -- 포함된 REQ 문서들 -- `docs/batches//design.md` (존재하는 경우) -- `docs/batches//planning.md` -- `docs/batches//implementation.md` -- `docs/batches//verification.md` -- `docs/project-structure.md` (존재하는 경우) -- `docs/runtime-flows.md` (존재하는 경우) - -# Core Rules - -- verification은 Discovery 성공 기준이 아니라 REQ acceptance criteria에 직접 연결한다. -- evidence가 없는 항목은 통과로 간주하지 않는다. -- 확인되지 않은 항목은 `Blocking Issues`에 명시한다. -- `batch-lite`에서 design 문서가 없다면 planning의 `Design Gate`와 implementation 결과를 기준으로 구조 영향이 실제로 없었는지 확인해야 한다. -- design의 Architecture Impact가 `none`이 아니거나 baseline 문서 생성/갱신이 batch 범위에 있으면, baseline 문서 동기화 evidence를 verification에 포함해야 한다. -- baseline 문서 동기화가 필요한데 evidence가 없으면 release-ready로 간주하지 않는다. - -# Execution Procedure - -1. batch와 포함 REQ를 확정한다. -2. REQ acceptance criteria를 목록화한다. -3. design 문서가 있으면 Architecture Impact와 Reference Doc Update Plan을 읽고, 없으면 planning의 `Design Gate`를 읽는다. -4. implementation 결과와 테스트 로그를 근거로 evidence를 연결한다. -5. baseline 문서 동기화가 필요하면 `docs/project-structure.md`와 `docs/runtime-flows.md`의 생성/갱신 여부를 evidence에 포함한다. -6. `batch-lite`에서 design이 없었다면 verification에 `no architecture impact confirmed` 여부를 남긴다. -7. `verification.md`의 Acceptance Mapping, Evidence, Result를 채운다. - -# Validation - -- 포함 REQ마다 acceptance mapping이 존재하는지 확인한다. -- evidence 없는 항목이 있으면 Result에 blocker로 반영했는지 확인한다. -- Architecture Impact가 있는 batch라면 baseline 문서 동기화 evidence 또는 blocker가 명시됐는지 확인한다. -- `batch-lite`에서 design이 없는 경우 planning과 implementation을 기준으로 구조 영향 없음 또는 design 누락 blocker 중 하나가 명시됐는지 확인한다. \ No newline at end of file diff --git a/.github/skills/draft-batch/SKILL.md b/.github/skills/draft-batch/SKILL.md deleted file mode 100644 index 0b99c58..0000000 --- a/.github/skills/draft-batch/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: draft-batch -description: "Use when: creating a new batch from approved REQ IDs, running /draft-batch with one or more req IDs, scaffolding docs/batches// files, or updating docs/batches/index.md for a new delivery unit." -argument-hint: "예: req-001 req-002 req-003" -user-invocable: true ---- - -# Purpose - -This skill creates a new batch delivery unit from approved requirements, chooses the right delivery profile, scaffolds its folder and documents, and updates the batch register. - -# Inputs - -- Approved `REQ-ID` 목록 -- 선택 입력 - - batch profile 힌트 (`standard` | `batch-lite` | `minor-change`) -- `docs/srs/index.md` -- 대상 REQ 문서들 -- `docs/batches/index.md` -- `.github/templates/batches/` 아래 템플릿 - -# Core Rules - -- `Approved` 상태가 아닌 REQ는 batch에 포함하지 않는다. -- 새 batch 폴더는 `docs/batches/bat-XXX_YYYYMMDD_scope/` 형식을 사용한다. -- 새 batch는 `standard` 또는 `batch-lite` profile 중 하나를 가진다. -- 입력이 정확히 1개의 `Approved` REQ이고 범위가 국소적이며 구조/인터페이스/런타임 흐름 변경이 없으면 `minor-change` fast path를 사용할 수 있다. 이 경우 결과 batch profile은 `batch-lite`로 기록한다. -- `standard` batch는 생성 시 아래 파일을 함께 만든다. - - `index.md` - - `planning.md` - - `design.md` - - `implementation.md` - - `verification.md` -- `batch-lite`는 생성 시 아래 파일만 먼저 만든다. - - `index.md` - - `planning.md` -- `batch-lite`의 `implementation.md`와 `verification.md`는 해당 단계 진입 시 생성한다. -- `batch-lite`라도 구조/인터페이스/흐름 영향이 생기면 `design.md`를 추가하고 사실상 standard depth로 다룬다. -- `docs/batches/index.md`의 register를 함께 갱신하며, 가능하면 profile도 함께 기록한다. - -# Execution Procedure - -1. 입력된 REQ 경로와 상태를 확인한다. -2. REQ 수, 영향 범위, 구조/인터페이스/런타임 영향 여부를 기준으로 `standard` 또는 `batch-lite` profile을 결정한다. -3. 배치 제목과 slug를 결정하고 다음 `BAT-ID`를 계산한다. -4. 템플릿 기반으로 batch 폴더와 profile에 맞는 문서를 생성한다. -5. `index.md`에 `Profile`, 포함 REQ, source discovery, 공통 범위를 채운다. -6. `planning.md`에 `Design Gate`를 기록해 design이 즉시 필요한지 여부를 남긴다. -7. `docs/batches/index.md`에 새 register 행을 추가한다. - -# Validation - -- 포함 REQ가 모두 `Approved`인지 확인한다. -- `standard`면 5개 문서가 모두 생성되었는지 확인한다. -- `batch-lite`면 `index.md`, `planning.md`가 생성되었고 `Profile: batch-lite`가 기록되었는지 확인한다. -- `docs/batches/index.md`에 새 batch 행이 추가되었는지 확인한다. \ No newline at end of file diff --git a/.github/skills/draft-release/SKILL.md b/.github/skills/draft-release/SKILL.md deleted file mode 100644 index 61be50d..0000000 --- a/.github/skills/draft-release/SKILL.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: draft-release -description: "Use when: creating a release document from one or more release-candidate batches, running /draft-release with BAT IDs, generating docs/releases/rel-XXX_YYYYMMDD_.md, or updating docs/releases/index.md before release approval." -argument-hint: "예: bat-001 또는 bat-001 bat-002" -user-invocable: true ---- - -# Purpose - -This skill creates a profile-aware release document from one or more verified batches and registers it in the release index. - -# Inputs - -- `BAT-ID` 목록 -- `docs/batches/index.md` -- 각 batch의 `verification.md` -- `docs/releases/index.md` -- `.github/templates/releases/release.md` - -# Core Rules - -- `release-candidate`가 아닌 batch는 release에 포함하지 않는다. -- 새 release 경로는 `docs/releases/rel-XXX_YYYYMMDD_.md` 형식을 사용한다. -- release는 `docs-only`, `tooling`, `app-service` profile 중 하나를 가진다. -- release profile은 포함 batch의 운영 부담 중 가장 무거운 것을 따른다. 기본 우선순위는 `app-service` > `tooling` > `docs-only`다. -- `docs-only`는 문서, 가이드, 템플릿, 정적 산출물 변경에 사용한다. -- `tooling`은 bootstrap, 스크립트, CLI, 자동화, 패키지 동작 검증이 필요한 변경에 사용한다. -- `app-service`는 실제 서비스 배포, runtime health, 로그, 운영 확인이 필요한 변경에 사용한다. -- release 문서 생성 시 Included Batch, Profile, Scope, Rollout Plan, Rollback Plan, Verification Checklist를 채운다. - -# Execution Procedure - -1. 입력 batch들의 상태와 verification 결과를 확인한다. -2. 포함 batch의 변경 성격과 검증 evidence를 기준으로 release profile을 결정한다. -3. 다음 `REL-ID`를 계산하고 제목/slug를 정한다. -4. 템플릿 기반으로 release 문서를 생성한다. -5. `docs/releases/index.md`에 register 행을 추가한다. - -# Validation - -- 포함 batch가 모두 `release-candidate`인지 확인한다. -- release 문서에 `Profile`이 있고 profile별 검증 항목이 비어 있지 않은지 확인한다. -- release 문서와 index register 행이 함께 생성됐는지 확인한다. \ No newline at end of file diff --git a/.github/skills/draft-req/SKILL.md b/.github/skills/draft-req/SKILL.md deleted file mode 100644 index 03f918c..0000000 --- a/.github/skills/draft-req/SKILL.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -name: draft-req -description: "Use when: turning a confirmed Discovery into one or more requirement documents under `docs/srs/`, running /draft-req with a dcy-id or Discovery path, creating docs/srs//req-XXX_.md files, updating docs/srs/index.md from FR/NFR input, or carrying forward constraints from docs/project-structure.md and docs/runtime-flows.md." -argument-hint: "예: dcy-001 또는 docs/discovery/dcy-001_20260424_krx-stock-picker-python-scaffold.md" -user-invocable: true ---- - -# Purpose - -This skill converts a Discovery document into one or more requirement documents under `docs/srs/`, updates the SRS register, and writes the generated REQ references back to the source Discovery document. - -`docs/project-structure.md`와 `docs/runtime-flows.md`가 존재하면, REQ 작성 시 이 문서들을 공통 baseline 참조 문서로 읽고 필요한 구조 제약만 REQ에 반영한다. - -# When to use - -- `/draft-req dcy-001`처럼 Discovery를 REQ backlog로 정규화해야 할 때 -- Discovery의 FR/NFR을 구현 가능한 requirement 문서로 분리해야 할 때 -- `docs/srs/index.md`의 register와 Next Requirement ID를 함께 갱신해야 할 때 -- Discovery가 구조 또는 runtime baseline 갱신을 요구할 때 이를 REQ backlog에 반영해야 할 때 - -# Inputs - -- Discovery 식별자 - - `dcy-001` 같은 prefix - - 전체 Discovery ID - - `docs/discovery/.md` 경로 -- 대상 Discovery 문서 본문 -- `docs/project-structure.md` (존재하는 경우) -- `docs/runtime-flows.md` (존재하는 경우) -- `.github/templates/srs/index.md` -- `.github/templates/srs/req-template.md` - -# Core Rules - -## 0. Baseline architecture reference rules - -- `docs/project-structure.md`와 `docs/runtime-flows.md`는 REQ의 상세 본문을 대체하지 않지만, 구조 제약과 흐름 제약을 읽기 위한 공통 baseline 참조 문서로 사용한다. -- REQ에는 baseline 문서의 전체 구조나 전체 흐름을 복제하지 않는다. 구현에 필요한 제약과 acceptance 기준만 남긴다. -- source Discovery가 구조 또는 runtime baseline 갱신을 요구하면, REQ 후보에 baseline 문서 갱신 작업을 포함해야 한다. -- baseline 문서가 없고 source Discovery가 그 갭 보완을 직접 범위에 포함한다면, baseline 문서를 다루는 REQ를 누락하면 안 된다. -- baseline 문서가 없는데 source Discovery가 그 갭을 다루지 않는다면, `bootstrap-baseline` 선행 누락 가능성을 보고한다. - -## 1. 입력 해석 - -- 입력이 prefix면 `docs/discovery/` 아래에서 일치하는 문서를 찾는다. -- 여러 후보가 나오면 임의 선택하지 않고 사용자 확인이 필요하다고 보고한다. -- `.vendor/` 경로는 탐색 대상에서 제외한다. - -## 2. REQ 분해 원칙 - -- FR은 기본적으로 독립 구현과 독립 검증 가능 단위로 REQ 후보를 만든다. -- 하나의 FR이 과도하게 크면 복수 REQ로 분해한다. -- NFR은 독립 측정 가능성, 독립 backlog 가치, 운영 영향도가 충분할 때만 별도 REQ로 승격한다. -- 특정 FR 또는 batch 검증 기준에 흡수하는 편이 적절한 NFR은 별도 REQ를 만들지 말고 Notes에 연결 근거를 남긴다. -- baseline 문서 갱신 요구는 보통 `Documentation`, `Interface`, `Configuration` 중 가장 적절한 Type으로 분류한다. - -## 3. Type 및 경로 규칙 - -- 아래 폴더 중 정확히 하나를 선택한다. - - `Configuration` - - `Data` - - `Deployment` - - `Documentation` - - `Exception` - - `Installation` - - `Integration` - - `Interface` - - `Migration` - - `Non-Functional` - - `Testing` -- 새 REQ 경로는 `docs/srs//req-XXX_.md` 형식을 사용한다. -- `REQ-XXX` 번호는 `docs/srs/index.md`의 `Next Requirement ID`를 source of truth로 사용한다. - -## 4. 문서 작성 규칙 - -- `docs/srs/req-template.md`를 기준으로 문서를 만든다. -- `Status` 기본값은 `Proposed`다. -- `Intent`, `Requirement`, `Acceptance Criteria`, `Impacted Area`, `Notes`를 실제 문장으로 채운다. -- baseline 문서와 충돌하거나 baseline 문서를 갱신해야 하는 구조 제약이 있으면 `Impacted Area` 또는 `Notes`에 명시한다. -- 사람 승인 없이는 확정할 수 없는 항목은 비워 두지 말고 `Notes`에 결정 필요 또는 가정으로 명시한다. -- Change Log는 최초 생성 기록을 남긴다. - -## 5. 인덱스 갱신 규칙 - -- REQ를 하나 이상 생성하면 `docs/srs/index.md`의 아래 항목을 함께 갱신한다. - - `Next Requirement ID` - - `Requirement Register` - - `Recent Change Log Summary` -- Register에는 ID, Title, Type, Status, Priority, Owner, Link를 추가한다. - -## 6. Source Discovery 역참조 갱신 규칙 - -- REQ를 하나 이상 생성하면 source Discovery 문서의 `# 0. 문서 상태`에도 어떤 REQ가 생성됐는지 기록해야 한다. -- Discovery 문서에 `생성된 REQ 참조` 항목이 이미 있으면 생성된 REQ 목록으로 갱신하고, 없으면 `후속 Discovery 참조` 바로 아래에 추가한다. -- `생성된 REQ 참조`는 생성된 REQ 번호 순서대로 유지하고 중복을 허용하지 않는다. -- source Discovery 문서를 갱신했다면 `마지막 갱신 시각(KST)`도 함께 갱신한다. -- 생성된 REQ가 아직 없으면 `생성된 REQ 참조: 없음`을 유지한다. - -# Execution Procedure - -1. 입력에서 Discovery 경로를 확정한다. -2. Discovery 문서에서 FR/NFR, 범위 경계, 리스크, 성공 기준을 읽는다. -3. `docs/project-structure.md`와 `docs/runtime-flows.md`가 있으면 읽고, REQ에 남겨야 할 구조 제약과 흐름 제약을 추린다. -4. FR/NFR별 REQ 후보 목록을 만든다. -5. baseline 문서 갱신 또는 baseline 갭 보완 요구가 있으면 이를 반영한 REQ 후보를 포함한다. -6. 각 후보에 대해 Type, 제목, slug, Acceptance Criteria를 결정한다. -7. `docs/srs/index.md`의 다음 번호부터 순서대로 REQ 파일을 생성한다. -8. `docs/srs/index.md`를 갱신한다. -9. source Discovery 문서의 `생성된 REQ 참조`와 `마지막 갱신 시각(KST)`를 갱신한다. -10. 생성 결과와 제외한 NFR이 있으면 그 이유를 함께 보고한다. - -# Output Expectations - -- 생성된 REQ 파일 목록 -- 각 REQ의 Type과 핵심 의도 -- baseline 문서 참조 또는 생성/갱신 요구를 어떤 REQ에 반영했는지 -- `docs/srs/index.md` 갱신 결과 -- source Discovery 문서의 `생성된 REQ 참조` 갱신 결과 -- 별도 REQ로 승격하지 않은 NFR 목록과 처리 이유 - -# Validation - -- 모든 REQ 경로가 `docs/srs//req-XXX_.md` 형식을 만족하는지 확인한다. -- 생성된 REQ 수와 `Requirement Register` 행 수가 일치하는지 확인한다. -- `Next Requirement ID`가 마지막 생성 번호보다 정확히 1 큰지 확인한다. -- baseline 문서 갱신 또는 baseline 갭 보완이 source Discovery 범위에 있으면, 이를 다루는 REQ가 실제로 생성되었는지 확인한다. \ No newline at end of file diff --git a/.github/skills/new-discovery/SKILL.md b/.github/skills/new-discovery/SKILL.md deleted file mode 100644 index b7ad283..0000000 --- a/.github/skills/new-discovery/SKILL.md +++ /dev/null @@ -1,354 +0,0 @@ ---- -name: new-discovery -description: "Use when: deciding whether to update an existing Discovery document or start a new one, running /new-discovery with or without parameters, drafting docs/discovery/dcy-__.md for a real product/service change, generating Discovery documents, or updating docs/discovery/index.md after baseline initialization is ready." -argument-hint: "예: 사용자 로그인 기능 추가 - OAuth2 소셜 로그인, 세션 관리, 로그아웃 처리" -user-invocable: true ---- - -# Purpose - -This skill decides whether an incoming request should update an existing Discovery document or start a new one, then drafts or updates Discovery documentation based on issues, user requests, meeting notes, or repository state. - -입력이 없으면 현재 Discovery/SRS/batch/release 상태와 baseline 문서 상태를 읽어 새 Discovery가 필요한지 판정한다. baseline 초기화가 아직 끝나지 않은 저장소에서는 먼저 `bootstrap-baseline`으로 `docs/project-structure.md`, `docs/runtime-flows.md`, active index를 준비한 뒤 첫 real Discovery를 생성한다. - -새 Discovery를 생성하기로 판정된 경우 generated output must follow this structure: - -- `docs/discovery/dcy-__.md` -- Update `docs/discovery/index.md` - -# When to use - -다음 상황에서 사용한다. - -- 새로운 SDLC 주기를 시작해야 할 때 -- 기존 SDLC의 요구사항, 범위, 성공 기준을 조정해야 하는데 새 주기 생성이 필요한지 먼저 판정해야 할 때 -- `/new-discovery ...` 또는 파라미터 없는 `/new-discovery`로 Discovery 초안을 만들어야 할 때 -- baseline 초기화가 완료된 뒤 첫 real Discovery를 시작해야 할 때 -- 이슈/요청사항을 `docs/discovery/` 아래 표준 Discovery 문서로 전환해야 할 때 -- Discovery 문서를 템플릿 기반으로 작성하되, 추론 가능한 내용을 실제 문장으로 채워야 할 때 -- `docs/discovery/index.md`에 새 Discovery 링크를 추가해야 할 때 - -# Inputs - -이 skill은 아래 입력을 사용한다. - -- 이슈 식별 정보 - - GitHub issue link 또는 issue 번호 -- 이슈 내용 - - 사용자 입력 원문 또는 이슈 본문 전체 - - 이슈 제목 또는 요청 요약 -- SDLC 문맥 - - 기존 `docs/discovery/` 폴더 구조 및 파일 목록 - - 기존 `docs/srs/`, `docs/batches/`, `docs/releases/` 상태 - - 기존 `docs/discovery/index.md` 내용 - - `docs/project-structure.md`, `docs/runtime-flows.md` 존재 여부 - - 현재 시각 (KST) -- 저장소 문맥 - - 현재 저장소 상태 - - 저장소 현황 탐색 결과 -- 템플릿 파일 - - `.github/templates/discovery/discovery.md` - - `.github/templates/discovery/index.md` - - `.github/templates/project-structure.md` - - `.github/templates/runtime-flows.md` - -입력 해석 규칙: -- GitHub issue link 또는 issue 번호가 있으면 해당 이슈의 제목, 본문, 링크를 기준 입력으로 사용한다. -- 사용자 입력 원문과 요청 요약은 이슈 링크가 없거나 보조 설명이 필요할 때 함께 사용한다. -- 파라미터가 없으면 저장소 상태를 먼저 분류한 뒤 추천 Discovery 또는 진행 중인 반복 유지 여부를 판단한다. -- 저장소 현황 탐색 결과는 `# 3. 문제점의 요약` 중 `현재 상태` 작성에 사용한다. - -# Core Rules - -## 0. Baseline 문서 정책 - -- `docs/project-structure.md`와 `docs/runtime-flows.md`는 SDLC의 별도 governance unit가 아니라, 이후 Discovery/REQ/Batch/Release에서 공통으로 참조하는 cross-cutting baseline 문서다. -- baseline 초기화는 첫 Discovery와 분리된 `bootstrap-baseline` 경로를 기본값으로 사용한다. -- baseline 문서와 active index가 모두 없는 저장소에서는 `new-discovery`가 Discovery를 생성하기 전에 `bootstrap-baseline`을 우선 추천하고 중단한다. -- baseline 문서가 이미 있고, Discovery가 구조/런타임 변경을 다룰 때만 해당 Discovery 안에 baseline 문서 갱신 요구를 포함한다. - -## 1. 파라미터 없는 실행 판정 규칙 - -- `/new-discovery`가 파라미터 없이 실행되면 먼저 아래 경로를 점검한다. - - `docs/discovery/` - - `docs/srs/` - - `docs/batches/` - - `docs/releases/` - - `docs/project-structure.md` - - `docs/runtime-flows.md` -- 파라미터 없는 실행은 아래 3가지 저장소 상태 중 하나로 판정한다. - - `no-history`: Discovery와 후속 SDLC 문서가 사실상 없고 첫 반복을 시작해야 하는 상태 - - `active-cycle`: draft/review Discovery, Proposed REQ, 진행 중 batch, 미정리 release처럼 활성 unit가 존재해 파라미터 없는 실행만으로는 다음 반복 추천이 모호한 상태 - - `ready-for-next-iteration`: active unit는 없고 새 반복을 시작할 준비가 된 상태 -- `no-history`이고 baseline 문서 또는 active index가 없으면 `bootstrap-baseline`을 우선 추천하고 새 Discovery를 생성하지 않는다. -- `no-history`이지만 baseline 초기화가 끝나 있으면 첫 real Discovery를 생성한다. -- `active-cycle`이면 새 Discovery를 생성하지 않고 중단한다. 대신 현재 반복을 계속 진행하도록 `run-sdlc` 또는 해당 후속 skill을 추천한다. -- `ready-for-next-iteration`이면 이전 Discovery, SRS, 구현 상태, baseline 문서 차이를 읽고 추천 follow-up Discovery를 생성한다. -- `ready-for-next-iteration`에서 후보 ranking만 먼저 필요하면 `suggest-next-discovery`를 사용하고, 실제 문서 생성이 확정되면 `new-discovery`로 이어 간다. -- baseline 문서가 없고 active unit도 없다면, follow-up Discovery보다 `bootstrap-baseline`을 우선한다. - -## 2. 기존 Discovery 갱신 vs 새 Discovery 생성 판정 규칙 - -- 기존 주기 후보 탐색과 비교는 아래 `확인 절차`를 따른다. -- 후보 문서가 있고 아래 조건을 모두 만족하면 기존 Discovery를 갱신한다. - - 변경이 기존 문제 정의와 핵심 사용자 시나리오를 유지한다. - - FR/NFR 변경이 문구 명확화, 측정 가능성 보강, 범위 경계의 미세 조정, 우선순위 재정렬 수준이다. - - 요구사항 삭제 또는 수정이 기존 REQ, batch planning, batch design, implementation 산출물을 사실상 무효화하지 않는다. - - 대상 Discovery가 아직 초기 정리 단계이거나, 후속 단계 문서가 있어도 변경 영향이 국소적이라 동일 문서 안에서 추적 가능하다. -- 아래 조건 중 하나라도 만족하면 새 Discovery를 생성한다. - - FR/NFR 삭제 또는 수정이 범위, 이해관계자, 핵심 사용자 시나리오, 성공 기준을 실질적으로 바꾼다. - - 기존 하위 단계 문서나 구현 결과를 다시 써야 할 정도로 설계/구현 전제가 깨진다. - - 기존 Discovery가 사실상 Freeze 이후 단계로 진행되어 변경 이력을 같은 문서에 계속 누적하면 책임 경계가 흐려진다. - - 운영 환류, 새로운 이슈, 정책 전환 등으로 후속 반복을 독립 주기로 추적하는 편이 명확하다. -- 기존 Discovery를 더 이상 기준으로 쓰지 않더라도 기존 문서를 삭제하지 않는다. 새 문서를 생성하는 경우 기존 문서는 참조 대상으로 유지하고, 대체 또는 후속 관계를 문서에 남긴다. -- 판정 결과는 반드시 아래 둘 중 하나로 명시한다. - - `기존 Discovery 갱신` - - `새 Discovery 생성` - -### 확인 절차 - -- 입력 원문 또는 추천 Discovery 주제에서 아래 식별 정보를 먼저 추출한다. - - GitHub issue 번호 또는 링크 - - 이슈 제목의 핵심 명사 - - 기능명, 모듈명, 동사, 영향 범위 키워드 -- `docs/discovery/` 아래 기존 문서를 탐색할 때는 `.vendor/` 경로를 제외하고 아래 순서로 후보를 찾는다. - - issue 번호 또는 링크의 정확 일치 - - 이슈 제목과 파일명 slug의 핵심 키워드 일치 - - 기존 Discovery 문서 안의 문제 정의, FR/NFR, 범위 경계 키워드 일치 -- 후보 문서가 하나 이상 발견되면 각 후보마다 아래 항목을 비교한다. - - 같은 문제 정의 또는 같은 기능 묶음을 다루는지 - - 현재 단계가 Discovery, REQ 정제, batch delivery, release 이후 어디까지 진행됐는지 - - 이번 변경이 기존 FR/NFR의 미세 조정인지, 핵심 요구사항 폐기 또는 재정의인지 - - 기존 하위 단계 문서와 구현 산출물을 유지 가능한지, 다시 작성해야 하는지 -- 후보가 없으면 기본 판정은 `새 Discovery 생성`이다. -- 후보가 하나이고 판정 규칙에 명확히 부합하면 해당 후보를 기준으로 자동 판정한다. -- 후보가 여러 개이거나, 하나의 후보라도 `기존 Discovery 갱신`과 `새 Discovery 생성` 사이에서 명확히 갈리지 않으면 임의로 진행하지 않는다. - - 후보 문서 경로 목록과 각 후보의 비교 근거를 사용자에게 보고한다. - - 사용자 확인 전에는 새 폴더 생성이나 기존 문서 갱신을 하지 않는다. -- 최종 판정 결과에는 선택된 대상 문서 경로 또는 새로 만들 Discovery 생성 사유를 반드시 함께 기록한다. - -## 3. Discovery ID 규칙 - -- `docs/discovery/` 아래 기존 문서 중 `dcy-<3자리>_` 패턴의 최대 번호를 찾는다. -- 다음 번호를 3자리 zero-pad로 계산한다. -- 오늘 날짜를 `YYYYMMDD` 형식으로 구한다. -- 사용자 입력 원문, 추천 Discovery 제목, 이슈 본문 전체, GitHub issue link에서 `topic-slug`를 만든다. -- `topic-slug`는 소문자 kebab-case의 형태로 만든다. -- slug 규칙: - - 영문/숫자 외 문자는 `-`로 치환 - - 연속 `-`는 하나로 축약 - - 시작/끝 `-` 제거 - - 결과가 비면 `untitled` 사용 -- 최종 `DISCOVERY_ID`는 `dcy-__` 형식이어야 한다. - -## 4. 문서 생성/갱신 규칙 - -- 판정 결과가 `새 Discovery 생성`이면 새 파일을 `docs/discovery/.md` 경로로 생성한다. -- `새 Discovery 생성`인 경우 Discovery 문서는 `.github/templates/discovery/discovery.md`를 기반으로 생성한다. -- `docs/discovery/index.md`가 없으면 `.github/templates/discovery/index.md`를 기반으로 생성한다. -- Discovery가 구조/런타임 baseline 갭을 직접 다루는 경우, `.github/templates/project-structure.md`와 `.github/templates/runtime-flows.md`를 이후 산출물 템플릿 참조로 취급한다. -- `기존 Discovery 갱신`이면 대상 Discovery 문서를 갱신한다. -- 동일 경로가 이미 있으면 덮어쓰지 말고 다음 ID를 재계산한다. -- 불필요한 파일은 만들지 않는다. - -## 5. Discovery 초안 작성 원칙 - -- 입력 문장이 짧더라도 기능 목적, 예상 사용자, 산출물, 성공 조건을 합리적으로 추론해 초안을 작성한다. -- 입력만으로 추론 가능한 내용은 플레이스홀더로 남기지 말고 실제 문장으로 채운다. -- 사용자 승인, 외부 데이터, 정책 결정, 확정 범위 경계처럼 추론만으로 확정하면 안 되는 항목만 플레이스홀더로 남긴다. -- 공란은 허용하지 않는다. -- 합리적 기본안을 제시할 수 있으면 초안 문장으로 채운다. -- 사용자 확인이 반드시 필요하면 관련 본문에는 최소 플레이스홀더를 남기고 `# 10. 사용자 결정 필요 항목 요약`에 정리한다. -- `기존 Discovery 갱신`인 경우 FR/NFR 변경 또는 삭제 이유, 영향 받는 하위 단계, 기존 문서와의 차이를 문서 안에 명시해 변경 근거를 남긴다. -- Discovery가 구조/런타임 baseline 변경을 직접 다루는 경우 `# 4. 이번에 정의할 변경`, `# 5. 요구사항 목록`, `# 9. 파일 처리 결과`에 `docs/project-structure.md`와 `docs/runtime-flows.md` 갱신 요구를 명시한다. - -## 6. 플레이스홀더 유지 규칙 - -- 사용자 승인 전 확정되면 안 되는 정책/범위/산출물 위치/대상 독자 결정은 본문에도 플레이스홀더를 남긴다. -- `# 10. 사용자 결정 필요 항목 요약`의 항목은 반드시 아래 형식 중 하나를 사용한다. - - `{{DECIDE_*}}` - - `{{CONFIRM_*}}` - - `{{DATA_*}}` -- `TBD` 같은 일반 문자열보다 의미가 드러나는 플레이스홀더를 우선 사용한다. - -# Discovery Sections To Fill - -다음 섹션은 자동으로 채워야 한다. - -## `# 1. 계획 상태 요약` - -- `해석` 2줄은 입력 요청 또는 파라미터 없는 실행의 저장소 상태 해석을 바탕으로 직접 작성한다. - -## `# 0. 문서 상태` - -- `새 Discovery 생성`이면 `대체됨`, `후속 주기 참조` 기본값은 `없음`으로 둔다. -- 기존 Discovery를 대체하거나 이어받는 새 Discovery를 만드는 경우, 이전 Discovery 문서에도 해당 참조 필드를 갱신한다. -- `기존 Discovery 갱신`이면 기존 참조 값을 보존하고, 새 관계가 확정된 경우에만 관련 필드를 갱신한다. - -플레이스홀더 치환 규칙: -- `{{DISCOVERY_ID:dcy-<3자리>__}}` -> 계산한 `DISCOVERY_ID` -- `{{SUPERSEDED_BY_DISCOVERY:없음 또는 docs/discovery/.md}}` -> `없음` 또는 대체 Discovery 경로 -- `{{FOLLOW_UP_DISCOVERY_REF:없음 또는 docs/discovery/.md}}` -> `없음` 또는 후속 Discovery 경로 - -## `# 2. 요구사항 판정 결과` - -- 기본값은 `신규`로 둔다. -- 입력만으로 명백한 중복/유사라고 판단 가능한 경우에만 다르게 쓴다. -- 근거 2줄도 직접 작성한다. - -## `# 3. 문제점의 요약` - -- 현재 상태, 기대 상태, 이해관계자, 주요 사용자 시나리오를 입력 기반으로 채운다. -- 특히 `현재 상태`는 저장소 탐색 결과와 baseline 문서 존재 여부를 반영해야 한다. - -## `# 4. 이번에 정의할 변경` - -- 핵심 변경 항목 1~3개를 직접 작성한다. -- Discovery가 구조 또는 runtime baseline 갭을 다루면 baseline 문서 갱신을 핵심 변경 항목에 포함한다. -- `영향 범위`의 코드/문서/운영 영향도 추론해 채운다. - -## `# 5. 요구사항 목록` - -- FR 2개 이상, NFR 1개 이상을 직접 작성한다. -- Discovery가 구조 또는 runtime baseline 갭을 다루는 경우, `docs/project-structure.md`와 `docs/runtime-flows.md` 갱신 요구를 FR에 포함한다. -- In Scope/Out of Scope는 초안 수준으로 제안한다. -- 사용자 승인 없이는 확정할 수 없는 범위 경계는 플레이스홀더로 남긴다. - -## `# 6. 리스크/가정 목록` - -- 리스크 2개 이상, 가정 2개 이상을 직접 작성한다. -- 오픈 질문은 정말 결정이 필요한 경우에만 남긴다. - -## `# 7. 초기 성공 기준` - -- 성공 기준 2개 이상을 작성한다. -- Discovery가 구조 또는 runtime baseline 변경을 다루는 경우 baseline 문서와 이후 산출물 참조 가능성을 성공 기준에 연결한다. -- 각 성공 기준에 대해 측정 방식과 데이터 출처 초안을 작성한다. - -## `# 9. 파일 처리 결과` - -- 생성 결과와 최소 참조 문서를 채운다. -- Discovery가 구조 또는 runtime baseline 갭을 다루면 `.github/templates/project-structure.md`와 `.github/templates/runtime-flows.md`를 참조 문서에 포함한다. -- 참조 문서가 없으면 `없음`이라고 쓴다. - -## `# 10. 사용자 결정 필요 항목 요약` - -- 자동 추론으로 확정할 수 없는 항목만 `DECIDE`, `CONFIRM`, `DATA`에 넣는다. -- 이 섹션 항목은 설명문이 아니라 플레이스홀더 형식으로 남긴다. - -## `# 11. Discovery Freeze` - -- `Handoff Decision` 기본값은 `보류`다. -- `Handoff Rationale`에는 현재 상태를 짧게 적는다. - - 예: `초기 Discovery 초안 생성 완료, REQ 초안 작성 전 사용자 확인 필요` - -# Repository Exploration - -Discovery 문서의 `# 3. 문제점의 요약` 중 `현재 상태`를 채우기 위해 저장소 현황 탐색을 수행한다. - -1. 이슈 원문 또는 추천 주제에서 핵심 키워드(기능명, 모듈명, 동사)를 추출한다. -2. 키워드를 기반으로 저장소에서 관련 파일, 디렉터리, 함수, 기존 문서를 탐색한다. -3. 탐색 범위는 아래와 같다. - - 소스 파일: `.ts`, `.js`, `.py`, `.go`, `.java` 등 - - 설정 파일 - - 기존 `docs/` 문서 -4. 아래 항목은 제외한다. - - 테스트 파일 - - 의존성 잠금 파일 (`package-lock.json`, `.lock`) -5. 탐색 결과에서 아래 내용을 추출한다. - - 관련 파일 경로 목록 (최대 10개) - - 현재 구현 방식 요약 (함수명, 클래스명, 주요 로직 1~3줄) - - 현재 상태에서 이슈 원문이 지적하는 문제가 실제로 존재하는지 여부 - - `docs/project-structure.md`, `docs/runtime-flows.md` 존재 여부와 최신성 판단에 필요한 단서 -6. 관련 파일을 찾지 못하면 `관련 파일 없음 — 신규 기능으로 추정`으로 기록한다. -7. 탐색 결과는 `# 3. 문제점의 요약`의 `현재 상태` 기술에만 반영한다. - -# Execution Procedure - -다음 순서대로 작업한다. - -1. 입력 원문이 있으면 이슈명을 한 문장으로 정리하고, 없으면 저장소 상태를 먼저 분류한다. -2. 파라미터 없는 실행이면 discovery/srs/batches/releases/baseline 문서 상태를 읽어 `no-history`, `active-cycle`, `ready-for-next-iteration` 중 하나로 판정한다. -3. 입력 원문이 없고 `active-cycle`이면 새 Discovery 자동 생성을 중단하고 현재 반복을 계속 진행해야 하는 이유와 추천 skill을 보고한다. -4. `no-history`이고 baseline 문서 또는 active index가 비어 있으면 `bootstrap-baseline`을 우선 추천하고 Discovery 생성을 중단한다. -5. `no-history`이지만 baseline 초기화가 끝나 있으면 첫 real Discovery 생성 대상으로 확정한다. -6. 입력 원문이 없고 `ready-for-next-iteration`이면 이전 Discovery, SRS, 구현 상태, baseline 문서 상태를 바탕으로 추천 follow-up Discovery 주제를 정한다. -7. 입력 원문, 이슈 링크, 신규 요구 요약처럼 명시적 신규 입력이 있으면 `active-cycle` 여부와 무관하게 위 `확인 절차`에 따라 issue 번호/링크, 제목 키워드, 기능명, 범위 키워드를 추출하고 기존 후보 주기를 찾는다. -8. 입력 원문이 없고 추천 Discovery 주제가 정해졌으면 위 `확인 절차`에 따라 issue 번호/링크, 제목 키워드, 기능명, 범위 키워드를 추출하고 기존 후보 주기를 찾는다. -9. 후보 비교 결과를 바탕으로 `기존 Discovery 갱신` 또는 `새 Discovery 생성`으로 판정한다. -10. `active-cycle` 상태에서 명시적 신규 입력으로 새 Discovery를 진행하는 경우, 기존 active unit와의 범위 충돌 여부와 병렬 진행 사유를 함께 기록한다. -11. 판정이 애매하면 사용자 확인 전까지 자동 생성 또는 자동 갱신을 중단하고 후보와 근거를 보고한다. -12. 저장소 현황 탐색을 수행한다. -13. 판정 결과가 `새 Discovery 생성`이면 기존 `docs/discovery/` 경로를 다시 스캔해 다음 번호를 계산한다. -14. `새 Discovery 생성`이면 날짜와 slug를 조합해 `DISCOVERY_ID`를 만든다. -15. `새 Discovery 생성`이면 `docs/discovery/.md` 파일을 생성한다. -16. `기존 Discovery 갱신`이면 대상 Discovery 문서를 갱신한다. -17. 아래 플레이스홀더를 우선 치환한다. - - `{{DOC_STATUS:draft|review|confirmed}}` -> `draft` - - `{{DISCOVERY_ID:dcy-<3자리>__}}` -> 계산한 `DISCOVERY_ID` - - `{{ISSUE_NAME:짧은 한글 또는 영문 이슈명}}` -> 입력 원문 또는 요약 이슈명 - - `{{ISSUE_NAME}}` -> 입력 원문 또는 요약 이슈명 - - `{{CREATED_AT_KST:TBD}}` -> 현재 시각(KST) - - `{{CREATED_AT_KST}}` -> 현재 시각(KST) - - `{{UPDATED_AT_KST:TBD}}` -> 현재 시각(KST) - - `{{UPDATED_AT_KST}}` -> 현재 시각(KST) - - `{{SUPERSEDED_BY_DISCOVERY:없음 또는 docs/discovery/.md}}` -> `없음` 또는 대체 Discovery 경로 - - `{{FOLLOW_UP_DISCOVERY_REF:없음 또는 docs/discovery/.md}}` -> `없음` 또는 후속 Discovery 경로 - - `{{OUTPUT_PATH}}` -> 생성 파일 경로 - - `{{FILE_RESULT:생성|갱신|미생성}}` -> `생성` -18. Discovery가 구조 또는 runtime baseline 갭을 직접 다루면 `docs/project-structure.md`와 `docs/runtime-flows.md` 갱신 요구를 핵심 변경과 FR에 반영한다. -19. `기존 Discovery 갱신`인 경우 `{{OUTPUT_PATH}}`와 `{{FILE_RESULT}}`는 갱신 대상 파일과 `갱신`으로 해석한다. -20. `새 Discovery 생성`이면서 기존 Discovery를 대체하거나 이어받는 경우, 기존 Discovery 문서에 `대체됨` 또는 `후속 주기 참조` 필드를 새 문서 경로로 갱신한다. -21. Discovery 문서에서는 추론 가능한 플레이스홀더를 적극적으로 실제 내용으로 치환한다. -22. 추론 불가능하거나 사용자 확인이 필요한 항목만 플레이스홀더로 남긴다. -23. `새 Discovery 생성`인 경우에만 `docs/discovery/index.md` 전역 인덱스에 새 행을 추가한다. -24. `기존 Discovery 갱신`인 경우 전역 인덱스에 새 행을 추가하지 않는다. - -# Global Index Rules - -- `docs/discovery/index.md`가 없으면 아래 골격으로 새로 만든다. -- `## Discovery 목록`에는 새 행을 추가한다. -- 형식은 아래와 같다. - -`| | | | [Discovery](./.md) | draft |` - -- 테이블 구조 자체는 바꾸지 않는다. -- `기존 Discovery 갱신`으로 판정된 경우 `docs/discovery/index.md`에 새 행을 추가하지 않는다. -- 같은 `DISCOVERY_ID`가 이미 있으면 중복 추가하지 않는다. - -# Output Expectations - -작업이 끝나면 아래 내용을 보고할 수 있어야 한다. - -- 판정 결과 (`기존 Discovery 갱신` 또는 `새 Discovery 생성`) -- 파라미터 없는 실행이면 저장소 상태 분류 (`no-history` | `active-cycle` | `ready-for-next-iteration`) -- `active-cycle` 상태였다면 자동 중단 사유 또는 병렬 Discovery 허용 사유 -- 대상 `DISCOVERY_ID` -- 확인에 사용한 후보 Discovery 목록과 각 후보의 비교 근거 -- 새 Discovery인 경우 생성한 파일 경로 -- 기존 Discovery 갱신인 경우 갱신한 파일 경로 -- 기존 Discovery와의 대체 또는 후속 참조 갱신 결과 -- `docs/discovery/index.md` 갱신 결과 또는 미갱신 사유 -- `docs/project-structure.md`, `docs/runtime-flows.md` baseline 상태와 Discovery 반영 결과 -- 저장소 현황 탐색 결과 -- 자동으로 채운 Discovery 핵심 섹션 요약 -- 사용자 확인이 필요해 플레이스홀더로 남긴 항목 요약 - -# Validation - -완료 전에 아래를 확인한다. - -- `새 Discovery 생성`이면 생성 경로가 `docs/discovery/.md` 형식을 만족하는지 -- `새 Discovery 생성`이면 Discovery 문서가 생성되었는지 -- `새 Discovery 생성`이면 `docs/discovery/index.md`가 신규 생성 또는 올바르게 갱신되었는지 -- Discovery가 구조 또는 runtime baseline 갭을 다루면 `docs/project-structure.md`, `docs/runtime-flows.md` 갱신 요구가 문서 본문에 포함됐는지 -- `기존 Discovery 갱신`이면 대상 Discovery 문서만 갱신되었는지 -- `기존 Discovery 갱신`이면 새 `DISCOVERY_ID`와 불필요한 파일이 생성되지 않았는지 -- 기존 Discovery 후보 탐색 결과와 판정 근거가 보고 가능한 형태로 정리되었는지 -- 파라미터 없는 실행에서 `active-cycle` 상태라면 사용자 확인이나 명시적 신규 입력 없이 새 Discovery를 자동 생성하지 않았는지 -- `active-cycle` 상태에서 명시적 신규 입력으로 새 Discovery를 생성한 경우, 기존 active unit와의 범위 충돌 여부와 판정 근거가 함께 정리됐는지 -- 판정이 애매한 경우 사용자 확인 없이 새 폴더 생성이나 기존 문서 갱신을 진행하지 않았는지 -- `대체됨`, `후속 주기 참조` 필드가 `없음` 또는 유효한 Discovery 경로로 채워졌는지 -- 새 Discovery가 기존 Discovery의 대체 또는 후속 반복이라면 기존 Discovery의 참조 필드가 함께 갱신되었는지 -- 추론 가능한 섹션은 실제 문장으로 채워졌는지 -- 사용자 확인이 필요한 항목만 플레이스홀더로 남았는지 \ No newline at end of file diff --git a/.github/skills/review-discovery/SKILL.md b/.github/skills/review-discovery/SKILL.md deleted file mode 100644 index 1e4f1e9..0000000 --- a/.github/skills/review-discovery/SKILL.md +++ /dev/null @@ -1,211 +0,0 @@ ---- -name: review-discovery -description: "Use when: reviewing an existing Discovery document for missing content, additional OQ candidates, internal consistency, duplication, unnecessary content, placeholder residue, and REQ handoff readiness without changing lifecycle state." -argument-hint: "예: dcy-001 또는 docs/discovery/dcy-001_20260424_topic.md" -user-invocable: true ---- - -# Purpose - -This skill reviews an existing Discovery document as a non-gating quality check before `confirm-discovery` or before human review. It looks for missing content, candidate open questions, internal inconsistencies, duplicated statements, unnecessary text, placeholder residue, and weak handoff readiness. - -이 skill은 별도 lifecycle stage를 만들지 않는다. 기본 목적은 Discovery 품질을 점검하고 수정 우선순위를 정리하는 것이며, 문서 상태 전환이나 index 갱신은 수행하지 않는다. - -# When to use - -- `/review-discovery dcy-xyz` 형태로 Discovery 문서를 먼저 점검하고 싶을 때 -- `confirm-discovery` 전에 누락, 중복, OQ 후보, 표현 불일치를 빠르게 정리하고 싶을 때 -- Discovery 문서가 길어지면서 같은 내용이 여러 섹션에 흩어졌는지 확인해야 할 때 -- 템플릿 잔여 문구, 불필요한 상세 구현 설명, 근거 없는 확정 표현이 남아 있는지 보고 싶을 때 -- 사람이 수정할 항목과 AI가 바로 정리할 수 있는 항목을 분리해서 보고 싶을 때 - -# Inputs - -- Discovery 식별자 - - `dcy-001` 같은 prefix - - `dcy-001_20260424_topic-slug` 같은 전체 ID - - `docs/discovery/.md` 같은 직접 경로 -- 대상 Discovery 문서 본문 -- 필요 시 관련 저장소 문맥 - - 현재 저장소의 관련 파일, 문서, 설정 - - `docs/discovery/index.md` - - `docs/project-structure.md`, `docs/runtime-flows.md` -- 현재 시각 (KST) - -# Core Rules - -## 1. 역할 경계 - -- 이 skill은 review helper이며, active SDLC 단계로 취급하지 않는다. -- 아래 항목은 수정하지 않는다. - - `# 0. 문서 상태`의 `상태` - - `# 11. Discovery Freeze`의 `Handoff Decision`, `Ready for REQ Drafting`, `Confirmed By`, `Confirmed At (KST)` - - `docs/discovery/index.md` -- 이 skill은 REQ 생성, 승인, 상태 승격을 하지 않는다. -- 사실 관계를 바꾸는 추정성 수정은 하지 않는다. 근거 없는 항목은 finding 또는 OQ 후보로 남긴다. -- 사용자가 단순 검토만 요청한 경우 결과는 findings-first로 보고한다. -- 사용자가 `정리`, `수정`, `보강`, `반영`까지 명시한 경우에만 의미 변화가 없는 경미한 편집을 수행할 수 있다. - -## 2. 기본 검토 축 - -반드시 아래 항목을 검토한다. - -### 2-1. 누락 항목 - -- 템플릿 기준으로 필요한 섹션과 핵심 내용이 빠지지 않았는지 확인한다. -- 빈 bullet, `TBD`, `{{...}}`, `필요 없으면 삭제` 잔여 문구를 누락 또는 미정리 항목으로 본다. -- 아래 핵심 정보가 약하거나 비어 있으면 finding으로 기록한다. - - 문제의 현재 상태와 기대 상태 - - 핵심 변경 항목과 영향 범위 - - FR/NFR와 범위 경계 - - 리스크, 가정, 성공 기준 - - REQ handoff 전 확인 기준 -- 단, `# 2. 요구사항 판정 결과`가 `완전 중복`이면 템플릿 가이드에 따라 이후 섹션 축약을 허용한다. 이 경우 `# 9. 파일 처리 결과`까지의 중복 판정 근거와 참조만 충분한지 본다. - -### 2-2. 추가 OQ 후보 - -- 아래 조건이면 새 OQ 후보를 제안한다. - - 범위 경계가 모호해 In Scope/Out of Scope가 흔들리는 경우 - - 성공 기준은 있는데 측정 방식이나 데이터 출처가 없는 경우 - - 리스크 완화 책임자나 판단 주체가 비어 있는 경우 - - 외부 정책, 외부 시스템, 운영 절차, 승인자 결정이 필요한데 본문에 근거가 없는 경우 - - 서로 다른 섹션이 충돌해서 사람 판단 없이는 확정할 수 없는 경우 -- 이미 다른 섹션에서 답이 있는 질문은 OQ로 늘리지 말고, answered cleanup 대상으로 분류한다. -- 사소한 문장 다듬기나 서식 문제는 OQ로 만들지 않는다. - -### 2-3. 문서 일관성 - -- 이슈명, 핵심 변경, FR/NFR, 범위 경계, 성공 기준, Freeze rationale 사이의 용어와 범위가 일치하는지 확인한다. -- `# 4. 이번에 정의할 변경`, `# 5. 요구사항 목록`, `# 6. 리스크/가정 목록`, `# 7. 초기 성공 기준`, `# 8. REQ로 넘기기 전 확인 체크`, `# 10. 사용자 결정 필요 항목 요약`, `# 11. Discovery Freeze`의 연결 관계를 본다. -- `Open` OQ가 남아 있는데 Freeze가 사실상 진행 가능처럼 읽히는지 확인한다. -- 문서 안에서 같은 개념을 서로 다른 이름으로 부르는 경우 정규화 필요 finding으로 기록한다. - -### 2-4. 중복 제거 대상 - -- 같은 사실이나 요구를 여러 섹션에서 반복하는지 찾는다. -- 중복은 아래 세 종류로 분류한다. - - 완전 중복: 문장만 다르지만 의미가 같은 경우 - - 부분 중복: 핵심은 같고 세부만 다른 경우 - - 잘못된 재기술: 상위 요약과 하위 상세가 서로 다른 말을 하는 경우 -- 중복이 있으면 어떤 섹션을 source of truth로 둘지 함께 제안한다. - -### 2-5. 불필요한 내용 - -- Discovery 단계에 필요 없는 구현 디테일, 과도한 알고리즘 설계, 배포 절차 상세, 이미 결정된 이력 서술을 찾는다. -- 현재 반복의 문제 정의와 성공 기준에 기여하지 않는 배경 설명은 삭제 또는 압축 후보로 본다. -- 템플릿 가이드 문구, 예시 텍스트, 삭제 예정 placeholder 설명이 본문에 남아 있으면 제거 대상으로 본다. - -## 3. 추가 검토 항목 - -사용자 요청 외에도 아래를 함께 본다. - -### 3-1. 근거와 사실 일치성 - -- 문서가 현재 저장소 상태를 서술하는 경우, 그 주장에 직접 관련된 파일이나 문서와 모순이 없는지 확인한다. -- 저장소 확인은 문서의 핵심 주장과 직접 연결된 범위만 탐색한다. 구현 전체를 감사하지는 않는다. -- 저장소 근거와 충돌하는 서술은 높은 우선순위 finding으로 기록한다. - -### 3-2. 실행 가능성과 측정 가능성 - -- FR/NFR, 성공 기준, 체크 항목이 다음 단계에서 실제 REQ/검증 항목으로 이어질 정도로 구체적인지 본다. -- `개선한다`, `지원한다`, `정리한다`처럼 모호한 동사만 있고 판정 기준이 없으면 구체화 필요 finding으로 기록한다. -- 성공 기준은 가능한 한 측정 방식과 데이터 출처에 연결되어야 한다. - -### 3-3. 플레이스홀더 위생 - -- 사용자 결정이 필요한 placeholder와 단순 미정리 placeholder를 구분한다. -- 같은 의미의 placeholder가 본문과 `# 10. 사용자 결정 필요 항목 요약`에 중복으로 남아 있으면 정리 대상으로 본다. -- 이미 문서 근거로 해소 가능한 placeholder는 unresolved로 방치하지 않는다. - -## 4. 허용되는 편집 범위 - -사용자가 수정까지 요청한 경우에만 아래 경미한 편집을 허용한다. - -- 중복 문장 삭제 또는 압축 -- 템플릿 잔여 문구 제거 -- 번호, 체크리스트, 링크, 용어 표기 일관성 정리 -- 답이 이미 문서 안에 있는 OQ를 본문 반영 후 resolved cleanup으로 정리 -- 의미 변화 없는 섹션 재배치 -- 실제 내용이 바뀌었을 때 `마지막 갱신 시각(KST)` 갱신 - -아래는 이 skill로 수정하지 않는다. - -- 새 요구사항 생성 -- 범위 재정의 -- 정책 확정 -- 승인자 지정 -- 상태 전환과 index 갱신 - -# Review Heuristics By Section - -섹션별로 최소한 아래를 점검한다. - -- `# 0. 문서 상태` - - 문서 ID, 이슈명, 시각 정보, 참조 필드가 자기모순 없는지 확인한다. -- `# 1. 계획 상태 요약` - - 현재 작성 배경과 이번 Discovery의 핵심 판단 포인트가 2줄 요약으로 드러나는지 본다. -- `# 2. 요구사항 판정 결과` - - 신규/유사/중복 판정과 근거가 실제 본문 범위와 맞는지 본다. -- `# 3. 문제점의 요약` - - 현재 상태, 기대 상태, 이해관계자, 시나리오가 왜 이 Discovery가 필요한지를 설명하는지 본다. -- `# 4. 이번에 정의할 변경` - - 변경 항목과 영향 범위가 `# 5` 요구사항과 연결되는지 본다. -- `# 5. 요구사항 목록` - - FR/NFR가 구분되어 있고 범위 경계가 충돌하지 않는지 본다. -- `# 6. 리스크/가정 목록` - - 리스크, 가정, OQ가 각각 역할에 맞게 쓰였는지 본다. -- `# 7. 초기 성공 기준` - - 측정 대상, 방식, 데이터 출처가 요구사항과 연결되는지 본다. -- `# 8. REQ로 넘기기 전 확인 체크` - - 체크 항목이 선언만 있고 판단 기준이 없는지 본다. -- `# 9. 파일 처리 결과` - - 참조 문서가 실제 검토 근거를 반영하는지 본다. -- `# 10. 사용자 결정 필요 항목 요약` - - DECIDE, CONFIRM, DATA가 실제 unresolved 항목과 맞는지 본다. -- `# 11. Discovery Freeze` - - review 결과와 무관하게 상태 전환 없이, 본문과 논리적 충돌만 점검한다. - -# Execution Procedure - -1. 입력에서 Discovery 문서 경로를 확정한다. -2. 대상 Discovery 문서를 읽고, 필요 시 `docs/discovery/index.md`와 직접 관련된 저장소 문맥을 읽는다. -3. 문서를 `중복 판정으로 축약 가능한 문서`인지 `정상 Discovery 검토 대상`인지 먼저 분류한다. -4. 위 `기본 검토 축`과 `추가 검토 항목` 기준으로 findings를 수집한다. -5. findings를 아래 우선순위로 분류한다. - - `blocking`: REQ handoff 또는 사실 관계에 직접 문제를 만드는 항목 - - `major`: 지금 고치는 편이 좋은 구조/일관성/측정 가능성 문제 - - `minor`: 표현, 중복, 서식, 경미한 cleanup 문제 -6. 추가 OQ 후보가 있으면 기존 OQ와 중복 여부를 제거한 뒤 후보 목록으로 정리한다. -7. 사용자가 수정까지 요청한 경우에만 `허용되는 편집 범위` 안에서 경미한 cleanup을 수행한다. -8. 편집을 수행한 경우 `마지막 갱신 시각(KST)`만 갱신하고, 상태 전환 없이 종료한다. -9. 최종적으로 findings, OQ 후보, auto-fix 결과, 다음 추천 행동을 보고한다. - -# Output Expectations - -작업이 끝나면 아래 내용을 보고할 수 있어야 한다. - -- 대상 `DISCOVERY_ID`와 문서 경로 -- 전체 판정 - - `양호, 바로 confirm-discovery 검토 가능` - - `경미한 정리 필요` - - `추가 보강 필요` - - `문서 구조 또는 사실 관계 재정비 필요` -- severity별 findings 목록 -- 새 OQ 후보 목록 -- 중복/불필요 내용 정리 제안 -- 자동 정리한 항목 목록(수정 요청이 있었을 때만) -- 다음 추천 행동 - - `confirm-discovery` 진행 - - Discovery 문서 추가 수정 후 재검토 - - 범위가 바뀌어 `new-discovery` 또는 `change-req` 판단 필요 - -# Validation - -완료 전에 아래를 확인한다. - -- 대상 경로가 실제 Discovery 문서를 가리키는지 확인한다. -- `완전 중복` 예외를 제외하고, 누락 판단이 템플릿 구조와 충돌하지 않는지 확인한다. -- 제안한 OQ가 이미 본문이나 다른 섹션에서 답을 가진 항목이 아닌지 확인한다. -- 같은 finding을 누락/중복/불필요 항목에 중복 보고하지 않았는지 확인한다. -- 수정이 있었다면 상태 전환 값과 `docs/discovery/index.md`가 바뀌지 않았는지 확인한다. -- 저장소 사실 관계와의 충돌을 지적했다면 최소 한 개 이상의 직접 근거가 있는지 확인한다. \ No newline at end of file diff --git a/.github/skills/run-batch-implementation/SKILL.md b/.github/skills/run-batch-implementation/SKILL.md deleted file mode 100644 index a943c6b..0000000 --- a/.github/skills/run-batch-implementation/SKILL.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: run-batch-implementation -description: "Use when: implementing a confirmed batch design, running /run-batch-implementation with a BAT ID, applying code changes for docs/batches/, or updating implementation logs and validation evidence for a batch." -argument-hint: "예: bat-001 또는 docs/batches/bat-001_20260424_scaffold" -user-invocable: true ---- - -# Purpose - -This skill executes the implementation work for a batch, updates code, and records the changed files, execution log, validation, and remaining risks in the batch implementation document. - -# Inputs - -- `BAT-ID` 또는 batch 경로 -- `docs/batches//planning.md` -- `docs/batches//design.md` -- `docs/batches//implementation.md` -- 관련 REQ 문서와 실제 코드 경로 - -# Core Rules - -- planning과 design이 없는 batch는 구현하지 않는다. -- 코드 변경 전 `implementation.md`의 Plan Summary와 Changed Files 초안을 먼저 맞춘다. -- 구현 직후 가장 좁은 테스트, lint, typecheck, 또는 동작 검증을 수행한다. -- blocker가 생기면 문서에 남기고 범위를 임의 확장하지 않는다. - -# Execution Procedure - -1. batch 경로, plan, design, 관련 REQ를 읽는다. -2. 구현 범위와 변경 파일 후보를 요약한다. -3. 실제 코드 변경을 수행한다. -4. `implementation.md`의 Changed Files, Execution Log, Validation, Remaining Risks를 갱신한다. -5. batch index 상태를 필요하면 `in-delivery`로 유지 또는 보정한다. - -# Validation - -- `implementation.md`에 실제 변경 파일과 검증 기록이 반영됐는지 확인한다. -- 구현 직후 수행한 가장 좁은 검증 명령 또는 결과가 문서에 남았는지 확인한다. \ No newline at end of file diff --git a/.github/skills/run-sdlc/SKILL.md b/.github/skills/run-sdlc/SKILL.md deleted file mode 100644 index 6a11936..0000000 --- a/.github/skills/run-sdlc/SKILL.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -name: run-sdlc -description: "Use when: orchestrating the next SDLC action from the current 3-phase state, running /run-sdlc with a Discovery, REQ, Batch, or Release ID, resuming from the first incomplete unit in docs/discovery, docs/srs, docs/batches, or docs/releases, or routing a no-history repository to bootstrap-baseline before the first real Discovery." -argument-hint: "예: dcy-001, bat-001, rel-001, req-001 req-002" -user-invocable: true ---- - -# Purpose - -This skill inspects the current state of Discovery, REQ, Batch, and Release documents and routes work to the next skill-only step in the 3-phase SDLC model. - -`docs/project-structure.md`와 `docs/runtime-flows.md`는 별도 governance unit는 아니지만, 이 skill은 다음 단계를 추천할 때 해당 baseline 문서의 존재 여부와 갱신 필요 여부를 함께 확인한다. - -# Inputs - -- `DISCOVERY_ID` -- `REQ-ID` 목록 -- `BAT-ID` -- `REL-ID` -- 관련 문서 경로 -- `docs/project-structure.md` (존재하는 경우) -- `docs/runtime-flows.md` (존재하는 경우) - -# Core Rules - -- 이 skill은 `docs/discovery/`, `docs/srs/`, `docs/batches/`, `docs/releases/`만 active 경로로 사용한다. -- `docs/project-structure.md`와 `docs/runtime-flows.md`는 active routing 대상은 아니지만, 모든 단계에서 참고할 cross-cutting baseline 문서로 취급한다. -- prompt 기반 stage command는 사용하지 않는다. -- `review-*` 단계는 별도 명령으로 두지 않고 대응 `confirm-*` 절차에 흡수된 상태를 전제로 한다. -- 사람 선택이 필요한 경계에서는 자동으로 넘기지 않고 중단한다. 대표적으로 `suggest-batch-reqs` 결과에서 어떤 후보를 채택할지 결정하는 단계가 그렇다. -- 단, 정확히 1개의 `Approved` REQ가 국소적이고 저위험 변경으로 판단되면 `minor-change` fast path로 `draft-batch`를 바로 제안할 수 있다. 이 경로의 batch profile은 `batch-lite`다. -- active unit가 아직 없고 baseline 문서 또는 active index가 비어 있으면, 첫 real Discovery 전에 `bootstrap-baseline`을 우선 추천한다. - -# Execution Procedure - -## 1. 입력 해석 - -- 입력이 `dcy-`로 시작하면 Discovery를 기준으로 현재 상태를 확인한다. -- 입력이 `req-` 목록이면 REQ 상태를 읽고 batch 생성 또는 추천 단계로 보낸다. -- 입력이 `bat-`로 시작하면 batch delivery 상태를 확인한다. -- 입력이 `rel-`로 시작하면 release 상태를 확인한다. -- 입력이 없으면 현재 저장소에서 첫 incomplete step을 판정한다. -- 어떤 입력이든 가능하면 `docs/project-structure.md`와 `docs/runtime-flows.md` 존재 여부를 함께 확인한다. - -## 1.5 bootstrap baseline routing - -- active unit가 없고 `docs/project-structure.md`, `docs/runtime-flows.md`, active index 중 일부라도 비어 있으면 `bootstrap-baseline`을 다음 skill로 제안한다. -- 이 경우 Discovery를 자동 생성하지 않는다. -- baseline 초기화가 끝난 뒤 `new-discovery`를 다음 real Discovery entrypoint로 안내한다. - -## 2. Discovery 기준 routing - -- Discovery가 아직 `confirmed`가 아니면 `confirm-discovery`를 우선 실행한다. -- Discovery는 confirmed지만 연결된 REQ가 없으면 `draft-req`를 다음 단계로 사용한다. -- Approved REQ는 있으나 batch가 없으면 `suggest-batch-reqs`를 실행해 후보를 제시하고, 사용자가 후보를 선택한 뒤 `draft-batch`로 이어 가도록 멈춘다. -- baseline 문서 갭이 있으면 상태 요약에 `docs/project-structure.md`와 `docs/runtime-flows.md` 갱신 필요 여부를 함께 기록한다. - -## 3. REQ 기준 routing - -- `Proposed` REQ면 `confirm-req`를 다음 단계로 사용한다. -- 기존 REQ의 `Requirement`, `Acceptance Criteria`, 구현 전제가 바뀌어 Change Log 기반 변경 관리가 필요하면 `change-req`를 다음 단계로 사용한다. -- `Approved` REQ가 `release-candidate` 또는 `released` batch의 verification evidence와 연결돼 있으면 `confirm-req-implemented`를 다음 단계로 사용한다. -- 단일 `Approved` REQ이고 구조/인터페이스/런타임 영향이 없는 소규모 변경이면 `draft-batch`를 `minor-change -> batch-lite` 경로로 바로 제안한다. -- Approved REQ 묶음이 2개 이상이거나 사람 선택이 필요한 경우 `suggest-batch-reqs` 또는 `draft-batch`로 이어진다. -- baseline 문서 갱신이 REQ에 포함돼 있으면 상태 요약에 이를 반영한다. - -## 4. Batch 기준 routing - -- `planning.md`가 비어 있거나 draft 수준이면 `draft-batch-planning` -- batch profile이 `standard`이거나 `batch-lite`라도 planning의 `Design Gate`가 `yes`면 `draft-batch-design` -- batch profile이 `batch-lite`이고 `Design Gate`가 `no`면 design 없이 `run-batch-implementation` -- 구현 로그와 코드 변경이 아직 없으면 `run-batch-implementation` -- verification 문서가 없거나 불완전하면 `draft-batch-verification` -- verification evidence가 있지만 release-candidate가 아니면 `confirm-batch-verification` -- batch가 `release-candidate` 또는 `released`이고 포함 REQ 중 `Approved` 상태가 남아 있으면 `confirm-req-implemented` -- batch가 `release-candidate`이고 포함 REQ 구현 상태 동기화까지 끝났으면 `draft-release` -- batch design 또는 verification에 structure/flow 영향이 보이면 baseline 문서 갱신 필요 여부를 상태 요약에 포함한다. - -## 5. Release 기준 routing - -- release 문서가 draft면 profile(`docs-only` | `tooling` | `app-service`)에 맞는 근거가 채워졌는지 확인한 뒤 `confirm-release` -- `feedback-captured` release에 follow-up 입력이 있으면 `suggest-next-discovery` 또는 `change-req`를 다음 단계 후보로 제안한다. -- release가 confirmed 이후 운영 관찰이 남아 있지 않으면 `capture-release-feedback` - -# Output Expectations - -- 입력으로 해석한 단위 종류 -- 현재 상태 요약 -- baseline 문서 상태 (`present` | `missing` | `update-likely-required` | `bootstrap-required`) -- 바로 실행할 다음 skill 1개 또는 추천 후보 목록 -- 자동으로 진행하지 않고 멈춘 이유가 있으면 그 사유 - -# Validation - -- 다음 단계가 현재 문서 상태와 모순되지 않는지 확인한다. -- 제안한 다음 skill이 실제 `.github/skills/` 아래 존재하는지 확인한다. -- baseline 문서가 필요한 상황에서 상태 요약 또는 추천 사유에 그 정보가 누락되지 않았는지 확인한다. \ No newline at end of file diff --git a/.github/skills/suggest-batch-reqs/SKILL.md b/.github/skills/suggest-batch-reqs/SKILL.md deleted file mode 100644 index 35c590a..0000000 --- a/.github/skills/suggest-batch-reqs/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: suggest-batch-reqs -description: "Use when: recommending delivery batch groupings from approved requirements, running /suggest-batch-reqs with a Discovery ID or REQ list, assessing cohesion and delivery risk, or preparing input for /draft-batch without changing repository state." -argument-hint: "예: dcy-001 또는 req-001 req-002 req-003" -user-invocable: true ---- - -# Purpose - -This skill recommends one or more candidate batch groupings from approved requirements and reports rationale, exclusions, risk, and confidence without modifying any files. 입력이 없으면 현재 저장소에서 아직 delivery 입력으로 소모되지 않은 Approved REQ를 찾아 이번 batch 후보를 추천한다. - -# When to use - -- Approved REQ가 여러 개 쌓여 어떤 묶음으로 delivery를 시작할지 추천이 필요할 때 -- `/suggest-batch-reqs dcy-001` 또는 `/suggest-batch-reqs req-001 req-002`처럼 batch 후보안을 받아야 할 때 -- `/suggest-batch-reqs`처럼 인자 없이 현재 저장소의 Approved REQ 중 아직 구현 완료 또는 기존 batch 편성이 끝나지 않은 후보만 추려 이번 batch 권장안을 보고 싶을 때 -- `draft-batch` 실행 전에 포함/제외 REQ 근거를 미리 보고 싶을 때 - -# Inputs - -- 선택 입력 - - `DISCOVERY_ID` 또는 Approved `REQ-ID` 목록 - - 입력이 없으면 `docs/srs/**/req-*.md`와 `docs/srs/index.md`를 읽어 Approved REQ를 찾는다. - - 입력이 없으면 `docs/batches/index.md`도 함께 읽어 이미 batch에 편성된 REQ를 제외한다. -- 선택 입력 - - batch 최대 크기 - - Type, Priority, Owner 필터 - - 제외할 REQ 목록 - - 강제로 함께 고려할 REQ 목록 - -# Core Rules - -- 이 skill은 추천만 수행하고 저장소 상태를 바꾸지 않는다. -- `Approved`가 아닌 REQ는 후보에서 제외한다. -- 입력이 없는 경우 `Implemented` 상태 REQ는 후보에서 제외한다. -- 입력이 없는 경우 `docs/batches/index.md` 기준으로 `draft`, `in-delivery`, `release-candidate`, `released` batch에 이미 포함된 REQ는 중복 추천하지 않는다. -- 아래 조건을 많이 만족할수록 같은 batch 후보로 묶는다. - - 같은 사용자 가치 또는 기능 흐름 - - 같은 모듈, 인터페이스, 런타임 경로 - - 같은 설계 전제와 환경 가정 - - 같은 iteration 안에서 구현과 검증 가능 - - acceptance criteria를 함께 검증하는 것이 효율적임 - - release 시 함께 배포해도 위험이 과도하지 않음 -- 아래 조건이 있으면 같은 batch로 묶지 않는다. - - 핵심 설계 전제 충돌 - - 완전히 다른 verification 흐름 필요 - - 독립 배포가 필요한 고위험 변경 - - 미해결 정책 결정이나 외부 의존성으로 planning이 불안정함 - -# Execution Procedure - -1. 입력이 Discovery면 연결된 REQ 후보를 찾고, 입력이 REQ 목록이면 각 REQ 문서를 읽는다. -2. 입력이 없으면 `docs/srs/**/req-*.md`, `docs/srs/index.md`, `docs/batches/index.md`를 읽어 Approved REQ 전체를 찾고, `Implemented` 상태 또는 기존 batch에 이미 포함된 REQ를 제외해 현재 후보군을 만든다. -3. 각 REQ의 상태, Type, Impacted Area, Acceptance Criteria를 요약한다. -4. 후보군에서 Approved REQ만 추린 뒤 응집도와 delivery risk 기준으로 후보안을 1개 이상 만든다. -5. 각 후보에 대해 포함 REQ, 제외 REQ와 제외 이유, 추천 근거, 위험도, 신뢰도를 정리한다. -6. 바로 사용할 수 있는 `/draft-batch ...` 입력 예시를 제시한다. - -# Output Expectations - -- 후보 `BAT-CANDIDATE-N` 목록 -- 입력이 없는 경우 스캔한 Approved REQ 목록 -- 후보별 포함 REQ -- 후보별 제외 REQ와 제외 이유 -- 후보별 추천 근거 -- 후보별 위험도 (`Low|Medium|High`) -- 후보별 신뢰도 (`High|Medium|Low`) -- `draft-batch` 추천 명령 예시 - -# Validation - -- 출력에 `Approved`가 아닌 REQ가 포함되지 않았는지 확인한다. -- 입력이 없는 경우 `Implemented` 상태 REQ와 기존 batch에 이미 포함된 REQ가 후보에 남아 있지 않은지 확인한다. -- 추천안이 2개 이상일 때는 최소 1개는 작은 묶음 또는 보수적 대안을 포함한다. -- 적절한 묶음이 없으면 `단일 REQ batch 권장`을 명시한다. \ No newline at end of file diff --git a/.github/skills/suggest-next-discovery/SKILL.md b/.github/skills/suggest-next-discovery/SKILL.md deleted file mode 100644 index 457b85f..0000000 --- a/.github/skills/suggest-next-discovery/SKILL.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -name: suggest-next-discovery -description: "Use when: recommending the next Discovery candidate from release feedback, pending requirements, baseline gaps, or recent doctor reports, running /suggest-next-discovery with or without a REL ID, or deciding whether feedback should become a new Discovery or a change-req instead." -argument-hint: "예: rel-001 또는 인자 없이 /suggest-next-discovery" -user-invocable: true ---- - -# Purpose - -This skill analyzes release feedback, pending REQs, baseline gaps, and recent delivery signals to recommend the next improvement loop. It reports ranked candidates and tells the user whether to start a new Discovery or route work into `change-req` instead. - -# When to use - -- `/suggest-next-discovery`처럼 다음 반복 주제를 추천받고 싶을 때 -- `feedback-captured` release 이후 어떤 후속 Discovery를 열지 정해야 할 때 -- release feedback에 기존 REQ 수정과 신규 Discovery 후보가 섞여 있어 triage가 필요할 때 -- `stagepilot-doctor --report ...` 결과와 release feedback를 함께 보고 다음 개선 루프를 정하고 싶을 때 - -# Inputs - -- 선택 입력 - - `REL-ID` prefix 또는 release 경로 - - 입력이 없으면 `docs/releases/*.md`, `docs/srs/index.md`, `docs/project-structure.md`, `docs/runtime-flows.md`를 읽어 후보를 찾는다. -- 선택 입력 - - `stagepilot-doctor` Markdown report 경로 -- 관련 release 문서의 `Feedback Handoff` -- `docs/srs/index.md`와 미구현/변경 대기 REQ 상태 -- `docs/project-structure.md`, `docs/runtime-flows.md` (존재하는 경우) - -# Core Rules - -- 이 skill은 추천만 수행하고 저장소 상태를 바꾸지 않는다. -- release feedback의 후보는 아래 셋 중 하나로 분류한다. - - `new-discovery` - - `change-req` - - `no-action` -- 기존 REQ의 수정으로 닫히는 항목은 새 Discovery보다 `change-req`를 우선 추천한다. -- 서로 다른 가치 흐름이나 이해관계자를 가지는 후보는 별도 Discovery로 분리한다. -- 추천에는 가치, 위험, 노력의 상대 평가를 포함한다. - -# Execution Procedure - -1. 입력 release가 있으면 해당 문서를, 없으면 최근 release 문서들을 읽는다. -2. `Feedback Handoff`에서 Discovery Input, REQ Input, Change Request Input을 추린다. -3. `docs/srs/index.md`와 관련 REQ 상태를 읽어 기존 REQ 수정으로 해결 가능한지 판정한다. -4. baseline 문서 gap, 미구현 REQ, doctor report의 경고/오류가 후속 반복 후보인지 함께 판단한다. -5. 후보를 `new-discovery` 또는 `change-req`로 분류하고 가치/위험/노력 점수를 매긴다. -6. 우선순위 순으로 추천 목록과 바로 실행할 명령 예시를 제시한다. - -# Output Expectations - -- 후보 목록 (`CANDIDATE-1`, `CANDIDATE-2`, ...) -- 각 후보의 분류 (`new-discovery` | `change-req` | `no-action`) -- 근거 release 또는 REQ -- 가치/위험/노력 (`High|Medium|Low`) -- 추천 이유 -- 다음 명령 예시 (`/new-discovery ...` 또는 `/change-req ...`) - -# Validation - -- 동일 후보를 `new-discovery`와 `change-req`에 중복 배치하지 않았는지 확인한다. -- release feedback 근거가 없는 임의 후보를 만들지 않았는지 확인한다. -- 기존 REQ 수정으로 닫히는 항목을 새 Discovery로 과도하게 승격하지 않았는지 확인한다. \ No newline at end of file diff --git a/.github/templates/batches/batch-index.md b/.github/templates/batches/batch-index.md deleted file mode 100644 index 8aca9a2..0000000 --- a/.github/templates/batches/batch-index.md +++ /dev/null @@ -1,25 +0,0 @@ -# {{BAT_ID}}: <배치 제목> - -- Status: draft | in-delivery | release-candidate | released | archived -- Profile: standard | batch-lite -- Source Discovery: -- Included REQ: -- Excluded REQ: - -## Summary -- <이 batch를 왜 묶었는지> - -## Scope -- In Scope: - - <포함 범위> -- Out of Scope: - - <제외 범위> - -## Documents -- Planning: [planning.md](./planning.md) -- Design: <`[design.md](./design.md)` 또는 `none (batch-lite with no architecture impact)`> -- Implementation: <`[implementation.md](./implementation.md)` 또는 `create at implementation stage`> -- Verification: <`[verification.md](./verification.md)` 또는 `create at verification stage`> - -## Notes -- <공통 리스크, 공통 결정 사항> diff --git a/.github/templates/batches/design.md b/.github/templates/batches/design.md deleted file mode 100644 index 87d718b..0000000 --- a/.github/templates/batches/design.md +++ /dev/null @@ -1,26 +0,0 @@ -# {{BAT_ID}} Design - -- Status: draft | confirmed -- Batch: {{BAT_ID}} - -## Architecture Summary -- <배치 수준 설계 요약> - -## Changed Areas -- Docs: <문서 경로> -- Modules: <모듈/컴포넌트> -- Interfaces: <인터페이스> - -## Key Decisions -1. <결정 1> -2. <결정 2> - -## Edge Cases -- <예외 처리 및 설계 경계> - -## Architecture Impact -- - -## Reference Doc Update Plan -- project-structure.md: <갱신 계획 또는 none> -- runtime-flows.md: <갱신 계획 또는 none> diff --git a/.github/templates/batches/implementation.md b/.github/templates/batches/implementation.md deleted file mode 100644 index d69112e..0000000 --- a/.github/templates/batches/implementation.md +++ /dev/null @@ -1,20 +0,0 @@ -# {{BAT_ID}} Implementation - -- Status: draft | in-progress | completed -- Batch: {{BAT_ID}} - -## Plan Summary -- <이번 구현에서 실제 변경할 범위> - -## Changed Files -- <파일 목록> - -## Execution Log -1. <실행 항목> -2. <실행 항목> - -## Validation -- <실행한 테스트, lint, typecheck, 수동 점검> - -## Remaining Risks -- <잔여 이슈> diff --git a/.github/templates/batches/index.md b/.github/templates/batches/index.md deleted file mode 100644 index 158750c..0000000 --- a/.github/templates/batches/index.md +++ /dev/null @@ -1,10 +0,0 @@ -# Batch Index - -## 목적 -- 이 문서는 batch delivery 단위의 전역 인덱스다. -- 각 batch는 `docs/batches//` 아래에서 profile(`standard` | `batch-lite`)에 맞는 산출물을 관리한다. - -## Batch Register - -| BAT ID | Profile | Status | Included REQ | Discovery | Folder | -| --- | --- | --- | --- | --- | --- | diff --git a/.github/templates/batches/planning.md b/.github/templates/batches/planning.md deleted file mode 100644 index 324adbd..0000000 --- a/.github/templates/batches/planning.md +++ /dev/null @@ -1,28 +0,0 @@ -# {{BAT_ID}} Planning - -- Status: draft | confirmed -- Batch: {{BAT_ID}} -- Profile: standard | batch-lite - -## Included REQ -- - -## Delivery Plan -- <구현 범위, 순서, 선행 조건> - -## Dependencies -- Internal: - - <내부 의존성> -- External: - - <외부 의존성> - -## Design Gate -- Design Required Before Implementation: yes | no -- Reason: <왜 즉시 design이 필요한지 또는 왜 생략 가능한지> - -## Milestones -1. <마일스톤> -2. <마일스톤> - -## Risks -- <리스크와 대응> diff --git a/.github/templates/batches/verification.md b/.github/templates/batches/verification.md deleted file mode 100644 index 0a7971e..0000000 --- a/.github/templates/batches/verification.md +++ /dev/null @@ -1,21 +0,0 @@ -# {{BAT_ID}} Verification - -- Status: draft | confirmed -- Batch: {{BAT_ID}} - -## Acceptance Mapping -- : - - - -## Evidence -- Test: - - <테스트 결과> -- Manual: - - <수동 검증 결과> -- Logs: - - <로그 또는 스크린샷 경로> - -## Result -- Ready for Release: false -- Blocking Issues: - - <차단 이슈 또는 없음> diff --git a/.github/templates/bootstrap/baseline-seed.yaml b/.github/templates/bootstrap/baseline-seed.yaml deleted file mode 100644 index 41c3cbf..0000000 --- a/.github/templates/bootstrap/baseline-seed.yaml +++ /dev/null @@ -1,95 +0,0 @@ -schema_version: 1 -capture_mode: declared # declared | observed | mixed - -project: - repository_name: {{REPOSITORY_NAME:repo-folder-name}} - display_name: {{PROJECT_NAME:display name or repo name}} - summary: {{PROJECT_SUMMARY:one-sentence project summary}} - primary_domain: {{PRIMARY_DOMAIN:short domain phrase or undecided}} - -stack: - tech_stack: - - {{TECH_STACK_ITEM_1:primary language or framework}} - -runtime: - primary_runtime: {{PRIMARY_RUNTIME:cli|api-service|web-app|worker|library|mixed|other}} - primary_entrypoints: - - name: {{ENTRYPOINT_NAME_1:command or interface name}} - purpose: {{ENTRYPOINT_PURPOSE_1:brief purpose}} - -interfaces: - contracts: - - name: {{INTERFACE_NAME_1:primary interface name}} - type: {{INTERFACE_TYPE_1:http-api|cli|event|file|internal-service|other}} - actors: {{INTERFACE_ACTORS_1:who calls or receives it}} - purpose: {{INTERFACE_PURPOSE_1:what this interface exists to do}} - stability: {{INTERFACE_STABILITY_1:experimental|internal|stable}} - inputs: - - {{INTERFACE_INPUT_1_1:input contract or invocation note}} - outputs: - - {{INTERFACE_OUTPUT_1_1:output contract or artifact}} - errors: - - {{INTERFACE_ERROR_1_1:error code or failure behavior}} - compatibility_rules: - - {{INTERFACE_COMPATIBILITY_1_1:compatibility or versioning rule}} - - name: {{INTERFACE_NAME_2:secondary interface name}} - type: {{INTERFACE_TYPE_2:http-api|cli|event|file|internal-service|other}} - actors: {{INTERFACE_ACTORS_2:who calls or receives it}} - purpose: {{INTERFACE_PURPOSE_2:what this interface exists to do}} - stability: {{INTERFACE_STABILITY_2:experimental|internal|stable}} - inputs: - - {{INTERFACE_INPUT_2_1:input contract or invocation note}} - outputs: - - {{INTERFACE_OUTPUT_2_1:output contract or artifact}} - errors: - - {{INTERFACE_ERROR_2_1:error code or failure behavior}} - compatibility_rules: - - {{INTERFACE_COMPATIBILITY_2_1:compatibility or versioning rule}} - shared_constraints: - - {{INTERFACE_SHARED_CONSTRAINT_1:cross-cutting interface constraint}} - current_gaps: - - {{INTERFACE_GAP_1:known contract gap or planned change}} - -structure: - planned_top_level_areas: - - path: {{TOP_LEVEL_PATH_1:path such as src/ or app/}} - responsibility: {{TOP_LEVEL_RESPONSIBILITY_1:brief responsibility}} - -data_model: - persistence_backend: {{PERSISTENCE_BACKEND:primary persistence backend or storage system}} - entities: - - name: {{ENTITY_NAME_1:primary entity}} - purpose: {{ENTITY_PURPOSE_1:what this entity represents}} - source_of_truth: {{ENTITY_SOURCE_1:table, collection, file, or service}} - lifecycle: {{ENTITY_LIFECYCLE_1:created -> updated -> archived}} - key_fields: - - {{ENTITY_FIELD_1_1:field :: type :: meaning}} - relationships: - - {{ENTITY_RELATIONSHIP_1_1:related entity :: relationship meaning}} - state_rules: - - {{ENTITY_STATE_RULE_1_1:state or invariant rule}} - - name: {{ENTITY_NAME_2:secondary entity}} - purpose: {{ENTITY_PURPOSE_2:what this entity represents}} - source_of_truth: {{ENTITY_SOURCE_2:table, collection, file, or service}} - lifecycle: {{ENTITY_LIFECYCLE_2:created -> updated -> archived}} - key_fields: - - {{ENTITY_FIELD_2_1:field :: type :: meaning}} - relationships: - - {{ENTITY_RELATIONSHIP_2_1:related entity :: relationship meaning}} - state_rules: - - {{ENTITY_STATE_RULE_2_1:state or invariant rule}} - consistency_rules: - - {{CONSISTENCY_RULE_1:consistency or referential rule}} - persistence_notes: - - {{PERSISTENCE_NOTE_1:mapping, storage, or migration note}} - current_gaps: - - {{MODEL_GAP_1:known model gap or planned schema change}} - -notes: - known_unknowns: - - {{KNOWN_UNKNOWN_1:optional unresolved bootstrap item or none}} - -metadata: - captured_at_kst: {{CAPTURED_AT_KST:YYYY-MM-DD HH:MM}} - source: bootstrap-baseline-questionnaire - updated_from: {{UPDATED_FROM:user-answer|repo-observation|mixed}} \ No newline at end of file diff --git a/.github/templates/data-model.md b/.github/templates/data-model.md deleted file mode 100644 index 9832d0b..0000000 --- a/.github/templates/data-model.md +++ /dev/null @@ -1,88 +0,0 @@ -# {{PROJECT_NAME}} Data Model - -- Baseline Mode: {{BASELINE_MODE:declared|observed|mixed}} -- Baseline Seed: {{BASELINE_SEED_PATH:.stagepilot/bootstrap/baseline.yaml}} -- Status: draft | confirmed -- Owner: {{OWNER_OR_TEAM}} -- Last Updated (KST): {{UPDATED_AT_KST}} -- Source Discovery / Batch: {{SOURCE_REF}} - -## Project Summary -- {{PROJECT_SUMMARY:one-sentence summary of the project}} - -## Primary Domain -- {{PRIMARY_DOMAIN:short domain phrase or undecided}} - -## Primary Persistence Backend -- {{PERSISTENCE_BACKEND:primary persistence backend or storage system}} - -## Purpose -- This document captures the approved cross-cutting data model baseline for the project. -- It is a shared reference for REQ drafting, batch design, implementation, verification, migration planning, and handoff. - -## Scope -- Covers core entities, relationships, states, persistence mappings, and shared consistency rules. -- Does not duplicate full functional acceptance criteria already owned by REQ documents. -- Does not replace ORM definitions, migration files, or generated schema artifacts. - -## Model Summary - -{{MODEL_SUMMARY_BULLETS:- - :: }} - -## Entity: `{{ENTITY_NAME_1:primary entity}}` - -- Purpose: {{ENTITY_PURPOSE_1:what this entity represents}} -- Source of Truth: {{ENTITY_SOURCE_1:table, collection, file, or service}} -- Lifecycle: {{ENTITY_LIFECYCLE_1:created -> updated -> archived}} - -### Key Fields - -{{ENTITY_FIELDS_1:- :: :: }} - -### Relationships - -{{ENTITY_RELATIONSHIPS_1:- :: }} - -### State Rules - -{{ENTITY_STATE_RULES_1:- }} - -## Entity: `{{ENTITY_NAME_2:secondary entity}}` - -- Purpose: {{ENTITY_PURPOSE_2:what this entity represents}} -- Source of Truth: {{ENTITY_SOURCE_2:table, collection, file, or service}} -- Lifecycle: {{ENTITY_LIFECYCLE_2:created -> updated -> archived}} - -### Key Fields - -{{ENTITY_FIELDS_2:- :: :: }} - -### Relationships - -{{ENTITY_RELATIONSHIPS_2:- :: }} - -### State Rules - -{{ENTITY_STATE_RULES_2:- }} - -## Shared Consistency Rules - -{{CONSISTENCY_RULE_BULLETS:- - }} - -## Persistence / Integration Notes - -{{PERSISTENCE_NOTES_BULLETS:- - }} - -## Current Gaps / Planned Changes - -{{MODEL_CURRENT_GAPS_BULLETS:- - }} - -## Update Triggers - -- Update this document when a core entity, key field, or relationship changes. -- Update this document when lifecycle or persistence behavior changes. -- Update this document when a batch introduces a migration, backfill, or breaking data assumption. - -## Change Log - -{{CHANGE_LOG_BULLETS:- - }} \ No newline at end of file diff --git a/.github/templates/discovery/discovery.md b/.github/templates/discovery/discovery.md deleted file mode 100644 index dc3c13d..0000000 --- a/.github/templates/discovery/discovery.md +++ /dev/null @@ -1,224 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: {{DOC_STATUS:draft|review|confirmed}} -- 문서 ID: {{DISCOVERY_ID:dcy-<3자리>__}} -- 이슈명: {{ISSUE_NAME:짧은 한글 또는 영문 이슈명}} -- 작성 시각(KST): {{CREATED_AT_KST:TBD}} -- 마지막 갱신 시각(KST): {{UPDATED_AT_KST:TBD}} -- 대체됨: {{SUPERSEDED_BY_DISCOVERY:없음 또는 docs/discovery/.md}} -- 후속 Discovery 참조: {{FOLLOW_UP_DISCOVERY_REF:없음 또는 docs/discovery/.md}} -- 생성된 REQ 참조: 없음 - -# 1. 계획 상태 요약 -현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. - -- 연계 문서 현황: - - 관련 점수 파일: {{RELATED_SCORE_FILE:미생성 또는 경로}} - - 관련 TODO/메모: {{TODO_FILE:경로 또는 없음}} -- 해석: - - {{SUMMARY_LINE_1:현재 검토/작성 배경 요약}} - - {{SUMMARY_LINE_2:이번 Discovery에서 판단해야 할 핵심 요약}} - -# 2. 요구사항 판정 결과 -이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. - -- 판정: {{REQUIREMENT_CLASSIFICATION:완전 중복|유사|신규}} -- 근거 - - {{CLASSIFICATION_REASON_1}} - - {{CLASSIFICATION_REASON_2}} - -> **판정이 `완전 중복`인 경우**: 이하 섹션 작성을 중단하고 `# 9. 파일 처리 결과`에 중복 판정 사유와 참조 문서 경로를 기록한 뒤 문서를 닫는다. REQ 초안 작성으로 넘기지 않는다. - -# 3. 문제점의 요약 -문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. - -- 현재 상태 - - {{CURRENT_STATE_1}} - - {{CURRENT_STATE_2}} -- 기대 상태 - - {{TARGET_STATE_1}} - - {{TARGET_STATE_2}} -- 이해관계자 - - {{STAKEHOLDER_1:예. 문서 작성자(AI agent)}} - - {{STAKEHOLDER_2:예. 리뷰어/승인자}} - - {{STAKEHOLDER_3:예. 운영 담당자}} -- 제약사항 - - {{CONSTRAINT_1}} - - {{CONSTRAINT_2}} - -## 주요 사용자 시나리오 -사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. - -- {{USER_SCENARIO_1}} -- {{USER_SCENARIO_2}} -- {{USER_SCENARIO_3}} - -# 4. 이번에 정의할 변경 -이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. - -- {{CHANGE_ITEM_1}} -- {{CHANGE_ITEM_2:필요 없으면 삭제}} -- {{CHANGE_ITEM_3:필요 없으면 삭제}} - -## 영향 범위 -변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. - -- 코드 영향: - - {{CODE_IMPACT_1:예. docs/discovery/index.md 갱신 로직}} - - {{CODE_IMPACT_2:필요 없으면 삭제}} -- 문서 영향: - - {{DOC_IMPACT_1}} - - {{DOC_IMPACT_2:필요 없으면 삭제}} -- 운영 영향: - - {{OPS_IMPACT_1:예. 배포/운영 절차 변경 없음}} - - {{OPS_IMPACT_2:필요 없으면 삭제}} - -# 5. 요구사항 목록 -실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. - -## 기능 요구사항 -- FR-1: {{FR_1}} -- FR-2: {{FR_2}} -- FR-3: {{FR_3:필요 없으면 삭제}} -- FR-4: {{FR_4:필요 없으면 삭제}} - -## 비기능 요구사항 -성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. - -- NFR-1: {{NFR_1}} -- NFR-2: {{NFR_2}} -- NFR-3: {{NFR_3:필요 없으면 삭제}} - -## 범위 경계 -이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. - -- In Scope - - {{IN_SCOPE_1}} - - {{IN_SCOPE_2}} -- Out of Scope - - {{OUT_OF_SCOPE_1}} - - {{OUT_OF_SCOPE_2}} - -# 6. 리스크/가정 목록 -불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. - -## 리스크 -- R-1 ({{RISK_LEVEL_1:High|Medium|Low}}): {{RISK_1}} - - 완화 방안: {{MITIGATION_1}} -- R-2 ({{RISK_LEVEL_2:High|Medium|Low}}): {{RISK_2}} - - 완화 방안: {{MITIGATION_2}} -- R-3 ({{RISK_LEVEL_3:High|Medium|Low}}): {{RISK_3:필요 없으면 삭제}} - - 완화 방안: {{MITIGATION_3:필요 없으면 삭제}} - -## 가정 -아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. - -- A-1: {{ASSUMPTION_1}} -- A-2: {{ASSUMPTION_2}} -- A-3: {{ASSUMPTION_3:필요 없으면 삭제}} - -## 오픈 질문 -답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. - -- OQ-1: {{OPEN_QUESTION_1}} - - 상태: {{OPEN_QUESTION_STATUS_1:Open|Answered|Deferred|Dropped}} - - 처리 방안: {{OPEN_QUESTION_ACTION_1:누가 어떤 방식으로 결정/확인할지}} - - 종료 조건: {{OPEN_QUESTION_EXIT_1:언제 이 질문을 Answered/Deferred/Dropped로 처리할지}} -- OQ-2: {{OPEN_QUESTION_2}} - - 상태: {{OPEN_QUESTION_STATUS_2:Open|Answered|Deferred|Dropped}} - - 처리 방안: {{OPEN_QUESTION_ACTION_2:누가 어떤 방식으로 결정/확인할지}} - - 종료 조건: {{OPEN_QUESTION_EXIT_2:언제 이 질문을 Answered/Deferred/Dropped로 처리할지}} -- OQ-3: {{OPEN_QUESTION_3:필요 없으면 삭제}} - - 상태: {{OPEN_QUESTION_STATUS_3:Open|Answered|Deferred|Dropped}} - - 처리 방안: {{OPEN_QUESTION_ACTION_3:누가 어떤 방식으로 결정/확인할지}} - - 종료 조건: {{OPEN_QUESTION_EXIT_3:언제 이 질문을 Answered/Deferred/Dropped로 처리할지}} - -### 오픈 질문 처리 기준 -- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. -- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. -- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. -- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. -- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다. -- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다. -- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다. - -# 7. 초기 성공 기준 -이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. - -- S-1: {{SUCCESS_METRIC_1}} -- S-2: {{SUCCESS_METRIC_2}} -- S-3: {{SUCCESS_METRIC_3:필요 없으면 삭제}} - -## 측정 방식 및 데이터 출처 -각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. - -- S-1 측정 방식: {{MEASURE_METHOD_1}} / 데이터 출처: {{MEASURE_SOURCE_1}} -- S-2 측정 방식: {{MEASURE_METHOD_2}} / 데이터 출처: {{MEASURE_SOURCE_2}} -- S-3 측정 방식: {{MEASURE_METHOD_3:필요 없으면 삭제}} / 데이터 출처: {{MEASURE_SOURCE_3:필요 없으면 삭제}} - -# 8. REQ로 넘기기 전 확인 체크 -REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. - -- [ ] 범위(In Scope/Out of Scope) 확정 - - 기준: {{CHECK_SCOPE_CRITERIA}} -- [ ] 성공 지표의 측정 방식/데이터 출처 확정 - - 기준: {{CHECK_METRICS_CRITERIA}} -- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정 - - 기준: {{CHECK_RISK_OWNER_CRITERIA}} -- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - - 기준: OQ 목록에 `Open` 상태 항목이 0개이거나, 잔여 항목 전부 `Deferred` 처리 완료 -- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정 - - 기준: {{CHECK_CONFIRMEDBY_CRITERIA}} - -# 9. 파일 처리 결과 -이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. - -- 처리 결과: {{FILE_RESULT:생성|갱신|미생성}} -- 생성 경로: {{OUTPUT_PATH}} -- 참조 문서: - - {{REFERENCE_1}} - - {{REFERENCE_2}} - - {{REFERENCE_3:필요 없으면 삭제}} - -# 10. 사용자 결정 필요 항목 요약 -사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. - -## DECIDE -- {{DECIDE_ITEM_1}} -- {{DECIDE_ITEM_2:필요 없으면 삭제}} - -## CONFIRM -- {{CONFIRM_ITEM_1}} -- {{CONFIRM_ITEM_2:필요 없으면 삭제}} - -## DATA -- {{DATA_ITEM_1}} -- {{DATA_ITEM_2:필요 없으면 삭제}} - -# 11. Discovery Freeze -REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. - -## 섹션 참조 -- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) -- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) -- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) -- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) -- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) - -## Freeze 플래그 -- Handoff Decision: {{FREEZE_HANDOFF_DECISION:REQ Drafting 진행 가능|보류}} -- Handoff Rationale: {{FREEZE_HANDOFF_RATIONALE:진행 가능 또는 보류 판단 근거 1-2줄}} -- Ready for REQ Drafting: {{READY_FOR_REQ_DRAFTING:false}} -- Confirmed By: {{CONFIRMED_BY:TBD}} -- Confirmed At (KST): {{CONFIRMED_AT_KST:TBD}} - -# 12. 작성 가이드 -템플릿 사용 시의 기본 규칙을 적는다. 실제 문서를 만들 때는 이 가이드를 기준으로 플레이스홀더를 치환한다. - -- 이 파일은 템플릿이다. 실제 문서 작성 시 `{{...}}` 플레이스홀더를 구체 값으로 교체한다. -- 필요 없는 항목은 삭제하고, 부족한 항목은 같은 형식으로 추가한다. -- Open Questions는 가정으로 대체하지 말고 그대로 유지한다. -- `대체됨`은 현재 Discovery가 더 이상 기준 문서가 아닐 때 대체 Discovery 경로를 기록하고, 해당 사항이 없으면 `없음`으로 둔다. -- `후속 Discovery 참조`는 범위를 이어받는 별도 Discovery가 생성된 경우 그 Discovery 경로를 기록하고, 없으면 `없음`으로 둔다. -- Discovery Freeze는 문서 하단에 반드시 유지한다. \ No newline at end of file diff --git a/.github/templates/discovery/index.md b/.github/templates/discovery/index.md deleted file mode 100644 index 8c9f028..0000000 --- a/.github/templates/discovery/index.md +++ /dev/null @@ -1,8 +0,0 @@ -# Discovery 전체 인덱스 - - - -## Discovery 목록 - -| ID | 날짜 | 이슈명 | 문서 | 상태 | -|---|---|---|---|---| \ No newline at end of file diff --git a/.github/templates/interface-contract.md b/.github/templates/interface-contract.md deleted file mode 100644 index 9b039b9..0000000 --- a/.github/templates/interface-contract.md +++ /dev/null @@ -1,92 +0,0 @@ -# {{PROJECT_NAME}} Interface Contract - -- Baseline Mode: {{BASELINE_MODE:declared|observed|mixed}} -- Baseline Seed: {{BASELINE_SEED_PATH:.stagepilot/bootstrap/baseline.yaml}} -- Status: draft | confirmed -- Owner: {{OWNER_OR_TEAM}} -- Last Updated (KST): {{UPDATED_AT_KST}} -- Source Discovery / Batch: {{SOURCE_REF}} -- Primary Runtime: {{PRIMARY_RUNTIME:cli|api-service|web-app|worker|library|mixed|other}} - -## Project Summary -- {{PROJECT_SUMMARY:one-sentence summary of the project}} - -## Primary Domain -- {{PRIMARY_DOMAIN:short domain phrase or undecided}} - -## Purpose -- This document captures the approved external and cross-boundary interface contracts for the project. -- It is a cross-cutting reference for REQ drafting, batch design, implementation, verification, and handoff. - -## Scope -- Covers externally visible APIs, CLI surfaces, events, files, or batch I/O contracts. -- Does not duplicate full business requirements already owned by REQ documents. -- Does not replace low-level code references or generated API artifacts. - -## Interface Summary - -{{INTERFACE_SUMMARY_BULLETS:- - :: }} - -## Interface: `{{INTERFACE_NAME_1:primary interface name}}` - -- Type: {{INTERFACE_TYPE_1:http-api|cli|event|file|internal-service|other}} -- Consumers / Producers: {{INTERFACE_ACTORS_1:who calls or receives it}} -- Purpose: {{INTERFACE_PURPOSE_1:what this interface exists to do}} -- Stability: {{INTERFACE_STABILITY_1:experimental|internal|stable}} - -### Inputs - -{{INTERFACE_INPUTS_1:- :: }} - -### Outputs - -{{INTERFACE_OUTPUTS_1:- :: }} - -### Error Contract - -{{INTERFACE_ERRORS_1:- :: }} - -### Compatibility Rules - -{{INTERFACE_COMPATIBILITY_1:- }} - -## Interface: `{{INTERFACE_NAME_2:secondary interface name}}` - -- Type: {{INTERFACE_TYPE_2:http-api|cli|event|file|internal-service|other}} -- Consumers / Producers: {{INTERFACE_ACTORS_2:who calls or receives it}} -- Purpose: {{INTERFACE_PURPOSE_2:what this interface exists to do}} -- Stability: {{INTERFACE_STABILITY_2:experimental|internal|stable}} - -### Inputs - -{{INTERFACE_INPUTS_2:- :: }} - -### Outputs - -{{INTERFACE_OUTPUTS_2:- :: }} - -### Error Contract - -{{INTERFACE_ERRORS_2:- :: }} - -### Compatibility Rules - -{{INTERFACE_COMPATIBILITY_2:- }} - -## Shared Constraints - -{{INTERFACE_SHARED_CONSTRAINTS_BULLETS:- - }} - -## Current Gaps / Planned Changes - -{{INTERFACE_CURRENT_GAPS_BULLETS:- - }} - -## Update Triggers - -- Update this document when an externally visible API, event, CLI, or file contract changes. -- Update this document when a batch changes compatibility, validation, or error behavior. -- Update this document when a new external consumer or producer is introduced. - -## Change Log - -{{CHANGE_LOG_BULLETS:- - }} \ No newline at end of file diff --git a/.github/templates/project-structure.md b/.github/templates/project-structure.md deleted file mode 100644 index bfda5f0..0000000 --- a/.github/templates/project-structure.md +++ /dev/null @@ -1,64 +0,0 @@ -# {{PROJECT_NAME}} Project Structure - -- Baseline Mode: {{BASELINE_MODE:declared|observed|mixed}} -- Baseline Seed: {{BASELINE_SEED_PATH:.stagepilot/bootstrap/baseline.yaml}} -- Status: draft | confirmed -- Owner: {{OWNER_OR_TEAM}} -- Last Updated (KST): {{UPDATED_AT_KST}} -- Source Discovery / Batch: {{SOURCE_REF}} - -## Project Summary -- {{PROJECT_SUMMARY:one-sentence summary of the project}} - -## Primary Domain -- {{PRIMARY_DOMAIN:short domain phrase or undecided}} - -## Tech Stack -{{TECH_STACK_BULLETS:- -- }} - -## Primary Runtime -- Type: {{PRIMARY_RUNTIME:cli|api-service|web-app|worker|library|mixed|other}} -- Planned Entry Points: -{{PRIMARY_ENTRYPOINT_BULLETS:- :: }} - -## Purpose -- This document is the current approved repository and package structure baseline. -- It is a cross-cutting reference document for Discovery, REQ, Batch Design, Batch Verification, and implementation updates. - -## Scope -- Covers the top-level repository layout and package/module boundaries. -- Does not duplicate detailed business rules, acceptance criteria, or implementation-only function signatures. - -## Top-Level Structure - -```text -{{TOP_LEVEL_STRUCTURE_TREE:}} -``` - -## Package / Module Responsibilities - -{{MODULE_RESPONSIBILITY_BULLETS:- `` - - }} - -## Dependency Rules - -{{DEPENDENCY_RULE_BULLETS:- }} - -## Shared Boundaries - -{{SHARED_BOUNDARY_BULLETS:- }} - -## Current Gaps / Planned Changes - -{{CURRENT_GAPS_BULLETS:- }} - -## Update Triggers - -- Update this document when package boundaries change. -- Update this document when new top-level runtime or domain modules are added. -- Update this document when a batch changes the approved structure baseline. - -## Change Log - -{{CHANGE_LOG_BULLETS:- - }} diff --git a/.github/templates/releases/index.md b/.github/templates/releases/index.md deleted file mode 100644 index 1b66789..0000000 --- a/.github/templates/releases/index.md +++ /dev/null @@ -1,10 +0,0 @@ -# Release Index - -## 목적 -- 이 문서는 release 단위의 전역 인덱스다. -- 각 release는 하나 이상의 release-candidate batch를 묶고, profile(`docs-only` | `tooling` | `app-service`)에 맞는 배포와 후속 환류를 추적한다. - -## Release Register - -| REL ID | Profile | Status | Included Batch | Discovery/REQ Input | Link | -| --- | --- | --- | --- | --- | --- | diff --git a/.github/templates/releases/release.md b/.github/templates/releases/release.md deleted file mode 100644 index ce1e43c..0000000 --- a/.github/templates/releases/release.md +++ /dev/null @@ -1,92 +0,0 @@ -# {{REL_ID}}: <릴리즈 제목> - -- Status: draft | confirmed | released | feedback-captured -- Profile: docs-only | tooling | app-service -- Included Batch: - -## Scope -- Included: - - <포함 batch 또는 REQ> -- Excluded: - - <제외 범위> - -## Rollout Plan -1. <배포 순서> -2. <배포 순서> - -## Rollback Plan -- <롤백 조건과 절차> - -## Verification Checklist -- <배포 전 확인> -- <배포 후 확인> - -## Release Execution Evidence -- Release Status Transition Target: released - -### Deployment Facts -- Executed At: - - <배포 시작 시각> -- Completed At: - - <배포 완료 시각> -- Environment: - - <배포 대상 환경> -- Operator: - - <배포 수행자> -- Revision or Artifact: - - <커밋 SHA 또는 배포 artifact 식별자> -- Commands or Procedure: - - <실행 명령 또는 배포 절차 요약> - -### Profile-Specific Checks -- docs-only: - - Published or Packaged Targets: - - <문서 경로, 배포 산출물 또는 none> - - Link or Rendering Check: - - <링크 점검, 렌더링 확인 또는 none> - - Navigation or Consumer Update: - - <목차/가이드/릴리즈 노트 반영 결과 또는 none> -- tooling: - - Install or Upgrade Smoke: - - <설치/업데이트/부트스트랩 결과 또는 none> - - CLI or Script Execution: - - <명령 실행 결과 또는 none> - - Compatibility Note: - - <하위 호환성/운영자 영향 또는 none> -- app-service: - - Health Endpoint: - - <호출 결과와 확인 시각> - - Startup Log Fields: - - - - Request Log Fields: - - - - Smoke or Manual Verification: - - <배포 후 재실행한 검증과 결과> - -### Rollback Outcome -- Rollback Required: - - -- Rollback Trigger: - - <발생 시 원인, 없으면 none> -- Rollback Action: - - <수행한 조치, 없으면 none> - -### Release Decision Evidence -- Released Decision: - - -- Rationale: - - -- Recorded By: - - <기록자> -- Recorded At: - - <기록 시각> - -## Feedback Handoff -- Observation Summary: - - <운영 관찰 사실 요약 또는 없음> -- Discovery Input: - - <후속 discovery 후보> -- REQ Input: - - <후속 req 후보> -- Change Request Input: - - <기존 req 변경 후보> \ No newline at end of file diff --git a/.github/templates/runtime-flows.md b/.github/templates/runtime-flows.md deleted file mode 100644 index 90078f1..0000000 --- a/.github/templates/runtime-flows.md +++ /dev/null @@ -1,58 +0,0 @@ -# {{PROJECT_NAME}} Runtime Flows - -- Baseline Mode: {{BASELINE_MODE:declared|observed|mixed}} -- Baseline Seed: {{BASELINE_SEED_PATH:.stagepilot/bootstrap/baseline.yaml}} -- Status: draft | confirmed -- Owner: {{OWNER_OR_TEAM}} -- Last Updated (KST): {{UPDATED_AT_KST}} -- Source Discovery / Batch: {{SOURCE_REF}} -- Primary Runtime: {{PRIMARY_RUNTIME:cli|api-service|web-app|worker|library|mixed|other}} - -## Project Summary -- {{PROJECT_SUMMARY:one-sentence summary of the project}} - -## Purpose -- This document is the current approved execution-flow baseline for the project. -- It is a cross-cutting reference document for Discovery, REQ, Batch Design, Batch Verification, and implementation updates. - -## Scope -- Covers representative command and runtime flows. -- Does not duplicate full acceptance criteria or low-level implementation details. - -## Covered Entry Points - -{{PRIMARY_ENTRYPOINT_BULLETS:- :: }} - -## Shared Components - -{{SHARED_COMPONENT_BULLETS:- }} - -## Flow: `{{FLOW_NAME_1:bootstrap or first runtime flow}}` - -{{FLOW_STEPS_1:1. -2. -3. }} - -## Flow: `{{FLOW_NAME_2:secondary runtime flow or operation}}` - -{{FLOW_STEPS_2:1. -2. -3. }} - -## Flow Constraints - -{{FLOW_CONSTRAINT_BULLETS:- }} - -## Current Gaps / Planned Changes - -{{CURRENT_GAPS_BULLETS:- }} - -## Update Triggers - -- Update this document when a command entry point changes. -- Update this document when a runtime orchestration path changes. -- Update this document when a batch changes the approved flow baseline. - -## Change Log - -{{CHANGE_LOG_BULLETS:- - }} diff --git a/.github/templates/srs/index.md b/.github/templates/srs/index.md deleted file mode 100644 index 67c789c..0000000 --- a/.github/templates/srs/index.md +++ /dev/null @@ -1,87 +0,0 @@ -# Software Requirement Specification Index - -## 목적 -- 이 문서는 프로젝트의 SRS 운영 기준과 requirement 목록을 관리하는 entry point이다. -- 개별 requirement의 상세 내용은 타입별 폴더 하위 `req-XXX_*.md` 파일에서 관리한다. - -## 사용 방법 -1. 새로운 requirement가 필요하면 `Next Requirement ID`를 확인하고 `docs/srs/req-template.md`를 복사해 `docs/srs//req-XXX_.md` 파일을 생성한다. -2. 초안 작성은 `draft-req`, 승인 전환은 `confirm-req`, 변경 대응은 `change-req`, 구현 완료 전환은 `confirm-req-implemented` skill을 기준으로 수행한다. -3. 작성 또는 수정 후 누락과 모호성을 점검한다. -4. requirement 추가 또는 변경 후 `Next Requirement ID`, `Requirement Register`, `Recent Change Log Summary`를 함께 갱신한다. -5. `Proposed` requirement를 승인하려면 승인 준비 상태와 blocker를 점검한다. -6. `Approved` requirement는 구현 이슈나 작업 항목으로 연결한다. -7. 구현과 검증이 끝난 requirement는 `confirm-req-implemented` skill로 완료 전환 가능 여부를 점검한다. -8. requirement 변경 시 `change-req` skill을 기준으로 해당 REQ 파일의 `Change Log`를 먼저 갱신한 뒤 본문을 수정한다. -9. 구현 전에는 관련 REQ 파일과 최근 `Change Log`를 기준으로 변경 범위를 판단한다. - -## 상태 정의 -- Proposed: 초안 상태이며 구현 기준으로 확정되지 않음 -- Approved: 구현 기준으로 승인됨 -- Implemented: 구현과 검증이 완료됨 -- Deprecated: 더 이상 유지하지 않음 - -## Status Transition Evidence - -### Proposed -> Approved -- `Status`, `Type`, `Priority`, `Owner`, `Intent`, `Requirement`, `Acceptance Criteria`, `Impacted Area`, `Change Log`가 모두 존재해야 한다. -- `Type`은 아래 분류 도표에서 정확하게 하나로 지정되어야 한다. -- `Acceptance Criteria`는 구현 판단에 사용할 수 있을 정도로 구체적이어야 한다. -- 미해결 assumption 또는 open question이 있으면 `Notes`에 명시하고, 승인 blocker 여부를 판단해야 한다. -- 승인 전환 시 해당 REQ 파일의 `Change Log`에 status 변경 사유를 기록해야 한다. - -### Approved -> Implemented -- 각 `Acceptance Criteria`에 대해 test, manual verification, 또는 구현 결과 중 최소 1개의 evidence가 있어야 한다. -- evidence는 requirement 문서 자체 또는 연결된 구현/검증 결과에서 추적 가능해야 한다. -- 미충족 `Acceptance Criteria`가 있으면 `Implemented`로 전환하지 않는다. -- 구현 완료 전환 시 잔여 리스크와 미검증 영역이 있으면 `Change Log`에 기록해야 한다. -- 상태 전환은 `confirm-req-implemented` skill을 기준으로 수행한다. - -### Requirement Change Handling -- 기존 REQ 본문(`Requirement`, `Acceptance Criteria`, `Impacted Area`)을 수정하기 전에는 새 `Change Log` 항목을 만든다. -- `Change Log`에는 최소한 `Acceptance Criteria Delta`, `Impacted Area Delta`, `Delivery Trace Delta`, `Revalidation Impact`, `Validation Plan`이 포함되어야 한다. -- 변경이 기존 구현 또는 verification evidence를 무효화하면 `Implemented` requirement는 `Approved`로 되돌린다. -- 변경이 승인 전제 자체를 다시 검토하게 만들면 `Approved` 또는 `Implemented` requirement를 `Proposed`로 되돌릴 수 있다. -- 상태 되돌림이 발생하면 `Requirement Register`와 `Recent Change Log Summary`도 함께 갱신한다. - -## Requirement 타입 분류 도표 - -| 타입 | 정의 | 예시 | -|------|------|------| -| **Configuration** | 설정 옵션, 커스터마이징 포인트 | 설정 파라미터, 환경 변수, 옵션 항목 | -| **Data** | 데이터 구조, 저장/로드, 파일 형식, 스키마 | 설정 파일 형식, DB 스키마, API 구조 | -| **Deployment** | 배포 방식, 운영 절차, 배포 환경 | 컨테이너 배포, 스택 관리, 운영 가이드 | -| **Documentation** | 문서 작성, 가이드, 매뉴얼, 안내서 | 사용자 가이드, API 문서, 튜토리얼 | -| **Exception** | 오류 처리, 예외 복구, 재시도 전략 | 오류 복구, 검증 실패 처리, 폴백 전략 | -| **Installation** | 시스템 설치, 환경 구성, 초기화 절차 | Docker 이미지 빌드, DB 마이그레이션, 사전 조건 설정 | -| **Integration** | 외부 시스템 연동, 플러그인, 확장 | 3rd party 연동, 플러그인 인터페이스 | -| **Interface** | 사용자 상호작용 방식 (UI, API, CLI, 제어방식) | CLI 명령어, API 엔드포인트, 웹 UI 기능 | -| **Migration** | 데이터 이전, 버전 업그레이드 | 데이터 마이그레이션, 버전 호환성 | -| **Non-Functional** | 성능, 보안, 확장성, 호환성, 신뢰성 | 성능 목표, 보안 정책, 확장성 | -| **Testing** | 테스트 전략, QA 요구사항, 검증 방식 | 테스트 커버리지, 회귀 테스트, 수락 기준 | - -## Requirement 작성 규칙 -- 각 requirement는 고유 ID를 가진다. 예: `REQ-001` -- 다음 신규 requirement 번호의 source of truth는 이 문서의 `Next Requirement ID` 섹션이다. -- 새 requirement를 추가하거나 ID 충돌이 발생할 수 있는 변경을 했으면 `Next Requirement ID`를 함께 갱신한다. -- 각 requirement는 `Type`을 명시해야 한다. 위 분류 도표 중 정확하게 하나로 선택하며, `docs/srs//` 폴더명도 같은 문자열을 사용한다. -- 각 requirement는 `Intent`, `Requirement`, `Acceptance Criteria`, `Impacted Area`를 반드시 포함한다. -- 모호한 표현("개선한다", "적절히 처리한다") 대신 관찰 가능하고 test 가능한 문장을 사용한다. -- requirement 변경 사항은 해당 REQ 파일의 `Change Log`에 기록한다. -- requirement 변경 시 영향 받는 batch/release와 재검증 필요 여부를 `Change Log`에 남긴다. -- `Recent Change Log Summary`는 최신 5건만 유지한다. -- status 전환은 `Status Transition Evidence` 기준을 따른다. - -## Next Requirement ID - -- Current: `REQ-001` -- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - -## Requirement Register - -| ID | Title | Type | Status | Priority | Owner | Link | -| --- | --- | --- | --- | --- | --- | --- | - -## Recent Change Log Summary - -- 없음 \ No newline at end of file diff --git a/.github/templates/srs/req-template.md b/.github/templates/srs/req-template.md deleted file mode 100644 index 2fd39c7..0000000 --- a/.github/templates/srs/req-template.md +++ /dev/null @@ -1,67 +0,0 @@ -# req-XXX: <요구사항 제목> - -- Status: Proposed | Approved | Implemented | Deprecated -- Type: Configuration | Data | Deployment | Documentation | Exception | Installation | Integration | Interface | Migration | Non-Functional | Testing -- Priority: High | Medium | Low -- Owner: <담당자 또는 팀> - -## Intent -- <이 요구사항이 필요한 이유와 달성하려는 목표를 1~3문장으로 작성> - -## Requirement -- <시스템이 반드시 수행해야 하는 동작을 단정형 문장으로 작성> - -## Acceptance Criteria -1. Given <선행 조건>, When <행동>, Then <기대 결과> -2. Given <선행 조건>, When <행동>, Then <기대 결과> - -## Impacted Area -- Docs: <영향 받는 문서 경로> -- Modules: <영향 받는 모듈/컴포넌트> -- Tests: <추가/수정해야 하는 테스트> - -## Notes -- <가정, 제약, 미해결 이슈> - -## Change Log - -### CHG-YYYYMMDD-01 -- Date: YYYY-MM-DD -- Author: <작성자> - -#### Change Summary -- <무엇이 변경되었는지> - -#### Intent -- <왜 변경했는지> - -#### Acceptance Criteria Delta -- Added: - - <추가된 기준> -- Updated: - - <변경된 기준> -- Removed: - - <삭제된 기준> - -#### Impacted Area Delta -- Docs: - - <문서 영향> -- Modules: - - <코드 영향> -- Tests: - - <테스트 영향> - -#### Delivery Trace Delta -- Batches: - - <영향 받는 batch 또는 none> -- Releases: - - <영향 받는 release 또는 none> - -#### Revalidation Impact -- Reverification Needed: yes | no -- Existing Implementation Invalidated: yes | no -- Status Recommendation: keep-current | revert-to-approved | revert-to-proposed -- Follow-up Action: <필요한 재검증, 재배치, 후속 작업> - -#### Validation Plan -- <검증 방법과 완료 조건> \ No newline at end of file diff --git a/.stagepilot/bootstrap/baseline.yaml b/.stagepilot/bootstrap/baseline.yaml deleted file mode 100644 index e08024f..0000000 --- a/.stagepilot/bootstrap/baseline.yaml +++ /dev/null @@ -1,161 +0,0 @@ -schema_version: 1 -capture_mode: observed - -project: - repository_name: 'portfolio-hub' - display_name: 'Portfolio Hub' - summary: 'PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI.' - primary_domain: 'portfolio management and trading ledger services' - -stack: - tech_stack: - - 'Python 3.12' - - 'FastAPI' - - 'Typer' - - 'Pydantic Settings' - - 'Psycopg' - - 'Alembic' - - 'PostgreSQL/TimescaleDB' - - 'Docker Compose' - -runtime: - primary_runtime: 'mixed' - primary_entrypoints: - - name: 'uvicorn app.main:app' - purpose: 'serve the authenticated HTTP API, OpenAPI docs, and startup bootstrap hooks' - - name: 'pfhub' - purpose: 'run operational CLI commands for health, users, portfolios, strategies, and service tokens' - - name: 'alembic upgrade head' - purpose: 'apply ordered database schema migrations for persistent environments' - -interfaces: - contracts: - - name: 'Primary HTTP API' - type: 'http-api' - actors: 'authenticated clients, operators, and downstream services' - purpose: 'manage authentication, portfolios, strategy sets, and users over JSON HTTP endpoints' - stability: 'internal' - inputs: - - 'Authorization: Bearer headers for protected routes' - - 'JSON request bodies for login, token issuance, user CRUD, strategy-set CRUD, and portfolio mutations' - - 'Route and query parameters for portfolio IDs, filters, comments, tags, and strategy lookups' - outputs: - - 'JSON responses defined by app/schemas/* response models' - - 'HTTP 201, 200, and 204 status codes matching create, read/update, and delete flows' - - 'GET /health readiness payload of {"status": "ok"}' - errors: - - '401 Unauthorized for invalid credentials or invalid tokens' - - '403 Forbidden for insufficient permission or token policy violations' - - '404 Not Found for missing users, tokens, portfolios, comments, tags, or strategy sets' - - '422 Unprocessable Entity for request validation failures' - compatibility_rules: - - 'Route prefixes /health, /auth, /users, /strategy-sets, and /portfolios form the current internal surface' - - 'Permission semantics read, trade-write, and admin must stay aligned across guards and token metadata' - - 'Breaking route or schema changes require REQ and Batch updates before release' - - name: 'PortfolioHub CLI' - type: 'cli' - actors: 'operators and automation' - purpose: 'provide command-line access to health checks, database setup, users, portfolios, strategies, and service tokens' - stability: 'internal' - inputs: - - 'pfhub subcommands and options such as user, portfolio, strategy, service-token, init-db, and migrate-db' - - '--format table|json and --force flags for automation-safe execution' - - 'PORTFOLIOHUB_* and TIMESCALEDB_* environment variables loaded through app/core/config.py' - outputs: - - 'Rich console tables or JSON payloads for read operations' - - 'One-time token reveal plus masked token metadata on issuance flows' - - 'Exit status and console error output for validation or operational failures' - errors: - - 'Typer argument and option validation failures' - - 'confirmation refusal for write commands without --force' - - 'database connectivity or disabled-persistence failures when DB-backed flows are invoked' - compatibility_rules: - - 'Command group names and flags should remain stable for internal automation' - - 'Write commands require explicit confirmation unless --force is supplied' - - 'JSON output should remain machine-readable across minor command additions' - shared_constraints: - - 'Token permission levels gate both HTTP and CLI mutations consistently.' - - 'DB-backed endpoints and commands require PORTFOLIOHUB_DB_ENABLED=true plus valid TimescaleDB configuration.' - - 'Secrets and token values are only shown once at issuance time and are masked afterwards.' - current_gaps: - - 'External consumer versioning policy is still implicit and not published as a separate artifact.' - - 'Redis and Loki are configured in settings but do not yet appear as first-class runtime contracts.' - - 'Ownership-scoped authorization and service-token policy may continue to evolve in later batches.' - -structure: - planned_top_level_areas: - - path: '.github/' - responsibility: 'repository automation, StagePilot skills, templates, and bootstrap scripts' - - path: 'app/' - responsibility: 'FastAPI routes, Typer CLI, domain logic, repositories, and schemas' - - path: 'docs/' - responsibility: 'active SDLC documents and shared cross-cutting baselines' - - path: 'migrations/' - responsibility: 'Alembic environment and ordered schema revisions' - - path: 'tests/' - responsibility: 'API, CLI, service, and persistence regression coverage' - -data_model: - persistence_backend: 'PostgreSQL or TimescaleDB via psycopg with Alembic-managed migrations' - entities: - - name: 'Portfolio' - purpose: 'represent an owned investment portfolio with balances, strategy references, tags, comments, and trading history' - source_of_truth: 'portfolios table with dependent portfolio_ledger, portfolio_comments, and portfolio_tags tables' - lifecycle: 'created -> updated by trade and cash operations -> reset or deleted' - key_fields: - - 'portfolio_id :: text :: stable portfolio primary key' - - 'symbol :: text :: traded asset symbol for the portfolio' - - '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' - relationships: - - 'User :: owner_user_id links a portfolio to its owning user when ownership is assigned' - - 'StrategySet :: active_strategy_id and avabile_strategies connect reusable strategy definitions' - - 'LedgerEntry, PortfolioComment, PortfolioTag :: child records are keyed by portfolio_id' - state_rules: - - 'Portfolio names are required and backfilled from symbol when older rows are migrated.' - - 'Balance-changing commands must update snapshot fields and append a ledger row together.' - - 'Tags are unique per portfolio and comments are ordered by created_at descending for retrieval.' - - name: 'User' - purpose: 'represent an authenticated principal that can own portfolios and receive client or service tokens' - source_of_truth: 'users table with dependent auth_tokens rows' - lifecycle: 'bootstrapped or created -> updated -> deactivated or deleted' - key_fields: - - 'id :: text :: stable user primary key' - - 'username :: text :: unique login and CLI lookup key' - - 'email :: text|null :: unique contact identity when present' - - 'permission :: text :: baseline role of read, trade-write, or admin' - - 'is_active :: boolean :: authentication and authorization gate' - - 'last_login_at :: timestamptz|null :: latest successful login timestamp' - relationships: - - 'AuthToken :: user_id links issued client and 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' - state_rules: - - 'username and email remain unique identifiers when present.' - - 'Bootstrap admin creation only runs when the users table is empty and DB mode is enabled.' - - 'Permission changes affect both API guards and CLI mutation capabilities.' - consistency_rules: - - 'auth_tokens.user_id and portfolios.owner_user_id should resolve to existing users before protected flows run.' - - 'strategy_sets.name plus config, strategy_sets.nick, and service token names have uniqueness constraints.' - - 'Portfolio snapshot fields and portfolio_ledger history must stay synchronized across mutations.' - persistence_notes: - - 'Alembic revisions in migrations/versions/ are the ordered schema history for persistent environments.' - - 'app/repositories/postgres.py includes ensure_schema() for bootstrap safety, but long-term schema evolution should run through Alembic.' - - 'Comments, tags, auth tokens, and ledger rows are normalized into dedicated tables keyed to users or portfolios.' - current_gaps: - - 'Production-like migration verification against a real PostgreSQL or TimescaleDB instance is still pending.' - - 'Retention and archival rules are not yet documented for ledger, comment, or revoked-token records.' - - 'Future authorization changes may require stronger ownership constraints in schema or query paths.' - -notes: - known_unknowns: - - 'Real database validation against a PostgreSQL or TimescaleDB deployment is not yet captured in active docs.' - - 'Redis and Loki integration boundaries are configured but not yet documented as baseline runtime flows.' - - 'The first real Discovery should define the next product or operational change after bootstrap.' - -metadata: - captured_at_kst: '2026-04-30 22:13' - source: 'bootstrap-baseline' - updated_from: 'repo-observation' \ No newline at end of file diff --git a/bootstrap/source-allowlist.txt b/bootstrap/source-allowlist.txt deleted file mode 100644 index a8a25c1..0000000 --- a/bootstrap/source-allowlist.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Relative paths from selected source .github directory. -# Files -copilot-instructions.md - -# Directories -agents/ -instructions/ -prompts/ -runbooks/ -scripts/ -skills/ -templates/ \ No newline at end of file diff --git a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/design.md b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/design.md deleted file mode 100644 index e039cf2..0000000 --- a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/design.md +++ /dev/null @@ -1,97 +0,0 @@ -# BAT-001 Design - -- Status: draft -- Batch: BAT-001 - -## Architecture Summary -- `assets` 테이블을 신규 도입하여 "자산(종목) 카탈로그"와 "포지션(포트폴리오)" 개념을 DB 레벨에서 분리한다. -- 기존 `portfolios` 테이블은 `symbol`/`market` 직접 보유에서 `asset_id FK` 참조 구조로 전환한다. -- 기존 운영 데이터는 `ARRAY_AGG(DISTINCT market)` 집약 규칙으로 `assets` catalog로 이전하고, 각 portfolio 행에 `asset_id`를 역으로 연결한 뒤 레거시 컬럼을 제거한다. -- 모든 DB 변경은 Alembic으로만 관리하며 `ensure_schema()`는 사용하지 않는다. - -## Alembic Revision 전략 - -### 결정: 2-revision 분리 (rev-A → rev-B) - -**rev-A** (REQ-009 전용): -- `assets` 테이블 CREATE TABLE -- `idx_assets_markets_gin` GIN 인덱스 CREATE - -**rev-B** (REQ-011 + REQ-014 결합, 단일 트랜잭션): -``` -BEGIN; --- Step 1: 사전 검증 (symbol 수 파악) -SELECT COUNT(DISTINCT symbol) FROM portfolios; - --- Step 2: assets catalog 채우기 (ARRAY_AGG(DISTINCT market), NULL → '{}') -INSERT INTO assets (symbol, markets) -SELECT symbol, ARRAY_AGG(DISTINCT market) FILTER (WHERE market IS NOT NULL) -FROM portfolios -GROUP BY symbol -ON CONFLICT (symbol) DO NOTHING; - --- Step 3: portfolios.asset_id 컬럼 추가 (NULL 허용, 임시) -ALTER TABLE portfolios ADD COLUMN asset_id UUID; - --- Step 4: asset_id 역연결 -UPDATE portfolios p SET asset_id = a.asset_id -FROM assets a WHERE a.symbol = p.symbol; - --- Step 5: NOT NULL 제약 + FK 추가 -ALTER TABLE portfolios ALTER COLUMN asset_id SET NOT NULL; -ALTER TABLE portfolios ADD CONSTRAINT fk_portfolios_asset_id - FOREIGN KEY (asset_id) REFERENCES assets(asset_id) ON DELETE RESTRICT; - --- Step 6: 레거시 컬럼 제거 -ALTER TABLE portfolios DROP COLUMN symbol; -ALTER TABLE portfolios DROP COLUMN market; -COMMIT; -``` - -**이유:** rev-A와 rev-B를 분리하면 rev-A만 단독 롤백 가능. rev-B 내부는 단일 트랜잭션으로 원자성 보장 (REQ-014 NFR-1). - -## Changed Areas -- Docs: - - `docs/data-model.md` — REQ-015에서 처리 (이 batch 범위 밖) -- Modules: - - `migrations/versions/` — rev-A, rev-B 신규 revision 파일 2개 - - `app/domain/models.py` — `MarketCode` Enum, `Asset` 모델 추가; `Portfolio` 모델 symbol/market 제거, asset_id 추가 - - `app/repositories/portfolio_repository.py` — asset CRUD Protocol 메서드 선언 추가 - - `app/repositories/postgres.py` — asset CRUD 구현 추가; portfolios 쿼리 assets JOIN 반영 - - `app/api/portfolios.py` — symbol/market 참조 제거 - - `app/api/v1_assets.py` — symbol/market 참조 제거 (list_assets 전면 재작성은 REQ-013 범위이므로 최소 수정) - - `app/cli/commands/portfolio.py` — 출력에서 symbol/market 제거 - - `app/schemas/` — PortfolioListItem 등 symbol/market 필드 제거 -- Interfaces: - - `portfolio_repository.py` Protocol에 신규 asset CRUD 메서드 추가 - -## Key Decisions - -1. **rev-B에서 `portfolios.asset_id`를 NULL 허용으로 먼저 추가 후 데이터 채운 뒤 NOT NULL 제약 적용** - - 이유: `ADD COLUMN ... NOT NULL` 단일 문장은 기존 행에 DEFAULT 없으면 오류 → 2단계 처리 필수 - - 구현: `ADD COLUMN asset_id UUID` → UPDATE → `SET NOT NULL` - -2. **REQ-011 schema 변경과 REQ-014 데이터 이전을 rev-B 단일 revision 내 단일 트랜잭션으로 결합** - - 이유: 데이터 이전 없이 symbol/market DROP이 불가하고, 두 revision으로 분리하면 중간 상태(asset_id NULL + symbol 잔존)가 운영 DB에 커밋될 위험 - - 트레이드오프: revision이 크고 복잡해지지만 롤백 안전성 확보 - -3. **`app/api/v1_assets.py`는 symbol/market 참조 최소 제거만 수행 (list_assets 재작성은 REQ-013 범위)** - - 이유: 이 batch는 DB/도메인 레이어 변경에 집중; list_assets 로직 전면 재작성은 BAT-002에서 처리 - - 임시 조치: portfolios JOIN assets 쿼리로 symbol을 asset.symbol로 대체, market 단수 필드 제거 - -4. **`MarketCode` Enum 검증은 API 레이어(이 batch에서는 repository 구현 내)에서만 수행, DB CHECK constraint 없음** - - REQ-009 명세 준수: app-layer Enum, DB CHECK constraint 미사용 - -## Edge Cases -- `portfolios.market IS NULL`인 기존 행: `ARRAY_AGG(DISTINCT market) FILTER (WHERE market IS NOT NULL)` → 빈 배열 `'{}'` 저장 (REQ-014 Requirement 명시) -- 동일 symbol의 여러 portfolio 행: `GROUP BY symbol`로 단일 assets 행 생성, `ARRAY_AGG(DISTINCT market)` 집약 (REQ-014 R-1, A-1) -- rev-B rollback 시: `portfolios.symbol`, `portfolios.market`이 복원되고 `asset_id`와 `assets` 테이블 변경이 모두 되돌아감 (단일 트랜잭션 보장) -- symbol/market 참조 코드 누락: `grep -rn "\.symbol\|\.market\|p\.symbol\|p\.market" app/` 전수 조사 후 수정 — AttributeError로 런타임에서 즉시 탐지 가능 - -## Architecture Impact -- project-structure.md: asset CRUD Protocol 추가 (domain layer 확장) — REQ-015에서 문서화 -- runtime-flows.md: migration flow 신규 추가 — REQ-015에서 문서화 - -## Reference Doc Update Plan -- project-structure.md: REQ-015(BAT-002)에서 처리 -- runtime-flows.md: REQ-015(BAT-002)에서 처리 diff --git a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/implementation.md b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/implementation.md deleted file mode 100644 index 55d7da9..0000000 --- a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/implementation.md +++ /dev/null @@ -1,62 +0,0 @@ -# BAT-001 Implementation - -- Status: verified -- Batch: BAT-001 - -## Plan Summary -- 이 batch 구현에서 실제 변경할 범위: - 1. `app/domain/models.py` — MarketCode Enum, Asset 모델 추가; Portfolio 모델 symbol/market 제거, asset_id 추가 - 2. `app/repositories/portfolio_repository.py` — asset CRUD Protocol 선언 추가 - 3. `app/repositories/postgres.py` — asset CRUD SQL 구현; portfolios JOIN assets 쿼리 반영 - 4. Alembic rev-A — assets 테이블 생성 + GIN 인덱스 - 5. Alembic rev-B — portfolios 데이터 이전 + asset_id FK 추가 + symbol/market 컬럼 제거 (단일 트랜잭션) - 6. `app/api/portfolios.py`, `app/api/v1_assets.py`, `app/cli/commands/portfolio.py`, `app/schemas/` — symbol/market 참조 제거 - 7. `tests/test_asset_catalog.py` 신규 — REQ-009 AC-1~5, REQ-011 AC-1~5, REQ-014 AC-1~6 - -## Changed Files -- `app/domain/models.py` -- `app/repositories/portfolio_repository.py` -- `app/repositories/postgres.py` -- `migrations/versions/_create_assets_table.py` (신규) -- `migrations/versions/_portfolios_asset_migration.py` (신규) -- `app/api/portfolios.py` -- `app/api/v1_assets.py` -- `app/cli/commands/portfolio.py` -- `app/schemas/` (영향 파일 구현 단계에서 확정) -- `tests/test_asset_catalog.py` (신규) - -## Changed Files (확정) -- `app/domain/models.py` — MarketCode Enum, Asset dataclass 추가; Portfolio.market 제거 → asset_id 추가 -- `app/repositories/portfolio_repository.py` — create_portfolio symbol→asset_id; asset CRUD Protocol 선언 5종 -- `app/repositories/postgres.py` — _SELECT_PORTFOLIO JOIN assets; save/reset/adjust_buffer/transfer_buffer RETURNING 제거; create_portfolio/update_portfolio 수정; asset CRUD 5종 구현; get_or_create asset 조회 로직 추가 -- `migrations/versions/20260519_0017_create_assets_table.py` (신규) — assets 테이블 + GIN 인덱스 (rev-A) -- `migrations/versions/20260519_0018_portfolios_asset_migration.py` (신규) — 데이터 이전 + asset_id FK + symbol/market DROP (rev-B) -- `app/schemas/v1.py` — AssetItem.market 제거 -- `app/api/v1_assets.py` — market=p.market 제거 -- `app/schemas/portfolio.py` — PortfolioBase.symbol 제거; CreatePortfolioRequest.asset_id 추가; UpdatePortfolioRequest.symbol 제거 -- `app/services/portfolio_service.py` — create_portfolio symbol→asset_id -- `app/cli/commands/portfolio.py` — create --symbol→--asset-id; update --symbol 제거 -- `tests/test_asset_catalog.py` (신규) — REQ-009 AC-4,5 + REQ-011 AC-4,5 + REQ-014 migration 파일 구조 검증 -- `tests/test_cli.py` — FakeRepo create_portfolio symbol→asset_id; _portfolio() name 공백 제거; 테스트 픽스처 업데이트 -- `tests/test_portfolio_uuid.py` — FakeRepo create_portfolio symbol→asset_id; --symbol→--asset-id 전환 -- `tests/test_v1.py` — market 필드 어서션 제거 -- `tests/test_portfolio_ownership.py` — POST body symbol→asset_id -- `tests/test_daemon_assignment.py` — Portfolio() market= 제거 - -## Execution Log -1. app/domain/models.py: MarketCode Enum, Asset 모델 추가; Portfolio market→asset_id 필드 교체 -2. app/repositories/portfolio_repository.py: create_portfolio 시그니처 변경; asset CRUD Protocol 5종 선언 -3. app/repositories/postgres.py: JOIN 쿼리, RETURNING 제거 패턴, asset CRUD 5종 구현, get_or_create symbol→asset 조회 -4. Alembic rev-A(0017): assets 테이블 + GIN 인덱스 생성 -5. Alembic rev-B(0018): 데이터 이전 → asset_id FK → symbol/market DROP (단일 트랜잭션 내 idempotent 가드) -6. 스키마/API/CLI 레이어: symbol/market 참조 전수 제거 -7. 기존 테스트 suite 17개 실패 수정: 공백 포함 이름, --symbol→--asset-id, market 어서션 제거, request body 업데이트 -8. test_asset_catalog.py 신규 작성: 20개 테스트 - -## Validation -- pytest 162 passed (tests/test_asset_catalog.py 20, tests/test_cli.py 35, tests/test_portfolio_uuid.py 14, tests/test_v1.py 21, tests/test_portfolio_ownership.py 4, 나머지 기존 suite) -- 실행 명령: `.venv/bin/python -m pytest --tb=short` - -## Remaining Risks -- DB-integration ACs(REQ-009 AC-1,2,3; REQ-011 AC-1,2,3; REQ-014 AC-1~6)는 실제 PostgreSQL 연결 없이 검증 불가 — verification.md에 통합 테스트 항목으로 기록 -- R-4(운영 환경 대용량 lock): 로컬 테스트 범위 밖; 운영 배포 시 maintenance window 고려 필요 diff --git a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/index.md b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/index.md deleted file mode 100644 index 670197f..0000000 --- a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/index.md +++ /dev/null @@ -1,46 +0,0 @@ -# BAT-001: asset catalog DB 기반 도입 - -- Status: released -- Profile: standard -- Source Discovery: dcy-001 -- Included REQ: REQ-009, REQ-011, REQ-014 -- Excluded REQ: REQ-010, REQ-012, REQ-013, REQ-015 (→ BAT-002 예정) - -## Summary -- dcy-001의 핵심 DB 변경을 단일 delivery 단위로 묶는다. -- `assets` catalog 테이블을 신규 도입(REQ-009)하고, `portfolios` 테이블을 `asset_id FK` 참조 구조로 재설계하며 `symbol`/`market` 컬럼을 제거(REQ-011)한다. -- 기존 운영 데이터를 손실 없이 `assets` catalog로 이전하고 `portfolios.asset_id`를 채운 후 레거시 컬럼을 삭제하는 데이터 마이그레이션(REQ-014)을 원자적 Alembic revision으로 완성한다. -- 이 batch 완료 후 admin CRUD API/CLI(REQ-010), portfolio 생성 검증(REQ-012), GET /v1/assets 분기 재작성(REQ-013) 착수가 가능해진다. - -## Scope -- In Scope: - - `assets` 테이블 Alembic revision (asset_id UUID PK, symbol UNIQUE, markets TEXT[], GIN index) - - `MarketCode` Enum(KRX, NXT, SOR, NASDAQ, NYSE, UPBIT, BITTHUMB) — `app/domain/models.py` - - `Asset` 도메인 모델 — `app/domain/models.py` - - asset CRUD Protocol 선언 — `app/repositories/portfolio_repository.py` - - asset CRUD Postgres 구현 — `app/repositories/postgres.py` - - `portfolios.asset_id UUID NOT NULL FK REFERENCES assets(asset_id) ON DELETE RESTRICT` 추가 Alembic revision - - `portfolios.symbol`, `portfolios.market` 컬럼 제거 Alembic revision - - `portfolios` 기존 데이터를 `assets` catalog로 이전하는 데이터 마이그레이션 Alembic revision (ARRAY_AGG(DISTINCT market), 단일 트랜잭션) - - `Portfolio` 도메인 모델 symbol/market 제거, asset_id 추가 — `app/domain/models.py` - - `app/repositories/postgres.py` portfolios 쿼리 — assets JOIN 반영, symbol/market 참조 제거 - - symbol/market 직접 참조 모듈 전수 수정 (app/api, app/cli, app/schemas) - - `tests/test_asset_catalog.py` 신규 — REQ-009 AC-1~5, REQ-011 AC-1~5, REQ-014 AC-1~6 -- Out of Scope: - - admin asset CRUD HTTP API/CLI (→ REQ-010, BAT-002) - - pfhub portfolio create catalog 검증 (→ REQ-012, BAT-002) - - GET /v1/assets token-type 분기 재작성 (→ REQ-013, BAT-002) - - baseline 문서 갱신 (→ REQ-015, BAT-002) - - tag 모델 관련 모든 변경 (→ dcy-002 batches) - -## Documents -- Planning: [planning.md](./planning.md) -- Design: [design.md](./design.md) -- Implementation: [implementation.md](./implementation.md) -- Verification: [verification.md](./verification.md) - -## Notes -- R-2(High): 구현 착수 전 `grep -rn "\.symbol\|\.market" app/` 전수 조사 필수 — 6개 이상 모듈에 참조 잔존 가능성 -- REQ-011 schema 변경과 REQ-014 데이터 이전은 같은 Alembic revision 또는 순서 보장된 revision 시퀀스로 처리해야 한다 (REQ-014 Notes) -- 운영 데이터 migration이 포함되므로 로컬 DB에서 사전 dry-run 검증 권장 -- dcy-002 REQ-002, REQ-003는 이 batch 완료 후에 착수 가능 (portfolios 재설계 선행 필요) diff --git a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/planning.md b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/planning.md deleted file mode 100644 index 2f73cbd..0000000 --- a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/planning.md +++ /dev/null @@ -1,62 +0,0 @@ -# BAT-001 Planning - -- Status: verified -- Batch: BAT-001 -- Profile: standard - -## Included REQ -- REQ-009: assets 테이블 스키마 도입 (Data, High) -- REQ-011: portfolios 테이블 asset_id FK 추가 및 symbol/market 제거 (Data, High) -- REQ-014: portfolios 데이터 migration (Migration, High) - -## Delivery Plan - -### 단계 1 — REQ-009: assets 테이블 및 도메인 레이어 -1. `app/domain/models.py`에 `MarketCode` Enum(KRX, NXT, SOR, NASDAQ, NYSE, UPBIT, BITTHUMB) 추가 -2. `app/domain/models.py`에 `Asset` 도메인 모델(asset_id, symbol, markets, display_name, status, created_at, updated_at) 추가 -3. `app/repositories/portfolio_repository.py` Protocol에 asset CRUD 메서드 선언(get_asset_by_id, get_asset_by_symbol, list_assets, insert_asset, update_asset) -4. `app/repositories/postgres.py`에 asset CRUD SQL 구현 추가 -5. Alembic revision (rev-A): `assets` 테이블 생성 + `idx_assets_markets_gin` GIN 인덱스 생성 -6. `tests/test_asset_catalog.py` 신규 — REQ-009 AC-1~5 테스트 - -### 단계 2 — REQ-011 + REQ-014: portfolios 재설계 + 데이터 이전 (결합 revision) -1. 구현 전 `grep -rn "\.symbol\|\.market" app/` 실행 — 수정 대상 모듈 전수 파악 (R-2) -2. Alembic revision (rev-B): 아래를 단일 트랜잭션으로 처리 - - 사전 검증 쿼리: `SELECT symbol, ARRAY_AGG(DISTINCT market) FROM portfolios GROUP BY symbol` 실행 - - `assets` catalog에 기존 portfolios 데이터 이전 (symbol 기준 DISTINCT, ARRAY_AGG(DISTINCT market)) - - `portfolios.asset_id UUID NOT NULL` 컬럼 추가 + FK REFERENCES assets(asset_id) ON DELETE RESTRICT - - 각 portfolio 행에 asset_id 채우기 - - `portfolios.symbol`, `portfolios.market` 컬럼 제거 -3. `app/domain/models.py`의 `Portfolio` 모델 — symbol/market 제거, asset_id 추가 -4. `app/repositories/postgres.py` — portfolios 쿼리를 `assets JOIN portfolios` 형태로 변경 -5. symbol/market 직접 참조 모듈 수정: - - `app/api/portfolios.py` - - `app/api/v1_assets.py` - - `app/cli/commands/portfolio.py` - - `app/schemas/` (PortfolioListItem 등) -6. `tests/test_asset_catalog.py` — REQ-011 AC-1~5, REQ-014 AC-1~6 추가 - -### 단계 3 — 전체 테스트 suite 회귀 검증 -- 기존 테스트 suite 실행 — symbol/market 참조 갱신 후 regression 없음 확인 - -## Dependencies -- Internal: - - rev-A(REQ-009 assets 테이블)가 rev-B(REQ-011+014 portfolios 변경) 선행 조건 — 같은 batch 내 순서 보장 - - REQ-011 schema 변경과 REQ-014 데이터 이전은 동일 Alembic revision(rev-B)으로 결합하거나 rev-B-1(데이터 이전) → rev-B-2(symbol/market DROP) 순서로 분리 — design 단계에서 결정 -- External: - - 없음 (로컬 Postgres, 기존 인프라 변경 없음) - -## Design Gate -- Design Required Before Implementation: yes -- Reason: (1) Alembic revision 분리 전략(단일 revision vs 2-step) 결정 필요 — 운영 데이터 롤백 안전성 영향; (2) rev-B에서 `portfolios.asset_id NOT NULL` 추가 시 NULL 허용 단계 없이 바로 NOT NULL 가능한지 마이그레이션 순서 검증 필요; (3) symbol/market 참조 grep 결과에 따라 수정 범위가 변동될 수 있음 - -## Milestones -1. REQ-009: assets 테이블 Alembic revision + domain model + repository layer 완성 + AC-1~5 통과 -2. REQ-011 + REQ-014: portfolios 재설계 Alembic revision(rev-B) + 도메인 코드 전수 수정 완성 + AC-1~6 통과 -3. 기존 테스트 suite 전체 regression 통과 - -## Risks -- R-2(High): `grep -rn "\.symbol\|\.market" app/` 결과 범위 예측 불가 — 수정 누락 시 런타임 AttributeError 발생. 대응: 구현 착수 전 grep 결과를 planning에 기록 후 수정 체크리스트 작성 -- R-1(High): 동일 symbol 여러 포트폴리오 행의 market 값 충돌 — `ARRAY_AGG(DISTINCT market)` 집약 규칙으로 처리. 대응: migration dry-run 후 asset 수 = DISTINCT symbol 수 일치 확인 (REQ-014 AC-2) -- R-3(Medium): `portfolios.asset_id NOT NULL` 추가 시 기존 행에 NULL 발생 — 데이터 이전이 완료된 후에 NOT NULL 제약 적용 순서 보장 필요. 대응: revision 내 순서 검증 (데이터 먼저, NOT NULL 제약 나중) -- R-4(Low): rev-B 단일 트랜잭션에서 대용량 portfolios 테이블 lock — 로컬 테스트 환경에서는 무시 가능; 운영 환경 고려 시 design 단계에서 검토 diff --git a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/verification.md b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/verification.md deleted file mode 100644 index e3b933b..0000000 --- a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/verification.md +++ /dev/null @@ -1,45 +0,0 @@ -# BAT-001 Verification - -- Status: verified -- Batch: BAT-001 - -## Acceptance Mapping - -- REQ-009: - - AC-1: assets 테이블 스키마 컬럼 존재 확인 → **[DB-integration]** 실제 PostgreSQL 연결 필요. rev-A migration 파일(`20260519_0017`) 구조 검증: `test_migration_rev_a_creates_assets_table` PASSED - - AC-2: symbol UNIQUE constraint violation → **[DB-integration]** 실제 PostgreSQL 연결 필요. rev-A DDL에 `UniqueConstraint("symbol")` 포함 확인됨 - - AC-3: idx_assets_markets_gin GIN 인덱스 존재 → **[DB-integration]** 실제 PostgreSQL 연결 필요. rev-A DDL에 `create_index(...postgresql_using="gin")` 포함 확인됨 - - AC-4: 허용되지 않은 MarketCode 거부 → `test_market_code_rejects_unknown_code` PASSED, `test_market_code_rejects_empty_string` PASSED - - AC-5: 소문자 입력 대문자 정규화 통과 → `test_market_code_normalizes_lowercase_krx` PASSED, `test_market_code_normalizes_lowercase_nasdaq` PASSED - -- REQ-011: - - AC-1: portfolios 스키마 — asset_id FK 존재, symbol/market 없음 → **[DB-integration]** rev-B migration 파일(`20260519_0018`) 구조 검증: `test_migration_rev_b_drops_symbol_and_market_columns` PASSED, `test_migration_rev_b_has_data_migration_insert` PASSED - - AC-2: 존재하지 않는 asset_id FK constraint violation → **[DB-integration]** rev-B DDL에 `create_foreign_key(...ondelete="RESTRICT")` 포함 확인됨 - - AC-3: ON DELETE RESTRICT 동작 → **[DB-integration]** rev-B `ondelete="RESTRICT"` 확인됨 - - AC-4: 기존 portfolio 조회 API/CLI symbol/market 참조 오류 없음 → 기존 전체 test suite 162 passed (regression 없음). `test_portfolio_model_has_asset_id_no_market` PASSED, `test_asset_item_schema_has_no_market_field` PASSED, `test_create_portfolio_request_requires_asset_id_not_symbol` PASSED - - AC-5: 동일 user, 동일 asset_id 복수 portfolio 생성 가능 → `test_same_user_can_create_two_portfolios_for_same_asset_id` PASSED - -- REQ-014: - - AC-1: migration 전후 portfolios 행 수 일치 → **[DB-integration]** rev-B DATA migration INSERT/UPDATE 로직 확인됨 - - AC-2: assets 행 수 = DISTINCT symbol 수 → **[DB-integration]** rev-B `GROUP BY symbol` INSERT 확인됨 - - AC-3: portfolio.asset_id JOIN assets.symbol 일치 → **[DB-integration]** rev-B UPDATE portfolios SET asset_id = a.asset_id FROM assets WHERE a.symbol = p.symbol 확인됨 - - AC-4: assets.markets에 동일 symbol의 모든 market 값 포함 → **[DB-integration]** rev-B `ARRAY_AGG(DISTINCT market)` 확인: `test_migration_rev_b_has_data_migration_insert` PASSED - - AC-5: rollback 시 기존 portfolios 데이터 보존 → **[DB-integration]** rev-B downgrade() 구현 (symbol/market 컬럼 복원 + asset_id DROP) 확인됨 - - AC-6: migration 완료 후 portfolios에 symbol/market 컬럼 없음 → `test_migration_rev_b_drops_symbol_and_market_columns` PASSED - -## Evidence -- Test: - - `.venv/bin/python -m pytest --tb=short` → **162 passed** (2026-05-19) - - `tests/test_asset_catalog.py`: 20 passed (REQ-009 AC-4,5 / REQ-011 AC-4,5 / migration 파일 구조) - - 기존 suite 142 tests: 모두 pass (regression 없음) -- Manual: - - DB-integration ACs: 실제 PostgreSQL 인스턴스 연결 후 `alembic upgrade head` → psql 스키마 검증 필요 - - 명령: `psql -c "\d assets"` — asset_id, symbol, markets, GIN 인덱스 확인 - - 명령: `psql -c "\d portfolios"` — asset_id FK 존재, symbol/market 없음 확인 -- Logs: - - 없음 (DB-integration 미실행) - -## Result -- Ready for Release: true -- Blocking Issues: - - DB-integration ACs(REQ-009 AC-1~3, REQ-011 AC-1~3, REQ-014 AC-1~6)는 단위 테스트 범위 밖 — 운영 DB 또는 통합 테스트 환경에서 Alembic 적용 후 검증 필요. 로컬 DB 없는 CI 환경에서는 migration 파일 구조 검증으로 대체. diff --git a/docs/batches/bat-002_20260519_asset-catalog-api-completion/design.md b/docs/batches/bat-002_20260519_asset-catalog-api-completion/design.md deleted file mode 100644 index 278687c..0000000 --- a/docs/batches/bat-002_20260519_asset-catalog-api-completion/design.md +++ /dev/null @@ -1,75 +0,0 @@ -# BAT-002 Design - -- Status: verified -- Batch: BAT-002 - -## Architecture Summary -- **REQ-010**: `app/api/v1_assets.py`에 admin 전용 엔드포인트 3개(`POST /v1/assets`, `PATCH /v1/assets/{id}`, `PATCH /v1/assets/{id}/deactivate`)를 추가하고, `app/cli/commands/asset.py` 신규 파일로 `pfhub asset` 커맨드 그룹을 구현한다. 내부 로직은 BAT-001에서 구현된 `postgres.py`의 `insert_asset` / `update_asset` / `list_assets`를 직접 호출한다. -- **REQ-013**: 현재 service token 전용인 `GET /v1/assets` list_assets 핸들러를 `auth.token_type` 분기로 재작성한다. service/user/admin 각 경로에서 동일한 `list_portfolios_with_strategies` repository 메서드를 호출하되 필터 파라미터를 토큰 타입별로 다르게 설정한다. -- **REQ-012**: `PortfolioService.create_portfolio()`에 `repository.get_asset_by_id(asset_id)` 호출을 추가하여 active 상태를 검증한다. CLI에 `--symbol` 옵션을 추가해 symbol→asset_id 조회 경로를 제공한다. -- **REQ-015**: 코드 변경 없음. 구현 완료 후 baseline 문서 4종 갱신. - -## Changed Areas -- Docs: - - `docs/data-model.md` — assets 엔티티 정식 추가, portfolios asset_id FK 반영 (REQ-015) - - `docs/interface-contract.md` — POST/PATCH /v1/assets 신규, GET /v1/assets 토큰 분기 명세 (REQ-015) - - `docs/runtime-flows.md` — GET /v1/assets 토큰 분기 흐름, admin asset CRUD 흐름 (REQ-015) - - `docs/project-structure.md` — pfhub asset 커맨드 그룹, 신규 asset.py 모듈 (REQ-015) -- Modules: - - `app/cli/commands/asset.py` (신규) — pfhub asset create/list/update/deactivate - - `app/cli/main.py` — asset_app typer 그룹 등록 - - `app/api/v1_assets.py` — admin 엔드포인트 3개 추가 + list_assets 재작성 - - `app/schemas/v1.py` — AssetCreateRequest, AssetUpdateRequest, AssetResponse 추가 - - `app/services/portfolio_service.py` — create_portfolio catalog 검증 로직 추가 - - `app/cli/commands/portfolio.py` — create 커맨드에 `--symbol` 옵션 추가 - - `app/repositories/postgres.py` — list_portfolios_with_strategies market 필터 파라미터 추가 -- Interfaces: - - `POST /v1/assets` — admin token 필요, AssetCreateRequest body, 201 AssetResponse - - `PATCH /v1/assets/{asset_id}` — admin token 필요, AssetUpdateRequest body, 200 AssetResponse - - `PATCH /v1/assets/{asset_id}/deactivate` — admin token 필요, 200 AssetResponse - - `GET /v1/assets` — service/user/admin 토큰 분기, `?market=` 파라미터 지원 - -## Key Decisions - -1. **REQ-013 분기 구조: `auth.token_type` 필드로 분기** - - `service` 토큰: `auth.daemon_id` 추출 → None이면 403 fail-closed (기존 REQ-006/008 동작 유지). list_portfolios_with_strategies(managed_by_daemon_id=auth.daemon_id) - - `user` 토큰: list_portfolios_with_strategies(owner_user_id=auth.user_id) - - `admin` 토큰: 기본 list_portfolios_with_strategies(owner_user_id=auth.user_id); `?user_id=` 파라미터 시 해당 user id로 필터링 - - `?market=` 파라미터: 모든 토큰에서 지원 — list_portfolios_with_strategies에 market 필터 파라미터 추가 - - 이유: 기존 daemon 인증 흐름 test_daemon_assignment.py와 호환성 유지, token_type은 이미 TokenPrincipal에 존재 - -2. **REQ-010 admin 권한 검사: `Permission.ADMIN`으로 require_permission 재사용** - - 기존 `app/core/auth.py`의 `require_permission` 의존성 또는 `AuthContext` 확인 패턴 활용 - - non-admin이 POST/PATCH /v1/assets 호출 시 403 - - 이유: 인증 패턴 일관성 유지, 신규 미들웨어 불필요 - -3. **REQ-012 검증 위치: `PortfolioService.create_portfolio()` 서비스 레이어** - - CLI와 HTTP API 양쪽이 PortfolioService를 통하므로 한 곳에서 커버 - - `repository.get_asset_by_id(payload.asset_id)` → None이면 "asset not found" ValueError - - asset.status != "active"이면 "asset is inactive" ValueError - - `--symbol` 옵션은 CLI에만 추가 — CLI에서 `repository.get_asset_by_symbol(symbol)` 조회 후 asset_id 획득 → CreatePortfolioRequest에 전달 - - 이유: 서비스 레이어 검증으로 HTTP/CLI 중복 없음, symbol→id 변환은 CLI 관심사 - -4. **REQ-010 CLI 구조: 별도 파일 `app/cli/commands/asset.py` 분리** - - BAT-001의 `portfolio.py` 패턴과 동일하게 `asset_app = typer.Typer(...)` 생성 - - `app/cli/main.py`에 `app.add_typer(asset_app, name="asset")`으로 등록 - - 이유: 커맨드 그룹별 파일 분리 — 기존 패턴 일관성 - -5. **구현 순서: REQ-010 → REQ-013 → REQ-012 → REQ-015** - - REQ-010이 `v1_assets.py`에 신규 엔드포인트를 추가하고, REQ-013이 같은 파일의 list_assets를 재작성 — 순서 엄수로 충돌 방지 - - REQ-012는 서비스 레이어만 수정 — 독립적이지만 REQ-010 완료 후 테스트 환경 정비 후 진행 - -## Edge Cases -- REQ-013: admin token + `?user_id` 파라미터에서 해당 user가 존재하지 않는 경우 → 빈 배열 반환 (user 존재 여부 비공개, 404 미사용) -- REQ-013: service token + daemon_id=None → 403 (기존 REQ-008 동작 그대로) -- REQ-012: `--symbol` 조회 시 해당 symbol asset이 없는 경우 → CLI에서 즉시 오류 출력 후 종료 -- REQ-012: portfolio create 시 `get_asset_by_id()` 호출 비용 — 로컬 DB에서 단순 PK 조회로 성능 영향 없음 -- REQ-010: deactivate된 asset을 참조하는 기존 portfolio → 기존 portfolio 유지, 신규 create만 REQ-012에서 차단 - -## Architecture Impact -- runtime-flows: `GET /v1/assets` 분기 구조 신규 (service/user/admin 3경로) -- project-structure: `pfhub asset` 커맨드 그룹 신규, `app/cli/commands/asset.py` 모듈 신규 - -## Reference Doc Update Plan -- `project-structure.md`: `app/cli/commands/asset.py` 추가, `pfhub asset` 커맨드 그룹 추가 (REQ-015) -- `runtime-flows.md`: GET /v1/assets 토큰 분기 흐름 교체, admin asset CRUD 흐름 신규 추가 (REQ-015) diff --git a/docs/batches/bat-002_20260519_asset-catalog-api-completion/implementation.md b/docs/batches/bat-002_20260519_asset-catalog-api-completion/implementation.md deleted file mode 100644 index b974780..0000000 --- a/docs/batches/bat-002_20260519_asset-catalog-api-completion/implementation.md +++ /dev/null @@ -1,57 +0,0 @@ -# BAT-002 Implementation - -- Status: verified -- Batch: BAT-002 - -## Plan Summary -- 이번 구현에서 실제 변경할 범위: - 1. `app/schemas/v1.py` — AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse 추가 - 2. `app/api/v1_assets.py` — admin 엔드포인트 3개 추가(REQ-010) + list_assets 토큰 분기 재작성(REQ-013) - 3. `app/cli/commands/asset.py` (신규) — pfhub asset create/list/update/deactivate - 4. `app/cli/main.py` — asset_app 등록 - 5. `app/services/portfolio_service.py` — create_portfolio catalog 검증 추가(REQ-012) - 6. `app/cli/commands/portfolio.py` — create --symbol 옵션 추가(REQ-012) - 7. `app/repositories/portfolio_repository.py` — list_portfolios/list_portfolios_with_strategies market 파라미터 추가 - 8. `app/repositories/postgres.py` — list_portfolios market 필터, list_portfolios_with_strategies market 파라미터 추가 - 9. `docs/` 4종 갱신(REQ-015) - 10. 테스트 파일 갱신 및 신규 - -## Changed Files -- `app/schemas/v1.py` — AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse 추가 -- `app/api/v1_assets.py` — 전체 재작성: list_assets 토큰 분기(service/user/admin), admin 엔드포인트 3개 추가 -- `app/cli/commands/asset.py` (신규) — pfhub asset create/list/update/deactivate typer 커맨드 -- `app/cli/main.py` — asset_app 등록 -- `app/services/portfolio_service.py` — create_portfolio에 get_asset_by_id 호출 및 active 검증 추가 -- `app/cli/commands/portfolio.py` — create 커맨드에 --symbol 옵션 추가 -- `app/repositories/portfolio_repository.py` — list_portfolios, list_portfolios_with_strategies Protocol에 market 파라미터 추가 -- `app/repositories/postgres.py` — list_portfolios market 필터 추가, list_portfolios_with_strategies market 파라미터 추가 -- `docs/data-model.md` — Asset 엔티티 추가, Portfolio asset_id FK 반영 -- `docs/interface-contract.md` — POST/PATCH /v1/assets 신규, GET /v1/assets 토큰 분기 명세 갱신 -- `docs/runtime-flows.md` — GET /v1/assets 토큰 분기 흐름 교체, admin asset CRUD 흐름 신규 추가 -- `docs/project-structure.md` — pfhub asset 커맨드 그룹, 신규 api 모듈 반영 -- `tests/test_asset_catalog.py` — REQ-010/012/013 AC 테스트 추가 (18개 신규) -- `tests/test_v1.py` — REQ-013 AC 테스트 5개 추가, assert_called_once_with market=None 업데이트 -- `tests/test_daemon_assignment.py` — assert_called_once_with market=None 업데이트 -- `tests/test_cli.py` — FakeRepo.get_asset_by_id/get_asset_by_symbol 활성 Asset 반환으로 수정 -- `tests/test_portfolio_uuid.py` — FakeRepo.get_asset_by_id/get_asset_by_symbol 활성 Asset 반환으로 수정 - -## Execution Log -1. app/schemas/v1.py — AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse 추가 -2. app/cli/commands/asset.py — 신규 파일 생성 (pfhub asset create/list/update/deactivate) -3. app/cli/main.py — asset_app import 및 add_typer 등록 -4. app/api/v1_assets.py — list_assets 토큰 분기 재작성 + admin 엔드포인트 3개 추가 -5. app/repositories/portfolio_repository.py — Protocol market 파라미터 추가 -6. app/repositories/postgres.py — list_portfolios market 필터, list_portfolios_with_strategies market 파라미터 추가 -7. app/services/portfolio_service.py — create_portfolio catalog 검증 (get_asset_by_id → active 확인) -8. app/cli/commands/portfolio.py — create --symbol 옵션 추가 (symbol → asset_id 조회 경로) -9. tests/ — FakeRepo 업데이트 (active asset 반환), test_v1.py/test_daemon_assignment.py assert 업데이트 -10. tests/test_asset_catalog.py — REQ-010/012/013 신규 테스트 추가 -11. docs/ — data-model.md, interface-contract.md, runtime-flows.md, project-structure.md 갱신 - -## Validation -- pytest 전체 실행: 180 passed, 0 failed -- lint/typecheck: 별도 실행 미수행 (테스트 통과로 대체) - -## Remaining Risks -- admin 엔드포인트 route 순서: `/assets/{asset_id}/deactivate`가 `/assets/{asset_id}` 앞에 등록되어야 FastAPI가 올바르게 라우팅함 — 현재 구현에서 순서 보장됨 -- DB 통합 테스트 (market filter SQL `ANY(a.markets)`)는 live PostgreSQL에서만 검증 가능 diff --git a/docs/batches/bat-002_20260519_asset-catalog-api-completion/index.md b/docs/batches/bat-002_20260519_asset-catalog-api-completion/index.md deleted file mode 100644 index ebfe311..0000000 --- a/docs/batches/bat-002_20260519_asset-catalog-api-completion/index.md +++ /dev/null @@ -1,33 +0,0 @@ -# BAT-002: asset catalog API 완결 - -- Status: released -- Profile: standard -- Source Discovery: dcy-001 -- Included REQ: REQ-010, REQ-012, REQ-013, REQ-015 -- Excluded REQ: (없음 — dcy-001 잔여 4개 전부 포함) - -## Summary -- BAT-001에서 구축한 `assets` 테이블 및 `asset_id FK` 기반 위에 admin이 종목 catalog를 관리할 수 있는 CRUD API/CLI를 추가하고(REQ-010), portfolio 생성 시 catalog 검증을 도입하며(REQ-012), `GET /v1/assets` 엔드포인트를 service/user/admin 토큰 타입별 분기로 재정의한다(REQ-013). -- 모든 구현이 완료된 후 baseline 문서(data-model, interface-contract, runtime-flows, project-structure)를 갱신한다(REQ-015). - -## Scope -- In Scope: - - `pfhub asset create/list/update/deactivate` CLI 커맨드 신규 추가 (`app/cli/commands/asset.py`) - - `POST /v1/assets`, `PATCH /v1/assets/{id}`, `PATCH /v1/assets/{id}/deactivate` admin 전용 HTTP API 신규 추가 - - `GET /v1/assets` 토큰 타입별 분기 재작성 (service→daemon 필터, user→본인, admin→본인+?user_id) - - `pfhub portfolio create` —`--symbol` 옵션 추가 + service layer asset catalog 존재/active 검증 - - baseline 문서 4종 갱신 (REQ-015) -- Out of Scope: - - tags 관련 모든 변경 (→ dcy-002 batches) - - portfolio 조회·수정·삭제 흐름의 catalog 검증 (→ 별도 REQ 필요) - - `GET /v1/assets` 이외의 v1 엔드포인트 변경 - -## 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): REQ-010과 REQ-013이 `app/api/v1_assets.py` 동일 파일 수정 — 구현 순서 엄수 (REQ-010 신규 엔드포인트 → REQ-013 list_assets 재작성) -- BAT-001(verified) 완료가 선행 조건 — assets 테이블, asset CRUD postgres 구현, asset_id FK 모두 존재 확인됨 diff --git a/docs/batches/bat-002_20260519_asset-catalog-api-completion/planning.md b/docs/batches/bat-002_20260519_asset-catalog-api-completion/planning.md deleted file mode 100644 index a18bff9..0000000 --- a/docs/batches/bat-002_20260519_asset-catalog-api-completion/planning.md +++ /dev/null @@ -1,64 +0,0 @@ -# BAT-002 Planning - -- Status: verified -- Batch: BAT-002 -- Profile: standard - -## Included REQ -- REQ-010: admin asset catalog CRUD (Interface, High) -- REQ-012: portfolio 생성 CLI catalog 검증 (Interface, High) -- REQ-013: GET /v1/assets 토큰 타입별 분기 재정의 (Interface, High) -- REQ-015: asset catalog 도입 baseline 문서 갱신 (Documentation, Low) - -## Delivery Plan - -### 단계 1 — REQ-010: admin asset catalog CRUD -1. `app/schemas/v1.py`에 `AssetCreateRequest`, `AssetUpdateRequest`, `AssetResponse` 스키마 추가 -2. `app/api/v1_assets.py`에 `POST /v1/assets`, `PATCH /v1/assets/{id}`, `PATCH /v1/assets/{id}/deactivate` 엔드포인트 추가 (admin 권한 필요) -3. `app/cli/commands/asset.py` 신규 파일 — `pfhub asset create/list/update/deactivate` typer 커맨드 구현 -4. `app/cli/main.py`에 asset 커맨드 그룹 등록 -5. `tests/test_asset_catalog.py` 확장 — REQ-010 AC-1~8 - -### 단계 2 — REQ-013: GET /v1/assets 토큰 타입별 분기 -1. `app/api/v1_assets.py` list_assets 핸들러 재작성 — token_type 분기 (service/user/admin) -2. `app/repositories/postgres.py` list_portfolios_with_strategies — `?market=` 필터 지원 추가 -3. `tests/test_v1.py` 갱신 및 신규 토큰 분기 케이스 추가 — REQ-013 AC-1~7 - -### 단계 3 — REQ-012: portfolio create catalog 검증 -1. `app/services/portfolio_service.py` create_portfolio에 `get_asset_by_id()` 호출 + active 검증 추가 -2. `app/cli/commands/portfolio.py` create 커맨드에 `--symbol` 옵션 추가 (asset_id 조회 경로) -3. `tests/test_asset_catalog.py` 확장 / `tests/test_portfolio_uuid.py` 또는 `tests/test_cli.py` 갱신 — REQ-012 AC-1~4 - -### 단계 4 — REQ-015: baseline 문서 갱신 -1. `docs/data-model.md` — assets 엔티티, portfolios asset_id FK 갱신 -2. `docs/interface-contract.md` — POST/PATCH /v1/assets, GET /v1/assets 토큰 분기 명세 추가 -3. `docs/runtime-flows.md` — GET /v1/assets 토큰 분기 흐름, admin asset CRUD 흐름 추가 -4. `docs/project-structure.md` — pfhub asset 커맨드 그룹 및 신규 api 모듈 반영 - -### 단계 5 — 전체 테스트 suite regression 검증 -- `.venv/bin/python -m pytest --tb=short` 전체 실행 — regression 없음 확인 - -## Dependencies -- Internal: - - BAT-001(verified): assets 테이블, postgres asset CRUD 5종, Portfolio.asset_id FK 모두 구현 완료 — 선행 조건 충족 - - REQ-010(단계 1) 완료 후 REQ-013(단계 2) 착수 — `app/api/v1_assets.py` 동일 파일 순서 의존 - - REQ-010(단계 1) 완료 후 REQ-012(단계 3) 착수 — assets catalog 존재가 검증 선행 조건 -- External: - - 없음 - -## Design Gate -- Design Required Before Implementation: yes -- Reason: (1) REQ-013 토큰 분기 아키텍처 — service/user/admin 각 경로에서 repository 호출 패턴이 다르고 기존 daemon 인증 흐름(test_daemon_assignment.py)과의 정합성 설계 필요; (2) REQ-010 CLI 구조 — `app/cli/commands/asset.py` 신규 파일 구조 및 `pfhub asset` 커맨드 그룹 등록 방식 결정 필요; (3) REQ-012 검증 위치 — CLI vs 서비스 레이어 중 어디서 catalog 검증을 수행할지 결정 필요 (현재 계획: 서비스 레이어) - -## Milestones -1. REQ-010: pfhub asset CLI + admin HTTP API 3개 엔드포인트 완성 + AC-1~8 통과 -2. REQ-013: GET /v1/assets 토큰 분기 재작성 + AC-1~7 통과 -3. REQ-012: portfolio create catalog 검증 + AC-1~4 통과 -4. REQ-015: baseline 문서 4종 갱신 + AC-1~4 통과 -5. 전체 pytest suite regression 통과 - -## Risks -- R-1(High): REQ-010과 REQ-013이 `app/api/v1_assets.py` 동일 파일을 수정 — 구현 순서를 단계 1→2로 엄수해야 충돌 없음. 대응: planning 단계에서 순서 명시, 단계별 커밋 분리 -- R-2(Medium): REQ-013 list_assets 재작성이 기존 service token 전용 동작을 변경 — `test_daemon_assignment.py`, `test_v1.py` regression 위험. 대응: 단계 2 완료 직후 전체 pytest 실행으로 조기 탐지 -- R-3(Medium): REQ-012 service layer 검증 추가 시 기존 FakeRepo에 `get_asset_by_id/symbol` 스텁 미구현으로 테스트 실패 가능. 대응: 단계 3 착수 전 FakeRepo 업데이트 체크리스트 준비 -- R-4(Low): REQ-015 runtime-flows.md — GET /v1/assets 분기 흐름 다이어그램이 복잡해질 수 있음. 대응: 단계 4는 단계 2 완료 후 실제 구현을 기준으로 작성 diff --git a/docs/batches/bat-002_20260519_asset-catalog-api-completion/verification.md b/docs/batches/bat-002_20260519_asset-catalog-api-completion/verification.md deleted file mode 100644 index 22e1535..0000000 --- a/docs/batches/bat-002_20260519_asset-catalog-api-completion/verification.md +++ /dev/null @@ -1,53 +0,0 @@ -# BAT-002 Verification - -- Status: release-candidate -- Batch: BAT-002 - -## Acceptance Mapping - -- REQ-010: - - AC-1: admin CLI `pfhub asset create` 성공 → `tests/test_asset_catalog.py::test_asset_cli_create_success` ✓ - - AC-2: admin CLI `pfhub asset list` — active/inactive 필터 → `tests/test_asset_catalog.py::test_asset_cli_list_returns_all`, `test_asset_cli_list_active_only_filter` ✓ - - AC-3: admin CLI `pfhub asset update` 성공 → `tests/test_asset_catalog.py::test_asset_cli_update_success` ✓ - - AC-4: admin CLI `pfhub asset deactivate` 성공 → `tests/test_asset_catalog.py::test_asset_cli_deactivate_success` ✓ - - AC-5: `POST /v1/assets` admin token → 201 AssetResponse → `tests/test_asset_catalog.py::test_asset_http_create_returns_201` ✓ - - AC-6: `PATCH /v1/assets/{id}` admin token → 200 → `tests/test_asset_catalog.py::test_asset_http_update_returns_200` ✓ - - AC-7: `PATCH /v1/assets/{id}/deactivate` admin token → 200 → `tests/test_asset_catalog.py::test_asset_http_deactivate_returns_200` ✓ - - AC-8: non-admin으로 admin 엔드포인트 호출 시 403 → `tests/test_asset_catalog.py::test_asset_http_non_admin_403` ✓ - -- REQ-012: - - AC-1: active asset 존재 시 portfolio create 성공 → `tests/test_asset_catalog.py::test_portfolio_create_with_active_asset` ✓ - - AC-2: 미등록 asset_id 지정 시 "asset not found" 오류, 생성 실패 → `tests/test_asset_catalog.py::test_portfolio_create_unknown_asset` ✓ - - AC-3: inactive asset 지정 시 "asset is inactive" 오류, 생성 실패 → `tests/test_asset_catalog.py::test_portfolio_create_inactive_asset` ✓ - - AC-4: `--symbol`로 asset_id 조회 후 portfolio create 성공 → `tests/test_asset_catalog.py::test_portfolio_create_by_symbol` ✓ - -- REQ-013: - - AC-1: service token(daemon_id 있음) → daemon 관리 portfolio 필터링 → `tests/test_v1.py::test_v1_assets_returns_asset_list` (assert_called_once_with updated) ✓ - - AC-2: service token(daemon_id 없음) → 403 → `tests/test_daemon_assignment.py::test_assets_403_when_token_has_no_daemon_id` (기존) ✓ - - AC-3: user token → 본인 portfolio만 반환 → `tests/test_v1.py::test_v1_assets_user_token` ✓ - - AC-4: admin token 기본 → admin 본인 portfolio → `tests/test_v1.py::test_v1_assets_admin_token_default` ✓ - - AC-5: admin token + ?user_id → 해당 user portfolio → `tests/test_v1.py::test_v1_assets_admin_token_user_id_param` ✓ - - AC-6: 모든 토큰 + ?market= → 필터링 → `tests/test_v1.py::test_v1_assets_market_filter` ✓ - - AC-7: 응답 asset_id는 UUID 형식 → `tests/test_v1.py::test_v1_assets_asset_id_is_uuid` ✓ - -- REQ-015: - - AC-1: docs/data-model.md에 assets 엔티티, portfolios asset_id FK 반영 → 수동 검토 ✓ - - AC-2: docs/interface-contract.md에 POST/PATCH /v1/assets, 토큰 분기 명세 포함 → 수동 검토 ✓ - - AC-3: docs/runtime-flows.md에 GET /v1/assets 토큰 분기, admin CRUD 흐름 추가 → 수동 검토 ✓ - - AC-4: docs/project-structure.md에 pfhub asset 그룹 및 asset.py 모듈 반영 → 수동 검토 ✓ - -## Evidence -- Test: - - pytest 실행 결과: 180 passed, 0 failed (2026-05-19) - - 신규 테스트: test_asset_catalog.py 18개, test_v1.py 5개 추가 - - 기존 regression: test_cli.py, test_portfolio_uuid.py, test_v1.py, test_daemon_assignment.py 전체 통과 -- Manual: - - 수동 CLI 실행은 live DB 환경에서 별도 검증 필요 (FakeRepo 기반 테스트로 커버) - - HTTP admin 엔드포인트는 MagicMock 기반 테스트로 커버 -- Logs: - - `tests/test_asset_catalog.py` — 전체 38개 테스트 통과 - - `tests/test_v1.py` — 전체 통과 (기존 + 신규 5개) - -## Result -- Ready for Release: true -- Blocking Issues: 없음 diff --git a/docs/batches/bat-003_20260519_tags-db-foundation/design.md b/docs/batches/bat-003_20260519_tags-db-foundation/design.md deleted file mode 100644 index 706ba4c..0000000 --- a/docs/batches/bat-003_20260519_tags-db-foundation/design.md +++ /dev/null @@ -1,56 +0,0 @@ -# BAT-003 Design - -- Status: confirmed -- Batch: BAT-003 - -## Architecture Summary -- **REQ-001**: `migrations/versions/`에 `tags` 테이블 생성 Alembic revision을 추가하고, `app/domain/models.py`에 `Tag` 모델, `app/repositories/portfolio_repository.py`에 tag CRUD 프로토콜, `app/repositories/postgres.py`에 SQL 구현을 추가한다. -- **REQ-002 + REQ-003(컬럼)**: 두 REQ가 동일한 `portfolios.tag_ids UUID[]` 컬럼 추가를 공유하므로 단일 Alembic revision으로 통합한다. `Portfolio` 도메인 모델에 `tag_ids` 필드를 추가하고, `postgres.py`에 archived 표시 로직, `app/schemas/v1.py`에 `AssetItem`/`PortfolioItem` 필드를 추가한다. -- **REQ-006**: 별도 Alembic data migration revision으로 구현. 실행 순서: (1) admin 계정 ID 존재 검증 → (2) portfolio_tags.tag_value를 소문자 정규화 후 tags 테이블에 INSERT (ON CONFLICT DO NOTHING) → (3) portfolios.tag_ids 채우기 → (4) portfolio_tags DROP. - -## Changed Areas -- Docs: - - `docs/data-model.md` — tags 테이블 추가, portfolios.tag_ids 추가, portfolio_tags 제거 반영 (이 배치 범위 밖 — REQ-008 배치에서 갱신) -- Modules: - - `migrations/versions/` — (a) tags 테이블 revision, (b) portfolios.tag_ids + GIN 인덱스 revision, (c) 데이터 마이그레이션 revision (3개) - - `app/domain/models.py` — `Tag` 모델 추가, `Portfolio.tag_ids` 필드 추가 - - `app/repositories/portfolio_repository.py` — `TagRepository` 프로토콜 메서드 추가 (`create_tag`, `get_tag_by_id`, `list_tags`, `deactivate_tag`) - - `app/repositories/postgres.py` — tags CRUD SQL 구현, portfolio tag_ids 배열 조작, archived 표시 로직 - - `app/schemas/v1.py` — `AssetItem.tag_ids`, `PortfolioItem.tag_ids` 필드 추가 -- Interfaces: - - 없음 (데이터 레이어 전용 배치 — HTTP/CLI 변경 없음) - -## Key Decisions - -1. **REQ-002/REQ-003 Alembic revision 통합** - - 두 REQ가 동일한 `portfolios.tag_ids UUID[]` 컬럼 추가를 명시 → 별도 revision으로 분리 시 중복 오류 발생 - - 단일 revision에서 컬럼 추가 + GIN 인덱스 생성을 수행, REQ-002 AC-1~2 및 REQ-003 AC-1~2 공동 충족 - - REQ-003의 `portfolio_tags DROP`은 REQ-006 데이터 마이그레이션 revision 끝에 포함 - -2. **REQ-006 admin 계정 ID 주입 방식** - - Alembic revision 내 모듈 상수 `MIGRATION_ADMIN_ID`로 관리 — 운영 실행 전 운영자가 직접 값을 지정 - - 테스트 환경: `conftest.py` fixture로 users 테이블에 해당 ID를 사전 INSERT하여 마이그레이션 검증 - - 이유: 환경 변수 주입도 가능하나, 명시적 상수가 이력 추적에 유리하고 실수 방지 - -3. **archived 표시 로직 위치: `postgres.py` 조회 레이어** - - `list_portfolios_with_strategies` 내에서 `portfolios.tag_ids`와 `tags.is_active`를 LEFT JOIN으로 함께 조회 - - 비활성 tag_id는 배열에서 제거하지 않고 `TagRef(tag_id=..., archived=True)` 형태로 응답에 포함 - - 이유: 서비스 레이어에서 N+1 조회 없이 단일 쿼리로 처리, REQ-002 AC-3 및 REQ-003 AC-4 충족 - -4. **REQ-006 rollback (downgrade) 계획** - - Alembic `downgrade()` 함수에서: `portfolio_tags` 테이블 재생성 + tags 데이터 역방향 복원 SQL + `portfolios.tag_ids` 초기화 포함 - - 데이터 유실 없는 downgrade를 보장하되 완전 역복원은 어려우므로 운영 적용 전 스냅샷 백업 병행 권고 - -## Edge Cases -- REQ-006: 동일 tag_value(소문자 정규화 후)를 여러 portfolio에서 사용 → `ON CONFLICT DO NOTHING`으로 tags 테이블 단일 행 유지 -- REQ-006: users 테이블에 `MIGRATION_ADMIN_ID`가 없으면 오류 출력 + 마이그레이션 중단 (데이터 무변경) -- REQ-002/003: 기존 portfolios 행의 `tag_ids = '{}'` 기본값 — REQ-006 실행 이전에는 빈 배열 -- REQ-003 DROP: `portfolio_tags`가 이미 없는 경우 Alembic downgrade는 `IF NOT EXISTS`로 재생성, upgrade는 `IF EXISTS`로 DROP - -## Architecture Impact -- project-structure: 없음 (신규 모듈 파일 없음, 기존 파일 수정만) -- runtime-flows: 없음 (HTTP/CLI 변경 없음) - -## Reference Doc Update Plan -- `project-structure.md`: 없음 (이 배치에서 변경 없음) -- `runtime-flows.md`: 없음 (이 배치에서 변경 없음) diff --git a/docs/batches/bat-003_20260519_tags-db-foundation/implementation.md b/docs/batches/bat-003_20260519_tags-db-foundation/implementation.md deleted file mode 100644 index d16d282..0000000 --- a/docs/batches/bat-003_20260519_tags-db-foundation/implementation.md +++ /dev/null @@ -1,43 +0,0 @@ -# BAT-003 Implementation - -- Status: completed -- Batch: BAT-003 - -## Plan Summary -- REQ-001: tags 테이블 Alembic revision + Tag 도메인 모델 + tags CRUD 프로토콜/postgres 구현 -- REQ-002 + REQ-003(컬럼): portfolios.tag_ids UUID[] + GIN revision + domain/schema 업데이트 + archived 표시 로직 -- REQ-006: portfolio_tags 데이터 → tags master + portfolios.tag_ids 채우기 + portfolio_tags DROP (Alembic revision + 테스트 가능한 Python 함수 분리) -- 전체 pytest suite 206 passed, regression 없음 - -## Changed Files -- `migrations/versions/20260519_0019_tags_master_table.py` (신규) -- `migrations/versions/20260519_0020_portfolios_tag_ids_column.py` (신규) -- `migrations/versions/20260519_0021_portfolio_tags_data_migration.py` (신규) -- `app/domain/models.py` — `Tag` 모델 추가, `Portfolio.tag_ids: list[str] | None = None` 추가 -- `app/repositories/portfolio_repository.py` — `Tag` 임포트, `create_tag`/`get_tag_by_id`/`get_tags_by_ids`/`list_tags_master`/`deactivate_tag` 프로토콜 추가 -- `app/repositories/postgres.py` — `Tag` 임포트, `_SELECT_TAG` 상수 추가, `_SELECT_PORTFOLIO`에 `p.tag_ids` 추가, `_row_to_portfolio` tag_ids 처리, `ensure_schema()` 업데이트(tags 테이블 + portfolios.tag_ids + GIN), `_row_to_tag` 추가, 태그 CRUD 메서드 5개 추가 -- `app/schemas/v1.py` — `TagRefItem` 추가, `AssetItem.tag_ids: list[TagRefItem] | None = None` 추가 -- `app/api/v1_assets.py` — `TagRefItem` 임포트, `_build_tag_refs` 헬퍼 추가, `_build_asset_items` tag_ids archived 표시 로직 추가 -- `tests/test_tags_master.py` (신규) — 26개 테스트 - -## Execution Log -1. Alembic migration revision 0019: tags 테이블 생성 (upgrade/downgrade) -2. Alembic migration revision 0020: portfolios.tag_ids + GIN 인덱스 (upgrade/downgrade) -3. Alembic migration revision 0021: 데이터 마이그레이션 + portfolio_tags DROP, `run_migration()` 함수 분리 -4. domain/models.py: `Tag` 추가, `Portfolio.tag_ids` 추가 -5. portfolio_repository.py: 프로토콜에 `Tag` CRUD 메서드 5개 추가 -6. postgres.py: `_SELECT_TAG`, `_SELECT_PORTFOLIO` 업데이트, `ensure_schema()` 업데이트, `_row_to_tag` + CRUD 구현 -7. v1.py: `TagRefItem` + `AssetItem.tag_ids` -8. v1_assets.py: `_build_tag_refs` + batch tag lookup + archived 표시 -9. test_tags_master.py: 26개 AC 테스트 작성 (REQ-001 AC-2~5, REQ-002/003 AC-3~4, REQ-006 AC-1~6, migration 파일 존재 검증) -10. pytest 전체 suite 실행 → 206 passed, 0 failed - -## Validation -- pytest 실행 결과: **206 passed, 0 failed** (2026-05-19) -- 신규 테스트: test_tags_master.py 26개 -- 기존 regression: test_cli.py(35), test_v1.py(26), test_asset_catalog.py(35), test_portfolio_tags.py(4), test_portfolio_uuid.py(14), test_daemon_assignment.py(8) 전체 통과 - -## Remaining Risks -- REQ-001 AC-1 (tags 테이블 스키마 검증), REQ-002/003 AC-1~2 (GIN 인덱스 존재), REQ-003 AC-3 (portfolio_tags 테이블 부재): live DB 마이그레이션 실행 후 수동 검증 필요 -- REQ-006 AC-1~4,6: live DB 마이그레이션 실행 후 DB 상태 SQL 검증 필요 -- `MIGRATION_ADMIN_ID = "migration-admin"` — live 환경 실행 전 운영자가 유효한 user ID로 변경 필요 diff --git a/docs/batches/bat-003_20260519_tags-db-foundation/index.md b/docs/batches/bat-003_20260519_tags-db-foundation/index.md deleted file mode 100644 index c148834..0000000 --- a/docs/batches/bat-003_20260519_tags-db-foundation/index.md +++ /dev/null @@ -1,37 +0,0 @@ -# BAT-003: tags DB 기반 - -- Status: released -- Profile: standard -- Source Discovery: dcy-002 -- Included REQ: REQ-001, REQ-002, REQ-003, REQ-006 -- Excluded REQ: (없음 — dcy-002 데이터 레이어 4개 전부 포함) - -## Summary -- tags 마스터 테이블을 신규 도입하고(REQ-001), portfolios 테이블에 UUID[] 태그 참조 컬럼을 추가하며(REQ-002, REQ-003), 기존 portfolio_tags 자유 문자열 데이터를 신규 tags 마스터로 이전한 뒤 portfolio_tags 테이블을 제거한다(REQ-006). -- 이 배치 이후 HTTP API / CLI 계층(REQ-004, REQ-005, REQ-007, REQ-008)은 별도 배치에서 구현한다. - -## Scope -- In Scope: - - `tags` 테이블 생성 Alembic 마이그레이션 (`migrations/versions/`) - - `portfolios.tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼 + GIN 인덱스 추가 마이그레이션 - - `portfolio_tags` 데이터를 `tags` 마스터로 이전, `portfolios.tag_ids` 채우기, `portfolio_tags` DROP 데이터 마이그레이션 - - `Tag` 도메인 모델 추가, `Portfolio` 모델에 `tag_ids` 필드 추가 (`app/domain/models.py`) - - tags CRUD 프로토콜 추가 (`app/repositories/portfolio_repository.py`) - - tags INSERT/SELECT/UPDATE, portfolio tag_ids 배열 조작, archived 표시 로직 (`app/repositories/postgres.py`) - - `AssetItem`/`PortfolioItem`에 `tag_ids` 필드 추가 (`app/schemas/v1.py`) -- Out of Scope: - - tags HTTP API (→ REQ-004, 다음 배치) - - 태그 적용·해제 HTTP API (→ REQ-005, 다음 배치) - - pfhub tag CLI (→ REQ-007, 다음 배치) - - baseline 문서 갱신 (→ REQ-008, 다음 배치) - -## 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): REQ-006 데이터 마이그레이션은 비가역 — 롤백 계획 설계 단계에서 필수 -- REQ-002와 REQ-003이 portfolios.tag_ids 컬럼 추가를 공유 → 단일 Alembic revision으로 통합, 구현 순서 설계 단계에서 명시 -- REQ-006의 선행 조건: REQ-001(tags 테이블)과 단계 2 revision(portfolios.tag_ids 컬럼)이 먼저 적용된 상태 diff --git a/docs/batches/bat-003_20260519_tags-db-foundation/planning.md b/docs/batches/bat-003_20260519_tags-db-foundation/planning.md deleted file mode 100644 index cc22b72..0000000 --- a/docs/batches/bat-003_20260519_tags-db-foundation/planning.md +++ /dev/null @@ -1,62 +0,0 @@ -# BAT-003 Planning - -- Status: confirmed -- Batch: BAT-003 -- Profile: standard - -## Included REQ -- REQ-001: tags 마스터 테이블 도입 (Data, High) -- REQ-002: assets.tag_ids UUID[] 컬럼 추가 (Data, High) -- REQ-003: portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거 (Data, High) -- REQ-006: portfolio_tags 데이터 마이그레이션 (Migration, High) - -## Delivery Plan - -### 단계 1 — REQ-001: tags 마스터 테이블 도입 -1. `migrations/versions/` — `tags` 테이블 생성 Alembic revision -2. `app/domain/models.py` — `Tag` 도메인 모델 추가 -3. `app/repositories/portfolio_repository.py` — tags CRUD 프로토콜 메서드 추가 (`create_tag`, `get_tag_by_id`, `list_tags`, `deactivate_tag`) -4. `app/repositories/postgres.py` — tags INSERT/SELECT/UPDATE SQL 구현 -5. `tests/` — tags 테이블 스키마 검증, `is_admin_tag` 스냅샷 동작 테스트 - -### 단계 2 — REQ-002 + REQ-003(컬럼 추가 부분): portfolios.tag_ids 컬럼 -1. `migrations/versions/` — `portfolios.tag_ids UUID[]` 컬럼 + GIN 인덱스 Alembic revision (단일 revision으로 REQ-002/REQ-003 공동 충족) -2. `app/domain/models.py` — `Portfolio` 모델에 `tag_ids: list[str]` 필드 추가 -3. `app/repositories/postgres.py` — tag_ids 배열 조회 + archived 표시 로직 추가 -4. `app/schemas/v1.py` — `AssetItem`, `PortfolioItem`에 `tag_ids` 필드 추가 -5. `tests/` — portfolios 스키마 검증, GIN 인덱스 확인, archived 표시 동작 테스트 - -### 단계 3 — REQ-006: portfolio_tags 데이터 마이그레이션 -1. `migrations/versions/` — 데이터 마이그레이션 revision: admin ID 검증 → tags INSERT → portfolios.tag_ids 채우기 → portfolio_tags DROP -2. `tests/` — 마이그레이션 전후 DB 상태 SQL 검증, admin 계정 ID 없을 시 중단 확인, DISTINCT 수 일치 확인 - -### 단계 4 — REQ-003(DROP 검증): portfolio_tags 테이블 제거 확인 -1. REQ-006 revision 실행 후 portfolio_tags 테이블 부재 검증 — REQ-003 AC-3 충족 - -### 단계 5 — 전체 테스트 suite regression 검증 -- `.venv/bin/python -m pytest --tb=short` 전체 실행 — regression 없음 확인 - -## Dependencies -- Internal: - - BAT-001(verified), BAT-002(release-candidate): assets 테이블, portfolios 스키마 확정 — 선행 조건 충족 - - 단계 2는 단계 1(REQ-001 tags 테이블 revision) 완료 후 착수 - - 단계 3(REQ-006)은 단계 2(portfolios.tag_ids 컬럼 revision) 완료 후 착수 -- External: - - `users` 테이블 존재 (migration 0004에서 생성) — `tags.created_by_user_id` FK 참조 - -## Design Gate -- Design Required Before Implementation: yes -- Reason: (1) REQ-002/REQ-003 portfolios.tag_ids 컬럼 추가 중복 → 단일 revision 통합 방식 설계 필요; (2) REQ-006 데이터 마이그레이션 롤백 계획 및 admin 계정 ID 주입 방식 결정; (3) archived 표시 로직 위치(postgres.py vs service layer) 결정 필요 - -## Milestones -1. REQ-001: tags 테이블 마이그레이션 + domain/repo 구현 + AC-1~5 통과 -2. REQ-002 + REQ-003(컬럼): portfolios.tag_ids 마이그레이션 + domain/schema 구현 + AC-1~4 통과 -3. REQ-006: 데이터 마이그레이션 revision 실행 + AC-1~6 통과 -4. REQ-003 DROP 검증: portfolio_tags 테이블 부재 확인 + AC-3 통과 -5. 전체 pytest suite regression 통과 - -## Risks -- R-1(High): REQ-006 데이터 마이그레이션 비가역 — portfolio_tags 데이터 손실 방지를 위해 마이그레이션 전 롤백 revision 준비. 대응: 설계 단계에서 downgrade SQL 명시 -- R-2(Medium): REQ-002/REQ-003 컬럼 추가 중복 → 단일 Alembic revision 통합 시 두 REQ의 AC 충족 방식 조율 필요. 대응: 설계 단계에서 revision 구조와 AC 매핑 명시 -- R-3(Medium): portfolios 도메인 모델에 `tag_ids` 필드 추가 시 기존 FakeRepo, `test_portfolio_uuid.py`, `test_cli.py` 등 domain 모델 참조 테스트 영향 가능. 대응: 단계 2 완료 직후 전체 pytest 실행으로 조기 탐지 -- R-4(Low): REQ-006 admin 계정 ID — 테스트 환경에서 users 테이블에 해당 ID 사전 삽입 필요. 대응: 설계 단계에서 테스트용 fixture admin 계정 ID 결정 diff --git a/docs/batches/bat-003_20260519_tags-db-foundation/verification.md b/docs/batches/bat-003_20260519_tags-db-foundation/verification.md deleted file mode 100644 index 5ea762b..0000000 --- a/docs/batches/bat-003_20260519_tags-db-foundation/verification.md +++ /dev/null @@ -1,79 +0,0 @@ -# BAT-003 Verification - -- Status: release-candidate -- Batch: BAT-003 - -## Acceptance Mapping - -- REQ-001: - - AC-1: tags 테이블 스키마 검증 (tag_id UUID PK, name UNIQUE, created_by_user_id, is_admin_tag, is_active, created_at) → `test_migration_0019_tags_table_exists`, `test_migration_0019_creates_tags_table` (migration file content 검증; live DB 컬럼 타입 검증은 pre-release 수동 항목) - - AC-2: admin 권한 토큰 태그 생성 시 is_admin_tag = TRUE → `test_create_tag_with_admin_flag_true` - - AC-3: non-admin 토큰 태그 생성 시 is_admin_tag = FALSE → `test_create_tag_with_admin_flag_false` - - AC-4: 태그 비활성화 시 is_active = FALSE, 행 삭제 없음 → `test_deactivate_tag_sets_is_active_false`, `test_deactivate_tag_does_not_delete_row` - - AC-5: is_active = FALSE 태그 조회 가능, 비활성 상태 확인 → `test_get_inactive_tag_returns_row` - -- REQ-002: - - AC-1: portfolios.tag_ids UUID[] NOT NULL DEFAULT '{}' 컬럼 존재 → `test_migration_0020_portfolios_tag_ids_column` (migration file content 검증; live DB 컬럼 타입 검증은 pre-release 수동 항목) - - AC-2: portfolios.tag_ids GIN 인덱스 존재 → `test_migration_0020_portfolios_tag_ids_column` (GIN 키워드 포함 확인; live DB 인덱스 존재 검증은 pre-release 수동 항목) - - AC-3: is_active = FALSE tag_id 포함 시 archived 표시와 함께 반환, 자동 제거 없음 → `test_v1_assets_inactive_tag_archived_true` (GET /v1/assets 응답에서 archived=True, 배열 유지 확인) - - AC-4: tag_ids = '{}' 기본값 asset 조회 시 빈 배열 반환 → `test_v1_assets_empty_tag_ids_returns_none`, `test_portfolio_tag_ids_defaults_empty` - -- REQ-003: - - AC-1: portfolios.tag_ids UUID[] NOT NULL DEFAULT '{}' 컬럼 존재 (REQ-002와 공동 revision) → `test_migration_0020_portfolios_tag_ids_column` - - AC-2: portfolios.tag_ids GIN 인덱스 존재 (REQ-002와 공동 revision) → `test_migration_0020_portfolios_tag_ids_column` - - AC-3: 마이그레이션 후 portfolio_tags 테이블 부재 → `test_migration_ac4_portfolio_tags_dropped` (revision 0021 파일에 DROP TABLE portfolio_tags 포함 확인) - - AC-4: portfolio tag_ids 비활성 태그 archived 표시 반환, 자동 제거 없음 → `test_v1_assets_inactive_tag_archived_true` - -- REQ-006: - - AC-1: 소문자 정규화된 DISTINCT tag_value 수만큼 tags 행 생성, is_admin_tag=FALSE, created_by_user_id=지정 admin ID → `test_migration_ac1_tags_created_with_correct_attrs` - - AC-2: "TestTag"/"testtag" 중복 → tags에 "testtag" 1행만 생성 (ON CONFLICT DO NOTHING) → `test_migration_ac2_case_insensitive_dedup` - - AC-3: 각 portfolio.tag_ids에 기존 portfolio_tags 연결 tag_value의 tag_id 모두 포함 → `test_migration_ac3_portfolio_tag_ids_populated` - - AC-4: 마이그레이션 후 portfolio_tags 테이블 부재 → `test_migration_ac4_portfolio_tags_dropped` - - AC-5: 유효하지 않은 admin ID 지정 시 데이터 무변경 오류 중단 → `test_migration_ac5_invalid_admin_id_aborts` - - AC-6: DISTINCT tag_value 수 N == 마이그레이션 후 tags 행 수 N → `test_migration_ac6_distinct_tag_count_matches` - -## Evidence - -- Test: - - pytest 실행 결과: **206 passed, 0 failed** (2026-05-19) - - 신규 테스트: `tests/test_tags_master.py` 26개 - - `test_tag_domain_model_fields` - - `test_create_tag_with_admin_flag_true` - - `test_create_tag_with_admin_flag_false` - - `test_create_tag_duplicate_name_raises` - - `test_deactivate_tag_sets_is_active_false` - - `test_deactivate_tag_does_not_delete_row` - - `test_get_inactive_tag_returns_row` - - `test_list_tags_master_active_only_by_default` - - `test_list_tags_master_include_inactive` - - `test_portfolio_has_tag_ids_field` - - `test_portfolio_tag_ids_defaults_empty` - - `test_asset_item_tag_ids_field_exists` - - `test_v1_assets_active_tag_not_archived` - - `test_v1_assets_inactive_tag_archived_true` - - `test_v1_assets_empty_tag_ids_returns_none` - - `test_migration_ac1_tags_created_with_correct_attrs` - - `test_migration_ac2_case_insensitive_dedup` - - `test_migration_ac3_portfolio_tag_ids_populated` - - `test_migration_ac4_portfolio_tags_dropped` - - `test_migration_ac5_invalid_admin_id_aborts` - - `test_migration_ac6_distinct_tag_count_matches` - - `test_migration_0019_tags_table_exists` - - `test_migration_0019_creates_tags_table` - - `test_migration_0020_portfolios_tag_ids_column` - - `test_migration_0021_data_migration_exists` - - `test_migration_0021_has_admin_verification` - - 기존 regression 전체 통과: test_cli.py(35), test_v1.py(26), test_asset_catalog.py(35), test_portfolio_tags.py(4), test_portfolio_uuid.py(14), test_daemon_assignment.py(8) -- Manual (pre-release 수동 검증 항목 — live DB 마이그레이션 실행 후): - - tags 테이블 스키마 확인 (REQ-001 AC-1 live 검증) - - portfolios.tag_ids 컬럼 타입 UUID[] NOT NULL DEFAULT '{}' 확인 (REQ-002/003 AC-1 live 검증) - - idx_portfolios_tag_ids_gin 인덱스 존재 확인 (REQ-002/003 AC-2 live 검증) - - portfolio_tags 테이블 부재 확인 (REQ-003 AC-3 / REQ-006 AC-4 live 검증) - - MIGRATION_ADMIN_ID를 운영 환경 유효 user ID로 변경 필요 (revision 0021) -- Logs: N/A - -## Result - -- Ready for Release: true -- Blocking Issues: 없음 -- Pre-release 수동 검증 항목: live DB 마이그레이션 실행 후 스키마/인덱스 상태 SQL 확인 (상기 Manual 항목 참조) diff --git a/docs/batches/bat-004_20260520_tags-http-api/design.md b/docs/batches/bat-004_20260520_tags-http-api/design.md deleted file mode 100644 index 43ec54b..0000000 --- a/docs/batches/bat-004_20260520_tags-http-api/design.md +++ /dev/null @@ -1,64 +0,0 @@ -# BAT-004 Design - -- Status: confirmed -- Batch: BAT-004 - -## Architecture Summary -- REQ-004: `app/api/v1_tags.py` 신규 라우터 파일(prefix="/v1"), POST /v1/tags / GET /v1/tags / PATCH /v1/tags/{tag_id}/deactivate 엔드포인트. -- REQ-005(asset side): 기존 `app/api/v1_assets.py`에 POST/DELETE /v1/assets/{asset_id}/tags 라우트 추가. v1 API에서 asset_id = portfolio_id이므로 두 엔드포인트 모두 `portfolios.tag_ids`를 조작. -- REQ-005(portfolio side): `app/api/v1_portfolios.py` 신규 라우터 파일(prefix="/v1/portfolios"), POST/DELETE /v1/portfolios/{portfolio_id}/tags 엔드포인트. 기존 `portfolios.py`(prefix="/portfolios")와 path prefix가 달라 충돌 없음. -- 정책 검증 로직은 서비스 레이어 없이 라우터에서 직접 처리(현재 아키텍처 패턴 유지). -- tag_ids 배열 조작: `append_portfolio_tag_id` / `remove_portfolio_tag_id` 신규 메서드를 postgres.py에 추가. - -## Changed Areas -- Docs: docs/interface-contract.md (엔드포인트 명세 — REQ-008 배치에서 일괄 갱신) -- Modules: - - `app/api/v1_tags.py` — 신규 파일 (POST/GET/PATCH /v1/tags) - - `app/api/v1_portfolios.py` — 신규 파일 (POST/DELETE /v1/portfolios/{portfolio_id}/tags) - - `app/api/v1_assets.py` — POST/DELETE /v1/assets/{asset_id}/tags 라우트 추가 - - `app/schemas/v1.py` — TagItem, TagCreateRequest, TagListResponse, TagApplyRequest, TagApplyResponse 추가 - - `app/repositories/portfolio_repository.py` — append_portfolio_tag_id, remove_portfolio_tag_id 프로토콜 추가 - - `app/repositories/postgres.py` — append_portfolio_tag_id, remove_portfolio_tag_id 구현 추가 - - `app/main.py` — v1_tags, v1_portfolios 라우터 등록 - - `tests/test_tags_http_api.py` — 신규 테스트 파일 -- Interfaces: - - POST /v1/tags — 태그 생성 (trade-write 이상, DB token 필수) - - GET /v1/tags — 태그 목록 (read 이상, ?is_active 필터, DB token 필수) - - PATCH /v1/tags/{tag_id}/deactivate — 태그 비활성화 (admin 또는 생성자 본인, DB token 필수) - - POST /v1/assets/{asset_id}/tags — asset(portfolio)에 태그 적용 (admin, DB token 필수) - - DELETE /v1/assets/{asset_id}/tags/{tag_id} — asset에서 태그 해제 (admin, DB token 필수) - - POST /v1/portfolios/{portfolio_id}/tags — portfolio에 태그 적용 (trade-write, 소유자/admin, DB token 필수) - - DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id} — portfolio에서 태그 해제 (trade-write, 소유자/admin, DB token 필수) - -## Key Decisions -1. **v1_tags.py 신규 파일 분리**: tags 도메인 전용 라우터를 별도 파일로 분리해 v1_assets.py(asset 카탈로그 CRUD)와 관심사 분리. 이후 REQ-007 CLI 추가 시 충돌 없음. -2. **v1_portfolios.py 신규 파일**: REQ-005 portfolio tag 엔드포인트는 `/v1/portfolios` prefix. 기존 `portfolios.py`는 `/portfolios` prefix(v1 아님)이므로 새 라우터 파일 생성이 필요. 두 파일의 path가 완전히 달라 충돌 없음. -3. **asset_id = portfolio_id (v1 API 규약)**: `/v1/assets/{asset_id}/tags`는 catalog asset 테이블이 아니라 portfolios 테이블의 tag_ids를 조작. v1 API에서 portfolio_id를 asset_id로 노출하는 기존 규약 준수. `repo.get(asset_id)` 로 portfolio 존재 확인. -4. **소문자 정규화 위치**: POST /v1/tags 라우터에서 `name = body.name.strip().lower()`. DB는 TEXT UNIQUE 그대로(BAT-003 설계 일관성). -5. **정책 검증 위치**: 라우터에서 직접 `repo.get_tag_by_id()` 호출 후 조건 검사 → 서비스 레이어 없이 라우터 직접 처리(현재 아키텍처 패턴 유지). -6. **array_append 멱등성**: `UPDATE portfolios SET tag_ids = array_append(tag_ids, %s::uuid) WHERE portfolio_id = %s AND NOT (%s::uuid = ANY(tag_ids))` — tag_id가 이미 배열에 있으면 UPDATE 행 0건 → rowcount=0인 경우 portfolio 존재를 별도 확인, 존재하면 멱등 처리(200 반환). -7. **deactivate 멱등성**: `repo.get_tag_by_id(tag_id)` 먼저 호출 → is_active=FALSE이면 바로 현재 상태 반환(200). is_active=TRUE이면 `repo.deactivate_tag(tag_id)` 실행. tag 존재하지 않으면 404. -8. **auth permission 분기(v1 패턴)**: 모든 v1 엔드포인트는 `auth.token_source != "db"` 이면 403 반환(기존 v1_assets.py 패턴 일관성 유지). permission_dependency(Depends) 사용해 최소 권한 확인 후 라우터에서 세부 조건 추가 확인. - -## Edge Cases -- 존재하지 않는 tag_id → PATCH /deactivate → 404 (get_tag_by_id 결과 None) -- 존재하지 않는 portfolio_id → POST/DELETE /assets|portfolios/{id}/tags → 404 -- non-admin 비생성자 → PATCH /deactivate → 403 -- is_admin_tag=FALSE tag_id → POST /v1/assets/{asset_id}/tags → 403 -- is_active=FALSE tag_id → POST /v1/assets/{asset_id}/tags 또는 /portfolios → 403 -- 다른 user 생성 non-admin tag → POST /v1/portfolios/{id}/tags (소유자 토큰) → 403 -- 이미 tag_ids 배열에 있는 tag_id 재적용 → 200 멱등 반환 (array_append 조건부 + rowcount 분기) -- 이미 is_active=FALSE → PATCH /deactivate 재요청 → 200 멱등 반환 -- env token (token_source="env") → 403 DB token required (v1 패턴 일관성) - -## Architecture Impact -- both (project-structure: 신규 라우터 파일 2개, runtime-flows: 태그 HTTP 흐름 신규) - -## Reference Doc Update Plan -- project-structure.md: REQ-008 배치에서 갱신 (v1_tags.py, v1_portfolios.py 추가, pfhub tag 그룹 추가) -- runtime-flows.md: REQ-008 배치에서 갱신 (POST/GET/PATCH /v1/tags 흐름, POST/DELETE /v1/assets|portfolios/{id}/tags 흐름) - -## Baseline Comparison (vs project-structure.md / runtime-flows.md) -- project-structure.md 현재: app/api/ — v1_assets.py, v1_user_info.py, v1_ledger.py만 v1 라우터로 기록됨. v1_tags.py, v1_portfolios.py 추가 필요. -- runtime-flows.md 현재: GET /v1/assets, GET /v1/user_info, POST ledger/trades, POST /auth/service-tokens 흐름만 기록됨. tags CRUD HTTP 흐름, 태그 적용/해제 HTTP 흐름 추가 필요. -- 두 baseline 문서 갱신은 REQ-008 배치에서 수행. diff --git a/docs/batches/bat-004_20260520_tags-http-api/implementation.md b/docs/batches/bat-004_20260520_tags-http-api/implementation.md deleted file mode 100644 index ecdc1e8..0000000 --- a/docs/batches/bat-004_20260520_tags-http-api/implementation.md +++ /dev/null @@ -1,39 +0,0 @@ -# BAT-004 Implementation - -- Status: completed -- Batch: BAT-004 - -## Plan Summary -- REQ-004: v1_tags.py 신규 라우터 + TagItem/TagCreateRequest/TagListResponse 스키마 + main.py 등록 + 소문자 정규화 + 권한 분기 + 비활성화 멱등성 -- REQ-005: v1_assets.py asset 태그 라우트 + v1_portfolios.py portfolio 태그 라우트 + TagApplyRequest/TagApplyResponse 스키마 + postgres.py array_append/remove + 정책 검증 -- 전체 pytest suite 228 passed, regression 없음 - -## Changed Files -- `app/api/v1_tags.py` (신규) — POST/GET/PATCH /v1/tags -- `app/api/v1_portfolios.py` (신규) — POST/DELETE /v1/portfolios/{portfolio_id}/tags -- `app/api/v1_assets.py` — POST/DELETE /v1/assets/{asset_id}/tags 라우트 추가, TagApplyRequest/TagApplyResponse 임포트 추가 -- `app/schemas/v1.py` — TagItem, TagCreateRequest, TagListResponse, TagApplyRequest, TagApplyResponse 추가 -- `app/repositories/portfolio_repository.py` — append_portfolio_tag_id, remove_portfolio_tag_id 프로토콜 추가 -- `app/repositories/postgres.py` — append_portfolio_tag_id, remove_portfolio_tag_id 구현 추가 -- `app/main.py` — v1_tags, v1_portfolios 라우터 등록 -- `tests/test_tags_http_api.py` (신규) — 22개 테스트 - -## Execution Log -1. app/schemas/v1.py: TagItem, TagCreateRequest, TagListResponse, TagApplyRequest, TagApplyResponse 추가 -2. app/repositories/portfolio_repository.py: append_portfolio_tag_id / remove_portfolio_tag_id 프로토콜 추가 -3. app/repositories/postgres.py: append_portfolio_tag_id (array_append + idempotent rowcount 분기) / remove_portfolio_tag_id 구현 추가 -4. app/api/v1_tags.py 신규: create_tag(소문자 정규화, is_admin_tag 분기), list_tags(is_active 필터), deactivate_tag(admin/creator 검증, 멱등성) -5. app/api/v1_portfolios.py 신규: apply_portfolio_tag(소유자/admin 확인, 태그 정책 검증), remove_portfolio_tag(소유자/admin 확인) -6. app/api/v1_assets.py: apply_asset_tag(admin only, is_admin_tag=TRUE AND is_active=TRUE), remove_asset_tag(admin only) 추가 -7. app/main.py: v1_tags, v1_portfolios 라우터 등록 -8. tests/test_tags_http_api.py: 22개 AC 테스트 작성 (REQ-004 AC-1~11, REQ-005 AC-1~11) -9. pytest 전체 suite 실행 → 228 passed, 0 failed - -## Validation -- pytest 실행 결과: **228 passed, 0 failed** (2026-05-20) -- 신규 테스트: test_tags_http_api.py 22개 (전체 통과) -- 기존 regression: test_cli.py, test_v1.py, test_asset_catalog.py, test_portfolio_tags.py, test_portfolio_uuid.py, test_daemon_assignment.py, test_tags_master.py 전체 통과 - -## Remaining Risks -- PATCH /v1/tags/{tag_id}/deactivate: 비생성자 non-admin 시 403 확인은 unit test로 커버. live DB에서 auth.user_id가 올바르게 채워지는지는 운영 환경 연동 테스트 필요. -- append_portfolio_tag_id의 idempotent 처리(rowcount=0 분기): 단위 테스트로 커버되지 않았으며 live DB에서 정상 동작 확인 필요. diff --git a/docs/batches/bat-004_20260520_tags-http-api/index.md b/docs/batches/bat-004_20260520_tags-http-api/index.md deleted file mode 100644 index 1dfce5f..0000000 --- a/docs/batches/bat-004_20260520_tags-http-api/index.md +++ /dev/null @@ -1,36 +0,0 @@ -# BAT-004: tags HTTP API - -- Status: released -- Profile: standard -- Source Discovery: dcy-002 -- Included REQ: REQ-004, REQ-005 -- Excluded REQ: (없음 — dcy-002 HTTP API 2개 전부 포함) - -## Summary -- tags 마스터에 대한 HTTP API(POST/GET/PATCH /v1/tags)를 신규 라우터로 추가하고(REQ-004), asset 및 portfolio에 태그를 적용·해제하는 HTTP API(POST/DELETE /v1/assets/{id}/tags, POST/DELETE /v1/portfolios/{id}/tags)를 구현한다(REQ-005). -- 이 배치 이후 CLI 계층(REQ-007)과 baseline 문서 갱신(REQ-008)은 별도 배치에서 수행한다. - -## Scope -- In Scope: - - `app/api/v1_tags.py` 신규 라우터 (POST/GET/PATCH /v1/tags) — REQ-004 - - `app/schemas/v1.py` — TagItem, TagCreateRequest, TagListResponse, TagApplyRequest 스키마 추가 - - `app/repositories/portfolio_repository.py` — insert_tag, get_tags, deactivate_tag 등 프로토콜 보완 - - `app/repositories/postgres.py` — 태그 CRUD SQL 구현 보완, tag_ids array_append/array_remove SQL - - `app/api/v1_assets.py` — POST/DELETE /v1/assets/{asset_id}/tags 라우트 추가 — REQ-005 - - `app/api/portfolios.py` — POST/DELETE /v1/portfolios/{portfolio_id}/tags 라우트 추가 — REQ-005 - - `app/main.py` — v1_tags 라우터 등록 -- Out of Scope: - - pfhub tag CLI (→ REQ-007, 다음 배치) - - baseline 문서 갱신 (→ REQ-008, 다음 배치) - - DB 스키마 변경 (→ BAT-003 완료) - -## 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): 권한 분기(trade-write/admin/owner) 조건이 복잡 — 정책 오류 시 AC 위반 위험 -- REQ-004와 REQ-005는 같은 tags 도메인 HTTP 레이어이며 정책 검증 로직을 공유 → 함께 구현·검증 -- 선행 의존: BAT-003(release-candidate) — tags 테이블, portfolios.tag_ids, assets.tag_ids 완료 상태 diff --git a/docs/batches/bat-004_20260520_tags-http-api/planning.md b/docs/batches/bat-004_20260520_tags-http-api/planning.md deleted file mode 100644 index 496fe91..0000000 --- a/docs/batches/bat-004_20260520_tags-http-api/planning.md +++ /dev/null @@ -1,46 +0,0 @@ -# BAT-004 Planning - -- Status: confirmed -- Batch: BAT-004 -- Profile: standard - -## Included REQ -- REQ-004: tags 마스터 HTTP API (POST /v1/tags, GET /v1/tags, PATCH /v1/tags/{tag_id}/deactivate) -- REQ-005: 태그 적용·해제 HTTP API (POST/DELETE /v1/assets/{asset_id}/tags, POST/DELETE /v1/portfolios/{portfolio_id}/tags) - -## Delivery Plan -1. **REQ-004 — v1_tags.py 신규 라우터** - - `app/api/v1_tags.py` 파일 생성 — POST/GET/PATCH /v1/tags 엔드포인트 - - `app/schemas/v1.py` — TagItem, TagCreateRequest, TagListResponse 추가 - - `app/main.py` — v1_tags 라우터 등록 - - 이름 소문자 정규화(`str.lower()`)는 라우터 레이어에서 수행 - - 권한: POST는 trade-write 이상, GET은 read 이상, PATCH는 admin 또는 생성자 본인 - - 비활성화 멱등 처리(이미 is_active=FALSE → 200 반환) -2. **REQ-005 — 태그 적용·해제 라우트 추가** - - `app/api/v1_assets.py` — POST /v1/assets/{asset_id}/tags, DELETE /v1/assets/{asset_id}/tags/{tag_id} - - `app/api/portfolios.py` — POST /v1/portfolios/{portfolio_id}/tags, DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id} - - `app/schemas/v1.py` — TagApplyRequest 추가 - - `app/repositories/postgres.py` — tag_ids 배열 append/remove SQL (array_append, array_remove) - - 정책 검증: asset → is_admin_tag=TRUE AND is_active=TRUE, portfolio → is_active=TRUE AND (is_admin_tag=TRUE OR created_by_user_id=소유자) -3. **전체 pytest 실행 — regression 확인** - -## Dependencies -- Internal: - - BAT-003(release-candidate): tags 테이블, portfolios.tag_ids UUID[], assets.tag_ids UUID[], create_tag/get_tag_by_id/get_tags_by_ids/list_tags_master/deactivate_tag 프로토콜 및 postgres 구현 완료 - - app/core/auth.py: require_read, require_trade_write, require_admin 기존 permission dependency 재사용 -- External: - - 없음 - -## Design Gate -- Design Required Before Implementation: yes -- Reason: 신규 라우터 파일(v1_tags.py) 추가, 권한 분기(trade-write/admin/owner) 설계, 정책 검증 로직 위치 결정(라우터 직접 vs 서비스 레이어), array_append/remove 멱등성 처리 방식 결정이 필요하다. - -## Milestones -1. REQ-004 라우터 구현 완료 + 스키마 추가 + tests -2. REQ-005 apply/remove 라우트 구현 완료 + policy 검증 + tests -3. 전체 pytest suite 통과 (regression 없음) - -## Risks -- R-1 (Medium): 권한 분기(trade-write/admin/owner) 조건 복잡 — PATCH /deactivate에서 admin vs 생성자 본인 구분, portfolio 태그 적용에서 owner_user_id 확인 오류 가능성 -- R-2 (Low): array_append 중복 허용 여부 — 동일 tag_id를 이미 포함한 배열에 재적용 시 멱등 처리 설계 필요 -- R-3 (Low): assets.tag_ids 컬럼이 BAT-003 ensure_schema()에 추가됨 — REQ-002 live DB 마이그레이션 완료 여부 확인 필요 (테스트 환경은 ensure_schema로 자동 생성) diff --git a/docs/batches/bat-004_20260520_tags-http-api/verification.md b/docs/batches/bat-004_20260520_tags-http-api/verification.md deleted file mode 100644 index 39ccccf..0000000 --- a/docs/batches/bat-004_20260520_tags-http-api/verification.md +++ /dev/null @@ -1,70 +0,0 @@ -# BAT-004 Verification - -- Status: release-candidate -- Batch: BAT-004 - -## Acceptance Mapping - -- REQ-004: - - AC-1: POST /v1/tags {"name": "TestTag"} → HTTP 201, name="testtag"(소문자 정규화) → `test_create_tag_lowercase_normalization` - - AC-2: admin 토큰 → is_admin_tag=TRUE → `test_create_tag_admin_is_admin_tag_true` - - AC-3: non-admin trade-write 토큰 → is_admin_tag=FALSE → `test_create_tag_non_admin_is_admin_tag_false` - - AC-4: 동일 이름(대소문자 변형) 중복 → HTTP 409 → `test_create_tag_duplicate_name_returns_409` - - AC-5: GET /v1/tags → HTTP 200, 활성 태그 목록 → `test_list_tags_active_only` - - AC-6: GET /v1/tags?is_active=false → 비활성 태그만 반환 → `test_list_tags_inactive_filter` - - AC-7: admin 토큰 PATCH /deactivate → HTTP 200, is_active=FALSE → `test_deactivate_tag_admin` - - AC-8: 생성자(non-admin) 토큰 본인 태그 비활성화 → HTTP 200 → `test_deactivate_tag_creator` - - AC-9: non-admin 비생성자 PATCH /deactivate → HTTP 403 → `test_deactivate_tag_non_creator_returns_403` - - AC-10: 존재하지 않는 tag_id PATCH /deactivate → HTTP 404 → `test_deactivate_nonexistent_tag_returns_404` - - AC-11: 이미 is_active=FALSE 태그 재요청 → HTTP 200 멱등 → `test_deactivate_already_inactive_tag_idempotent` - -- REQ-005: - - AC-1: admin + admin 생성 활성 tag → POST /v1/assets/{id}/tags → HTTP 200, tag_ids 추가 → `test_apply_asset_tag_admin_success` - - AC-2: admin + is_admin_tag=FALSE tag → HTTP 403 → `test_apply_asset_tag_non_admin_tag_returns_403` - - AC-3: admin + is_active=FALSE tag → HTTP 403 → `test_apply_asset_tag_inactive_returns_403` - - AC-4: non-admin 토큰 → POST /v1/assets/{id}/tags → HTTP 403 → `test_apply_asset_tag_non_admin_token_returns_403` - - AC-5: admin → DELETE /v1/assets/{id}/tags/{tag_id} → HTTP 200, tag_id 제거 → `test_remove_asset_tag_admin_success` - - AC-6: 소유자, 본인 생성 non-admin 활성 tag → POST /v1/portfolios/{id}/tags → HTTP 200 → `test_apply_portfolio_tag_owner_own_tag` - - AC-7: 소유자, admin 생성 활성 tag → POST /v1/portfolios/{id}/tags → HTTP 200 → `test_apply_portfolio_tag_owner_admin_tag` - - AC-8: 소유자, 타 user 생성 non-admin tag → HTTP 403 → `test_apply_portfolio_tag_other_user_non_admin_tag_returns_403` - - AC-9: 소유자/admin → DELETE /v1/portfolios/{id}/tags/{tag_id} → HTTP 200 → `test_remove_portfolio_tag_owner_success` - - AC-10: 존재하지 않는 tag_id → HTTP 404 → `test_apply_portfolio_tag_nonexistent_tag_returns_404` - - AC-11: 존재하지 않는 portfolio_id → HTTP 404 → `test_apply_portfolio_tag_nonexistent_portfolio_returns_404` - -## Evidence - -- Test: - - pytest 실행 결과: **228 passed, 0 failed** (2026-05-20) - - 신규 테스트: `tests/test_tags_http_api.py` 22개 - - `test_create_tag_lowercase_normalization` - - `test_create_tag_admin_is_admin_tag_true` - - `test_create_tag_non_admin_is_admin_tag_false` - - `test_create_tag_duplicate_name_returns_409` - - `test_list_tags_active_only` - - `test_list_tags_inactive_filter` - - `test_deactivate_tag_admin` - - `test_deactivate_tag_creator` - - `test_deactivate_tag_non_creator_returns_403` - - `test_deactivate_nonexistent_tag_returns_404` - - `test_deactivate_already_inactive_tag_idempotent` - - `test_apply_asset_tag_admin_success` - - `test_apply_asset_tag_non_admin_tag_returns_403` - - `test_apply_asset_tag_inactive_returns_403` - - `test_apply_asset_tag_non_admin_token_returns_403` - - `test_remove_asset_tag_admin_success` - - `test_apply_portfolio_tag_owner_own_tag` - - `test_apply_portfolio_tag_owner_admin_tag` - - `test_apply_portfolio_tag_other_user_non_admin_tag_returns_403` - - `test_remove_portfolio_tag_owner_success` - - `test_apply_portfolio_tag_nonexistent_tag_returns_404` - - `test_apply_portfolio_tag_nonexistent_portfolio_returns_404` - - 기존 regression 전체 통과: test_cli.py, test_v1.py, test_asset_catalog.py, test_portfolio_tags.py, test_portfolio_uuid.py, test_daemon_assignment.py, test_tags_master.py -- Manual (pre-release 수동 검증 항목 — live DB 연동 시): - - POST /v1/tags 실제 DB token으로 소문자 정규화 확인 - - append_portfolio_tag_id idempotent 처리(tag_id 중복 적용 시 200 반환) 확인 -- Logs: N/A - -## Result -- Ready for Release: true -- Blocking Issues: 없음 -- Pre-release 수동 검증: live DB 연동 환경에서 auth.user_id 정상 채움 확인, append idempotent 처리 확인 diff --git a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/design.md b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/design.md deleted file mode 100644 index c5929de..0000000 --- a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/design.md +++ /dev/null @@ -1,44 +0,0 @@ -# BAT-005 Design - -- Status: confirmed -- Batch: BAT-005 - -## Architecture Summary -- REQ-007: `app/cli/commands/tag.py` 신규 파일에 `tag_app = typer.Typer()` 생성 후 `pfhub tag` 7개 커맨드 구현. CLI는 HTTP API를 거치지 않고 기존 repo 메서드를 직접 호출(현재 CLI 아키텍처 패턴 유지). apply/remove는 HTTP 오류를 repo ValueError로 전환해 표시. -- `pfhub portfolio tag add/list/remove`는 `portfolio.py`에서 완전 삭제(deprecated 래퍼 없음) — 기존 테스트(test_portfolio_tags.py) 조정 필요. -- REQ-008: 4개 baseline 문서를 코드 현황에 맞게 갱신. 모듈/테스트 변경 없음. - -## Changed Areas -- Docs: - - `docs/data-model.md` — tags 테이블 스키마, portfolios.tag_ids, portfolio_tags DROP 반영 - - `docs/interface-contract.md` — /v1/tags, /v1/assets/{id}/tags, /v1/portfolios/{id}/tags 명세 추가 - - `docs/project-structure.md` — tag.py 신규, pfhub tag 커맨드 그룹 추가, pfhub portfolio tag 제거 - - `docs/runtime-flows.md` — pfhub tag CLI 흐름 신규, /v1/tags HTTP 흐름 신규 -- Modules: - - `app/cli/commands/tag.py` — 신규 파일 - - `app/cli/main.py` — tag_app 등록 - - `app/cli/commands/portfolio.py` — tag_app, tag_add, tag_list, tag_remove 완전 삭제 - - `tests/test_portfolio_tags.py` — pfhub portfolio tag CLI 테스트 제거 또는 조정 -- Interfaces: - - 신규: pfhub tag create/list/deactivate/apply/remove 7개 커맨드 - - 제거: pfhub portfolio tag add/list/remove 3개 커맨드 - -## Key Decisions -1. **HTTP API 직접 호출 vs repo 메서드**: 기존 CLI 아키텍처는 AppContext를 통해 repo를 주입받아 직접 repo 메서드를 호출. pfhub tag CLI도 동일 패턴 유지 — HTTP 클라이언트 별도 없음. apply/remove는 `repo.append_portfolio_tag_id` / `repo.remove_portfolio_tag_id` 직접 호출, 정책 검증은 repo가 아닌 HTTP API 레이어에서 하는 것이 원칙이나 CLI는 repo 직접 호출이므로 정책 검증 로직을 CLI 레이어에서도 수행. -2. **pfhub portfolio tag 완전 삭제**: deprecated 래퍼 없이 완전 제거. REQ-007 AC-9 "명령어가 존재하지 않는다(Typer error)" 충족. -3. **apply/remove sub-group**: `pfhub tag apply`와 `pfhub tag remove`는 각각 `asset`/`portfolio` 두 sub-command를 갖는 nested Typer group으로 구현. -4. **--force 확인**: `pfhub tag deactivate`, `pfhub tag remove` 두 커맨드에서 `--force` 플래그가 없으면 실행 거부(기존 CLI 패턴 일관성). -5. **test_portfolio_tags.py 조정**: 기존 `pfhub portfolio tag add/list/remove` 테스트를 제거. 이 파일의 나머지 테스트(존재하면)는 유지. - -## Edge Cases -- `pfhub tag create ` 중복 이름 → repo ValueError → CLI 오류 메시지 출력 후 non-zero exit -- `pfhub tag deactivate ` --force 없이 실행 → "확인이 필요합니다" 오류 -- `pfhub tag apply asset/portfolio` 정책 위반 → repo/HTTP 403 오류 → CLI 오류 메시지 출력 -- `pfhub portfolio tag add/list/remove` 실행 → Typer "No such command" 오류 (제거 후) - -## Architecture Impact -- both (project-structure: tag.py 신규, runtime-flows: pfhub tag 흐름 신규) - -## Reference Doc Update Plan -- project-structure.md: REQ-008에서 직접 갱신 (이 배치 내부에서 처리) -- runtime-flows.md: REQ-008에서 직접 갱신 (이 배치 내부에서 처리) diff --git a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/implementation.md b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/implementation.md deleted file mode 100644 index 63ff610..0000000 --- a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/implementation.md +++ /dev/null @@ -1,37 +0,0 @@ -# BAT-005 Implementation - -- Status: completed -- Batch: BAT-005 - -## Plan Summary -- REQ-007: tag.py 신규 CLI 커맨드 그룹 + main.py 등록 + portfolio.py tag 커맨드 제거 -- REQ-008: baseline 문서 4개 갱신 (data-model, interface-contract, project-structure, runtime-flows) - -## Changed Files -- `app/cli/commands/tag.py` — 신규: tag_app (create/list/deactivate), apply_app (asset/portfolio), remove_app (asset/portfolio) 7개 커맨드 -- `app/cli/main.py` — tag_app import 및 `app.add_typer(tag_app, name="tag")` 추가 -- `app/cli/commands/portfolio.py` — tag_app Typer, tag_add, tag_list, tag_remove 완전 삭제; PortfolioTagMutationRequest import 제거 -- `app/cli/core/utils.py` — print_tag_table() 추가; render_output()에 list[Tag] 분기 추가 -- `tests/test_cli.py` — test_cli_tag_add_and_list, test_cli_tag_remove_flow 제거 -- `tests/test_tags_cli.py` — 신규: FakeTagRepo 기반 24개 테스트 (REQ-007 AC-1~9 커버) -- `docs/data-model.md` — REQ-008 AC-1: Entity Tag 신규, portfolios.tag_ids, portfolio_tags 제거 반영 -- `docs/interface-contract.md` — REQ-008 AC-2: Tag Management API 섹션 신규 (7개 엔드포인트) -- `docs/project-structure.md` — REQ-008 AC-3: tag.py/v1_tags.py/v1_portfolios.py/tag schemas 반영 -- `docs/runtime-flows.md` — REQ-008 AC-4: pfhub tag CLI 흐름, /v1/tags, /v1/assets|portfolios/{id}/tags 흐름 신규 - -## Execution Log -1. planning.md, design.md status → confirmed; index.md, docs/batches/index.md → in-delivery -2. app/cli/commands/tag.py 신규 생성 (태그 7개 커맨드, _with_repo 패턴) -3. app/cli/main.py에 tag_app 등록 -4. app/cli/commands/portfolio.py에서 tag_app 관련 코드 완전 삭제 -5. app/cli/core/utils.py에 print_tag_table + list[Tag] render 분기 추가 -6. tests/test_cli.py에서 test_cli_tag_add_and_list, test_cli_tag_remove_flow 제거 -7. tests/test_tags_cli.py 신규 생성 (FakeTagRepo, 24개 테스트) -8. pytest 247 passed 확인 -9. docs/data-model.md, interface-contract.md, project-structure.md, runtime-flows.md 갱신 - -## Validation -- pytest 247 passed (8.47s) — regression 없음, 신규 24개 테스트 전체 통과 - -## Remaining Risks -- 없음 — REQ-007, REQ-008 구현 및 검증 완료 diff --git a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/index.md b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/index.md deleted file mode 100644 index cc8bbd6..0000000 --- a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/index.md +++ /dev/null @@ -1,35 +0,0 @@ -# BAT-005: pfhub tag CLI 및 baseline docs 갱신 - -- Status: released -- Profile: standard -- Source Discovery: dcy-002 -- Included REQ: REQ-007, REQ-008 -- Excluded REQ: (없음 — dcy-002 잔여 2개 전부 포함) - -## Summary -- tags 마스터 기반의 `pfhub tag` 최상위 CLI 커맨드 그룹을 신규 추가하고(REQ-007), 기존 `pfhub portfolio tag add/list/remove` 커맨드를 제거한다. 이후 dcy-002 전체 구현 완료를 반영해 baseline 문서 4개를 갱신한다(REQ-008). -- 이 배치가 완료되면 dcy-002(태그 소유권 및 적용 대상 정책) 관련 REQ-001~008 전체 구현이 종료된다. - -## Scope -- In Scope: - - `app/cli/commands/tag.py` 신규 파일 — `pfhub tag create/list/deactivate/apply/remove` 커맨드 - - `app/cli/main.py` — `tag_app` 등록 추가 (REQ-007) - - `app/cli/commands/portfolio.py` — `tag_app`, `tag_add`, `tag_list`, `tag_remove` 제거 (REQ-007) - - `docs/data-model.md` — tags 테이블 스키마, portfolios.tag_ids, portfolio_tags 제거 반영 (REQ-008) - - `docs/interface-contract.md` — /v1/tags, /v1/assets/{id}/tags, /v1/portfolios/{id}/tags 명세 추가 (REQ-008) - - `docs/project-structure.md` — pfhub tag 커맨드 그룹 추가, pfhub portfolio tag 제거 반영 (REQ-008) - - `docs/runtime-flows.md` — pfhub tag CLI 흐름, /v1/tags HTTP 흐름 추가 (REQ-008) -- Out of Scope: - - HTTP API 변경 (→ BAT-004 완료) - - DB 스키마 변경 (→ BAT-003 완료) - -## 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): `pfhub portfolio tag add/list/remove` 제거는 기존 운영 스크립트에 breaking change — 운영자 사전 고지 필요 -- REQ-007 선행 의존: REQ-004(tags HTTP API), REQ-005(태그 적용/해제 HTTP API) 모두 BAT-004(release-candidate) 완료 상태 -- REQ-008은 REQ-001~007 전체 구현 완료 후 일괄 반영 → 이 배치가 마지막 실행 단계 diff --git a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/planning.md b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/planning.md deleted file mode 100644 index a25327b..0000000 --- a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/planning.md +++ /dev/null @@ -1,50 +0,0 @@ -# BAT-005 Planning - -- Status: confirmed -- Batch: BAT-005 -- Profile: standard - -## Included REQ -- REQ-007: pfhub tag CLI 명령어 (pfhub tag create/list/deactivate/apply/remove + pfhub portfolio tag 제거) -- REQ-008: 태그 모델 도입 baseline 문서 갱신 (data-model, interface-contract, project-structure, runtime-flows) - -## Delivery Plan -1. **REQ-007 — pfhub tag CLI 신규 구현** - - `app/cli/commands/tag.py` 신규 파일 생성 - - `pfhub tag create ` — POST /v1/tags 호출 (trade-write) - - `pfhub tag list [--inactive]` — GET /v1/tags 호출 (is_active 필터) - - `pfhub tag deactivate --force` — PATCH /v1/tags/{tag_id}/deactivate 호출 - - `pfhub tag apply asset ` — POST /v1/assets/{asset_id}/tags 호출 (admin) - - `pfhub tag apply portfolio ` — POST /v1/portfolios/{portfolio_id}/tags 호출 - - `pfhub tag remove asset --force` — DELETE /v1/assets/{asset_id}/tags/{tag_id} 호출 - - `pfhub tag remove portfolio --force` — DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id} 호출 - - `app/cli/main.py` — tag_app 등록 (`app.add_typer(tag_app, name="tag")`) - - `app/cli/commands/portfolio.py` — tag_app Typer, tag_add, tag_list, tag_remove 제거 -2. **REQ-008 — baseline 문서 갱신** - - `docs/data-model.md` — tags 테이블 스키마, portfolios.tag_ids UUID[], portfolio_tags 제거 반영 - - `docs/interface-contract.md` — /v1/tags (POST/GET/PATCH), /v1/assets/{id}/tags (POST/DELETE), /v1/portfolios/{id}/tags (POST/DELETE) 명세 추가 - - `docs/project-structure.md` — app/cli/commands/tag.py 추가, pfhub tag 커맨드 그룹 추가, portfolio tag 제거 반영 - - `docs/runtime-flows.md` — pfhub tag CLI 흐름, /v1/tags HTTP 흐름 추가 -3. **전체 pytest 실행 — regression 확인** - -## Dependencies -- Internal: - - BAT-003(release-candidate): tags 테이블, portfolios.tag_ids, Tag 도메인 모델 완료 - - BAT-004(release-candidate): POST/GET/PATCH /v1/tags, POST/DELETE /v1/assets|portfolios/{id}/tags HTTP API 완료 - - 기존 CLI 패턴(AppContext 통해 settings/repository 주입, --force 확인) 준수 -- External: - - 없음 - -## Design Gate -- Design Required Before Implementation: yes -- Reason: 기존 `pfhub portfolio tag add/list/remove` 커맨드 제거 방식(완전 삭제 vs deprecated 표시), tag.py 커맨드 구조(apply/remove sub-group 설계), HTTP API 호출 방식(직접 repo 메서드 vs HTTP client) 결정 필요. - -## Milestones -1. REQ-007 CLI 구현 완료 + pfhub portfolio tag 제거 + tests -2. REQ-008 baseline 문서 4개 갱신 완료 -3. 전체 pytest suite 통과 (regression 없음) - -## Risks -- R-1 (Medium): `pfhub portfolio tag add/list/remove` 제거는 운영 환경 breaking change — 기존 스크립트/자동화 사용 여부 사전 확인 권고 -- R-2 (Low): pfhub tag CLI는 HTTP API를 직접 호출하지 않고 repo 메서드를 통해 구현 — repo 메서드가 이미 BAT-003/004에서 완성되어 있으므로 위험 낮음 -- R-3 (Low): REQ-007 AC-9(pfhub portfolio tag 제거 확인) 테스트 패턴 — CLI 테스트 파일에서 명령어 부재 확인 방식 설계 필요 diff --git a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/verification.md b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/verification.md deleted file mode 100644 index 6f4d336..0000000 --- a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/verification.md +++ /dev/null @@ -1,40 +0,0 @@ -# BAT-005 Verification - -- Status: release-candidate -- Batch: BAT-005 - -## Acceptance Mapping - -- REQ-007: - - AC-1: `pfhub tag create "코스피200"` → 태그 생성 성공 → `test_tag_create_success`, `test_tag_create_with_user` - - AC-2: `pfhub tag list` → 활성 태그 목록만 출력 → `test_tag_list_shows_active_tags` - - AC-3: `pfhub tag list --inactive` → 비활성 포함 전체 목록 출력 → `test_tag_list_inactive_shows_all` - - AC-4: `pfhub tag deactivate --force` → 비활성화; 재실행 멱등 → `test_tag_deactivate_with_force`, `test_tag_deactivate_already_inactive_is_idempotent`, `test_tag_deactivate_without_force_aborts`, `test_tag_deactivate_nonexistent_fails` - - AC-5: `pfhub tag apply asset ` → admin+active 태그만 허용 → `test_tag_apply_asset_success`, `test_tag_apply_asset_non_admin_tag_fails`, `test_tag_apply_asset_inactive_tag_fails` - - AC-6: `pfhub tag apply portfolio ` → admin 태그 또는 소유자 태그만 허용 → `test_tag_apply_portfolio_admin_tag_success`, `test_tag_apply_portfolio_owner_tag_success`, `test_tag_apply_portfolio_policy_violation_fails` - - AC-7: `pfhub tag remove asset --force` → 태그 제거 → `test_tag_remove_asset_with_force`, `test_tag_remove_asset_without_force_aborts` - - AC-8: `pfhub tag remove portfolio --force` → 태그 제거 → `test_tag_remove_portfolio_with_force` - - AC-9: `pfhub portfolio tag add/list/remove` → 명령어 없음(Typer error) → `test_portfolio_tag_subcommand_does_not_exist`, `test_portfolio_tag_list_does_not_exist`, `test_portfolio_tag_remove_does_not_exist` - -- REQ-008: - - AC-1: docs/data-model.md에 tags 테이블 스키마, portfolios.tag_ids UUID[], portfolio_tags 제거 반영 → 파일 내용 검증 완료 - - AC-2: docs/interface-contract.md에 /v1/tags (POST/GET/PATCH), /v1/assets/{id}/tags, /v1/portfolios/{id}/tags 명세 포함 → 파일 내용 검증 완료 - - AC-3: docs/project-structure.md에 pfhub tag 커맨드 그룹, app/cli/commands/tag.py 반영 → 파일 내용 검증 완료 - - AC-4: docs/runtime-flows.md에 pfhub tag CLI 흐름, /v1/tags HTTP 흐름, /v1/assets|portfolios/{id}/tags 흐름 포함 → 파일 내용 검증 완료 - -## Evidence -- Test: - - pytest 실행 결과: 247 passed (tests/test_tags_cli.py 24개 신규 포함, tests/test_cli.py 기존 32개 유지) - - 신규 테스트 파일: tests/test_tags_cli.py (REQ-007 AC-1~9 커버, FakeTagRepo 기반) - - regression: test_cli_tag_add_and_list, test_cli_tag_remove_flow 제거 (pfhub portfolio tag 커맨드 제거와 함께) -- Manual: - - docs/data-model.md: Entity Tag 섹션 신규, portfolios.tag_ids UUID[] 추가, portfolio_tags 제거 반영 - - docs/interface-contract.md: Tag Management API 섹션 신규 (7개 엔드포인트 명세) - - docs/project-structure.md: app/cli/commands/tag.py, app/api/v1_tags.py/v1_portfolios.py, v1.py tag schemas 반영 - - docs/runtime-flows.md: pfhub tag CLI 흐름, v1 tag management 흐름, v1 tag apply/remove 흐름 신규 -- Logs: - - `.venv/bin/python -m pytest -q` → 247 passed in 8.47s - -## Result -- Ready for Release: true -- Blocking Issues: 없음 diff --git a/docs/batches/bat-006_20260520_token-surface-code-fixes/design.md b/docs/batches/bat-006_20260520_token-surface-code-fixes/design.md deleted file mode 100644 index 201a954..0000000 --- a/docs/batches/bat-006_20260520_token-surface-code-fixes/design.md +++ /dev/null @@ -1,47 +0,0 @@ -# BAT-006 Design - -- Status: confirmed -- Batch: BAT-006 - -## Architecture Summary -- 세 가지 변경 모두 `app/api/auth_routes.py`·`app/services/auth_service.py`·`app/repositories/postgres.py` auth 계층에 집중된다. -- REQ-017: DB index 보강 (Alembic migration + ensure_schema 동기화). 애플리케이션 로직 변경 없음. rotate 코드(`auth_service.py:rotate_service_token`, `postgres.py:1502-1549`) 그대로 유지. -- REQ-018: HTTP route handler 1-line 추가. service 계층·도메인 모델 변경 없음. -- REQ-016: HTTP 라우트, service 메서드, 스키마 3-layer 제거. 의존 테스트 동시 정리. - -## Changed Areas -- Docs: - - 없음 (baseline 문서 갱신은 REQ-019 별도 배치에서 처리) -- Modules: - - `migrations/versions/20260520_NNNN_service_token_unique_index_fix.py` — 신규 Alembic revision (REQ-017) - - `app/repositories/postgres.py:161-167` — `ensure_schema()` index 정의 조건 갱신 (REQ-017) - - `app/api/auth_routes.py` — L131-137 `daemon_id=payload.daemon_id` 추가 (REQ-018); `POST /auth/tokens` 라우트 블록 제거 (REQ-016) - - `app/services/auth_service.py` — `issue_client_token`, 관련 데이터 클래스 제거 (REQ-016) - - `app/schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 제거 (REQ-016) -- Tests: - - `tests/test_service_token_rotate_integration.py` (신규, 실 DB 의존 마킹 — `@pytest.mark.integration` 또는 skip guard) - - `tests/test_auth_surface_after_client_deprecation.py` (신규) - - `tests/test_auth.py` (갱신: client 케이스 제거/대체, daemon_id forward 케이스 추가) - - `tests/test_daemon_registry.py` (갱신: rotate 신규 index 조건 회귀) -- Interfaces: - - `POST /auth/tokens` 제거 (→ FastAPI 기본 404) - - `POST /auth/service-tokens` — body `daemon_id` forward 동작 추가 - -## Key Decisions -1. **CONCURRENTLY migration 실행 방식**: `op.execute()` 직접 SQL + `connection.execution_options(isolation_level="AUTOCOMMIT")` 래핑. 기존 migration 패턴(`20260423_0013`)에서 사용한 `op.execute()` 직접 SQL 패턴을 그대로 사용하되 CONCURRENTLY는 autocommit 필요. downgrade에서는 `DROP INDEX IF EXISTS`(non-CONCURRENTLY). -2. **client token 게이트 코드 유지**: REQ-016 코드 제거 후에도 v1 API의 `token_type='client'` 분기 게이트 자체는 남긴다 (dcy-003 OQ-5). `test_portfolio_ownership.py`의 `token_type="client"` TokenPrincipal 픽스처는 게이트 동작 검증이므로 그대로 유지. -3. **REQ-016 제거 대상 정확 범위**: `auth_routes.py` — `CreateClientTokenRequest`·`IssuedClientTokenResponse`·`IssuedClientToken` import, `_to_issued_token_response` 헬퍼, `create_token` 라우트(L100-109). `auth_service.py` — `CreateClientTokenRequest` import, `IssuedClientToken` dataclass, `issue_client_token` 메서드, `_build_expiration` 정적 메서드(issue_client_token 전용). `schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 클래스. `tests/test_users.py` — `test_login_and_issue_client_token_flow` 제거. -4. **REQ-017 데이터 변경 없음**: 기존 active/revoked service token 행은 변경 없음. 신규 partial index가 기존 데이터에 자동 적용되므로 backfill 불요. -5. **REQ-018 1-line 위치 확정**: `auth_routes.py:131-137` `issue_service_token()` 호출에 `daemon_id=payload.daemon_id` 추가. `IssueServiceTokenRequest.daemon_id`(스키마 L80) 및 `AuthService.issue_service_token(daemon_id=)`(서비스 L120) 모두 이미 존재 — 라우트 전달 누락만 보정. - -## Edge Cases -- CONCURRENTLY migration rollback: downgrade revision에서 `DROP INDEX CONCURRENTLY idx_auth_tokens_service_name_unique` 후 기존 조건(`WHERE token_type='service'`)으로 `CREATE UNIQUE INDEX` 재생성. -- REQ-016 잔여 참조: `grep -rn "issue_client_token\|CreateClientTokenRequest\|IssuedClientTokenResponse"` 결과 0건이 AC-1 기준. 주석, import, 테스트 픽스처 모두 포함해서 제거. -- REQ-018 비존재 daemon_id: `AuthService.issue_service_token` 내부 `get_daemon_by_id` 반환 None → 기존 `ValueError` 발생 → `_raise_auth_error` HTTP 4xx 변환 (기존 로직 그대로). - -## Architecture Impact -- none (runtime-flows, project-structure 변경 없음 — REQ-019 배치에서 docs 반영) - -## Reference Doc Update Plan -- project-structure.md: none (이 배치에서 변경 없음) -- runtime-flows.md: none (이 배치에서 변경 없음) diff --git a/docs/batches/bat-006_20260520_token-surface-code-fixes/implementation.md b/docs/batches/bat-006_20260520_token-surface-code-fixes/implementation.md deleted file mode 100644 index 6a401b9..0000000 --- a/docs/batches/bat-006_20260520_token-surface-code-fixes/implementation.md +++ /dev/null @@ -1,39 +0,0 @@ -# BAT-006 Implementation - -- Status: completed -- Batch: BAT-006 - -## Plan Summary -- REQ-017: rotate unique index 보강 Alembic migration + ensure_schema 갱신 -- REQ-018: POST /auth/service-tokens daemon_id forward 1-line fix -- REQ-016: POST /auth/tokens 라우트·서비스·스키마 코드 제거 - -## Changed Files -- `migrations/versions/20260520_0022_service_token_unique_index_fix.py` — 신규 Alembic revision: DROP + CREATE UNIQUE INDEX CONCURRENTLY `WHERE token_type='service' AND revoked_at IS NULL` (REQ-017) -- `app/repositories/postgres.py` — `ensure_schema()` idx 정의 조건 갱신 `WHERE token_type='service' AND revoked_at IS NULL` (REQ-017) -- `app/api/auth_routes.py` — `daemon_id=payload.daemon_id` 추가 (REQ-018); `CreateClientTokenRequest`·`IssuedClientTokenResponse`·`IssuedClientToken` import 제거, `_to_issued_token_response` 헬퍼 제거, `create_token` 라우트 제거 (REQ-016) -- `app/services/auth_service.py` — `CreateClientTokenRequest` import 제거, `IssuedClientToken` dataclass 제거, `issue_client_token` 메서드 제거, `_build_expiration` 정적 메서드 제거 (REQ-016) -- `app/schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 클래스 제거 (REQ-016) -- `tests/test_auth.py` — `get_daemon_by_id` 추가 to `_AuthRepo`; REQ-018 AC-1~3 서비스 계층 테스트 3개 추가; REQ-016 AC-2 HTTP 404/405 테스트 추가 -- `tests/test_auth_surface_after_client_deprecation.py` — 신규: REQ-016 AC-1 grep 기반 잔여 심볼 검증 3개, AC-4 importlib 기반 심볼 제거 검증 2개 -- `tests/test_users.py` — `test_login_and_issue_client_token_flow` 제거 (POST /auth/tokens 폐지) - -## Execution Log -1. REQ-017: `migrations/versions/20260520_0022_service_token_unique_index_fix.py` 신규 작성 (CONCURRENTLY + autocommit 패턴) -2. REQ-017: `app/repositories/postgres.py:ensure_schema()` index 조건 갱신 -3. REQ-018: `app/api/auth_routes.py` `issue_service_token()` 호출에 `daemon_id=payload.daemon_id` 추가 -4. REQ-016: `app/schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 제거 -5. REQ-016: `app/services/auth_service.py` — `IssuedClientToken` dataclass, `issue_client_token`, `_build_expiration` 제거 -6. REQ-016: `app/api/auth_routes.py` — import, `_to_issued_token_response`, `create_token` 라우트 제거 -7. `tests/test_users.py` — `test_login_and_issue_client_token_flow` 제거 -8. `tests/test_auth.py` — `_AuthRepo.get_daemon_by_id` 추가; REQ-018 AC-1~3 테스트 추가; REQ-016 AC-2 HTTP 테스트 추가 (404/405 허용) -9. `tests/test_auth_surface_after_client_deprecation.py` 신규 생성 (REQ-016 AC-1/4) -10. pytest 255 passed 확인 - -## Validation -- pytest 255 passed (8.13s) — regression 없음 -- 신규 테스트: test_auth.py +4개, test_auth_surface_after_client_deprecation.py +5개 = 총 9개 추가 - -## Remaining Risks -- REQ-017 CONCURRENTLY migration: 실 DB(`docker-compose.local-test.yml`) 환경에서 AC-1~3 검증 미완료 (실 DB 없는 환경에서 단위 테스트만 수행) -- REQ-016 AC-3: release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` → COUNT=0 재확인은 release plan evidence로 남김 diff --git a/docs/batches/bat-006_20260520_token-surface-code-fixes/index.md b/docs/batches/bat-006_20260520_token-surface-code-fixes/index.md deleted file mode 100644 index c18b17c..0000000 --- a/docs/batches/bat-006_20260520_token-surface-code-fixes/index.md +++ /dev/null @@ -1,39 +0,0 @@ -# BAT-006: token surface 코드 수정 (client 폐지 / rotate index 보강 / daemon_id forward 보정) - -- Status: released -- Profile: standard -- Source Discovery: dcy-004 -- Included REQ: REQ-016, REQ-017, REQ-018 -- Excluded REQ: (없음 — dcy-004 코드 수정 3건 전부 포함) - -## Summary -- service token rotate 실 DB 실패 결함(REQ-017), HTTP daemon_id silently drop 결함(REQ-018), client token 발급 경로 코드 폐지(REQ-016) 세 가지 auth/token surface 코드 수정을 하나의 배치로 묶어 함께 배포한다. -- 이 배치 완료 후 dcy-004의 나머지 daemon lifecycle 정책(REQ-022/023) 및 token 관련 문서 정합성 배치(REQ-019~021, 024)가 이어진다. - -## Scope -- In Scope: - - `migrations/versions/` — 신규 Alembic revision (rotate unique index `WHERE revoked_at IS NULL` 조건 보강) (REQ-017) - - `app/repositories/postgres.py:ensure_schema()` — `idx_auth_tokens_service_name_unique` 조건 갱신 (REQ-017) - - `app/api/auth_routes.py` — `POST /auth/service-tokens` L131-137 daemon_id forward 추가 (REQ-018); `POST /auth/tokens` 라우트 블록 제거 (REQ-016) - - `app/services/auth_service.py` — `issue_client_token`, client 관련 헬퍼 제거 (REQ-016) - - `app/schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 제거 (REQ-016) - - `tests/test_service_token_rotate_integration.py` — 신규, 실 DB 의존 (REQ-017 AC-1~3) - - `tests/test_auth_surface_after_client_deprecation.py` — 신규 (REQ-016 AC-1/2/4) - - `tests/test_auth.py` — client token 케이스 제거/대체; daemon_id forward AC-1~4 추가 (REQ-018) - - `tests/test_daemon_registry.py` — rotate 신규 index 조건 회귀 확인 (REQ-017 AC-4) -- Out of Scope: - - daemon lifecycle 정책 변경 (→ REQ-022/023, 별도 배치) - - token 관련 baseline 문서 갱신 (→ REQ-019, 별도 배치) - - CLI service-token 동작 (변경 없음) - -## 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): REQ-017 `CREATE INDEX CONCURRENTLY`는 autocommit block 외부 실행 필요 — Alembic `op.execute` 직접 SQL + `isolation_level="AUTOCOMMIT"` 패턴 -- R-2 (Medium): REQ-016 코드 제거 전 release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` 재확인 필수 (현재 0건, release plan AC-3) -- R-3 (Low): REQ-018 fix 후 기존 test_auth.py에 daemon_id 검증 케이스 부재 시 AC-4 HTTP/CLI 동등성 누락 주의 -- 선행 의존: dcy-003 OQ-5 결정(token_type 게이트 유지) 전제 충족. BAT-003~005 모두 release-candidate 상태로 tag 관련 의존 충족 diff --git a/docs/batches/bat-006_20260520_token-surface-code-fixes/planning.md b/docs/batches/bat-006_20260520_token-surface-code-fixes/planning.md deleted file mode 100644 index 8cbf16a..0000000 --- a/docs/batches/bat-006_20260520_token-surface-code-fixes/planning.md +++ /dev/null @@ -1,46 +0,0 @@ -# BAT-006 Planning - -- Status: confirmed -- Batch: BAT-006 -- Profile: standard - -## Included REQ -- REQ-016: client token 발급 경로 코드 폐지 (Interface, High) -- REQ-017: service-token rotate unique index 보강 (Migration, High) -- REQ-018: POST /auth/service-tokens daemon_id forward 보정 (Interface, High) - -## Delivery Plan -1. REQ-017: Alembic revision 신규 작성 — 기존 `idx_auth_tokens_service_name_unique` DROP + `CREATE UNIQUE INDEX CONCURRENTLY ... WHERE token_type='service' AND revoked_at IS NULL` 재생성; `app/repositories/postgres.py:161-167` `ensure_schema()` index 조건 동기화 -2. REQ-018: `app/api/auth_routes.py:131-137` `issue_service_token()` 호출에 `daemon_id=payload.daemon_id` 추가 -3. REQ-016: `POST /auth/tokens` 라우트 블록 제거; `app/services/auth_service.py:issue_client_token` 및 관련 헬퍼 제거; `app/schemas/auth.py:CreateClientTokenRequest`·`IssuedClientTokenResponse` 제거 -4. 테스트: - - `tests/test_service_token_rotate_integration.py` 신규 (REQ-017 AC-1~3, 실 DB `docker-compose.local-test.yml` 의존) - - `tests/test_auth.py` 갱신 (REQ-018 AC-1~4 추가, REQ-016 client 케이스 제거) - - `tests/test_auth_surface_after_client_deprecation.py` 신규 (REQ-016 AC-1/2/4 검증) - - `tests/test_daemon_registry.py` 갱신 (REQ-017 AC-4 rotate 회귀) - -## Dependencies -- Internal: - - dcy-003 OQ-5 결정: `token_type='client'` 게이트는 REQ-016 코드 제거 이후에도 v1 API에 유지 (env token 침투 방어) - - REQ-017 결함 증거: REL-006 verification 실패 기록 (`temp/20260519_old_sdlc/releases/rel-006_...`) - - `app/api/auth_routes.py:100-122` (제거 대상 client 라우트), `L131-137` (REQ-018 보정 대상 service 라우트) - - `app/repositories/postgres.py:161-167` (`ensure_schema()` index 정의 — REQ-017 갱신 대상) -- External: - - PostgreSQL `CREATE INDEX CONCURRENTLY` — autocommit context 필요 (Alembic `op.execute` 직접 SQL) - - 실 DB 환경 `docker-compose.local-test.yml` — REQ-017 AC-1~3 검증용 - -## Design Gate -- Design Required Before Implementation: yes -- Reason: REQ-017 CONCURRENTLY migration 실행 패턴(Alembic autocommit handling), REQ-016 코드 제거 범위(service 메서드·스키마·테스트 경계), token_type 게이트 유지 범위를 design.md에서 확정해야 구현 시 누락 없이 진행 가능 - -## Milestones -1. design.md confirmed — migration 패턴, 제거 대상 목록, 테스트 전략 확정 -2. REQ-017 migration 작성 + ensure_schema 갱신 + rotate 회귀 통과 -3. REQ-018 1-line fix + HTTP daemon_id forward 테스트 통과 -4. REQ-016 코드 제거 + grep AC-1 검증 (0건) + 회귀 suite 전체 통과 -5. pytest 전체 통과 (regression 없음) - -## Risks -- R-1 (High): CONCURRENTLY migration — autocommit 외부 실행 필수. Alembic `connection.execution_options(isolation_level="AUTOCOMMIT")` 또는 `op.execute()` 직접 패턴 설계 필요. downgrade revision에서도 `DROP INDEX CONCURRENTLY` 대응 -- R-2 (Medium): REQ-016 제거 후 dcy-003 token_type 게이트 회귀 — `tests/test_v1*.py` 중 client token 픽스처가 있으면 함께 제거/대체 필요. grep으로 잔여 참조 0건 확인 -- R-3 (Low): REQ-018 fix 후 기존 test_auth.py에 daemon_id 검증 케이스 부재 시 AC-4 HTTP/CLI 동등성 누락 — 신규 테스트 케이스로 해소 diff --git a/docs/batches/bat-006_20260520_token-surface-code-fixes/verification.md b/docs/batches/bat-006_20260520_token-surface-code-fixes/verification.md deleted file mode 100644 index d4f5542..0000000 --- a/docs/batches/bat-006_20260520_token-surface-code-fixes/verification.md +++ /dev/null @@ -1,43 +0,0 @@ -# BAT-006 Verification - -- Status: release-candidate -- Batch: BAT-006 - -## Acceptance Mapping - -- REQ-017: - - AC-1: rotate 시퀀스(issue → rotate) 실 DB 성공, unique 위반 없음 → `tests/test_service_token_rotate_integration.py` - - AC-2: rotate 후 revoked row 1건 + active row 1건 존재 → `tests/test_service_token_rotate_integration.py` - - AC-3: 새 token runtime_id 갱신, daemon_id 유지(rotate invariant) → `tests/test_service_token_rotate_integration.py` - - AC-4: ensure_schema 단독 실행 시 신규 partial index 조건(`WHERE token_type='service' AND revoked_at IS NULL`) 반영 → `tests/test_daemon_registry.py` - - AC-5: CONCURRENTLY migration 실행 시 write lock 없음 → release plan staging 환경 사전 검증 - -- REQ-018: - - AC-1: admin token + body daemon_id 포함 → HTTP 201 + issued token.daemon_id 일치 → `tests/test_auth.py` - - AC-2: 비존재 daemon_id body → HTTP 4xx + 오류 메시지 → `tests/test_auth.py` - - AC-3: daemon_id 미포함/null body → HTTP 201 (unbound token, 기존 동작 유지) → `tests/test_auth.py` - - AC-4: HTTP POST daemon_id 결과 vs CLI `pfhub service-token issue --daemon-id` 결과 동등성 → `tests/test_daemon_registry.py` - -- REQ-016: - - AC-1: `grep -rn "issue_client_token\|CreateClientTokenRequest\|IssuedClientTokenResponse"` → 0건 → `tests/test_auth_surface_after_client_deprecation.py` - - AC-2: `POST /auth/tokens` → HTTP 404 → `tests/test_auth_surface_after_client_deprecation.py` - - AC-3: release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` → COUNT=0 → release plan evidence - - AC-4: dcy-003 token_type 게이트 회귀 테스트 → 모두 통과 → `tests/test_v1*.py` - -## Evidence -- Test: - - pytest 255 passed (8.13s) — REQ-016/017/018 신규 테스트 9개 포함, regression 없음 - - `tests/test_auth.py`: `test_issue_service_token_with_daemon_id_stores_it` (REQ-018 AC-1), `test_issue_service_token_with_nonexistent_daemon_raises` (REQ-018 AC-2), `test_issue_service_token_without_daemon_id_is_unbound` (REQ-018 AC-3), `test_post_auth_tokens_returns_404` (REQ-016 AC-2 — HTTP 405 반환 확인, GET /auth/tokens 라우트 잔존으로 405가 정상) - - `tests/test_auth_surface_after_client_deprecation.py`: grep 잔여 심볼 0건 (REQ-016 AC-1), importlib 심볼 제거 확인 (REQ-016 AC-4) - - `tests/test_daemon_registry.py`: rotate 회귀 3개 통과 (REQ-017 AC-4) -- Manual: - - `app/repositories/postgres.py:ensure_schema()` index 조건 `WHERE token_type='service' AND revoked_at IS NULL` 갱신 확인 - - `grep -rn "issue_client_token\|CreateClientTokenRequest\|IssuedClientTokenResponse"` → 0건 (test_auth_surface_after_client_deprecation.py 자체 제외) -- Logs: - - `.venv/bin/python -m pytest -q` → 255 passed in 8.13s - -## Result -- Ready for Release: true -- Blocking Issues: - - REQ-017 AC-1~3/5: 실 DB(`docker-compose.local-test.yml`) 환경 검증 미완료 — release plan에 staging 검증 의무화 필요 - - REQ-016 AC-3: release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` → COUNT=0 재확인을 release plan evidence로 첨부 필요 diff --git a/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/design.md b/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/design.md deleted file mode 100644 index 875b453..0000000 --- a/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/design.md +++ /dev/null @@ -1,44 +0,0 @@ -# BAT-007 Design - -- Status: confirmed -- Batch: BAT-007 - -## Architecture Summary -- REQ-022: daemon CLI의 `--deactivate` 분기에 신규 service 메서드(`revoke_tokens_for_daemon`) 호출을 추가한다. 해당 메서드는 repository의 `revoke_tokens_by_daemon_id`를 통해 `UPDATE auth_tokens SET revoked_at=NOW() WHERE daemon_id=? AND revoked_at IS NULL`을 실행한다. -- REQ-023: service_tokens CLI에 `migrate_null_daemon` 서브커맨드를 추가한다. `--dry-run`만 허용하며 실제 변경 없이 inventory + 매핑 후보를 출력한다. -- 두 변경 모두 신규 repository 메서드 → service 헬퍼 → CLI 연결 패턴으로 일관된다. - -## Changed Areas -- Docs: - - 없음 (baseline 문서 갱신은 REQ-019~021/024 별도 배치) -- Modules: - - `app/repositories/portfolio_repository.py` — `revoke_tokens_by_daemon_id(daemon_id: str) -> int` 추상 메서드 (REQ-022); `list_service_tokens_with_null_daemon() -> list[AuthToken]` 추상 메서드 (REQ-023) - - `app/repositories/postgres.py` — 두 메서드 구현 - - `app/services/auth_service.py` — `revoke_tokens_for_daemon(daemon_id: str) -> int` (REQ-022); `list_null_daemon_service_tokens() -> list[AuthToken]`, `find_candidate_daemons_for_user(user_id: str) -> list[Daemon]` (REQ-023) - - `app/cli/commands/daemon.py` — `update_daemon` --deactivate 분기에 revoke 호출 추가 (REQ-022) - - `app/cli/commands/service_tokens.py` — `migrate_null_daemon` 서브커맨드 추가 (REQ-023) -- Tests: - - `tests/test_daemon_registry.py` — REQ-022 AC-1~4 회귀 추가 (`_DaemonRepo`에 `revoke_tokens_by_daemon_id` 추가) - - `tests/test_null_daemon_migration.py` — 신규, FakeRepo 기반 REQ-023 AC-1~5 - -## Key Decisions -1. **REQ-022 CLI 호출 패턴**: daemon.py `_with_service`는 callback에 `(repository, app_context)`를 전달한다. `repo.update_daemon(...)` 후 `repo.revoke_tokens_by_daemon_id(daemon_id)`를 순차 호출하여 deactivate + revoke를 처리. `AuthService.revoke_tokens_for_daemon`은 서비스 계층 테스트 목적으로 추가. -2. **REQ-022 --force 옵션 추가**: `update_daemon` 명령에 `force: bool = typer.Option(False, "--force")` 추가. `--deactivate`와 함께 사용 시 `--force` 없으면 확인 프롬프트 표시. 자동 revoke는 `--force` 여부와 무관하게 항상 실행. -3. **REQ-022 회피 옵션 없음**: `--keep-tokens` 옵션 미노출 (dcy-004 OQ-3 결정 — 정책 일관성). deactivation이면 항상 revoke. -4. **REQ-022 재활성화 시 자동 복구 없음**: `--activate` 분기에서는 `revoke_tokens_by_daemon_id` 호출 없음. 이미 revoke된 token은 운영자가 신규 발급해야 함. -5. **REQ-023 dry-run 전용 설계**: `--dry-run` 없이 실행 시 `typer.Exit(code=1)` + 수동 rebind/revoke 안내 메시지. 자동 변경 코드 없음. -6. **REQ-023 매핑 후보 조회**: service_tokens.py `_with_service`는 callback에 `(service, app_context)` 전달. `service.list_null_daemon_service_tokens()`와 `service.find_candidate_daemons_for_user(user_id)`를 사용해 출력 구성. -7. **revoke_tokens_by_daemon_id 반환값**: revoke된 token 수(`int`) 반환 — AC-3(no-op, 0 반환) 검증에 사용. - -## Edge Cases -- REQ-022: daemon에 바인딩된 token이 0건인 경우 → `revoke_tokens_by_daemon_id` 0 반환, 정상 종료 (AC-3) -- REQ-022: `--activate` 재활성화 시 token revoke 호출 안 함 (분기 조건: `deactivate=True`일 때만) -- REQ-023: NULL daemon active token이 0건인 경우 → "no NULL daemon tokens" 메시지 + exit 0 (AC-2) -- REQ-023: 매핑 후보 daemon이 0건인 token → dry-run 출력에 별도 경고 표시 - -## Architecture Impact -- none (runtime-flows, project-structure 변경 없음 — REQ-019 배치에서 docs 반영) - -## Reference Doc Update Plan -- project-structure.md: none (이 배치에서 변경 없음) -- runtime-flows.md: none (이 배치에서 변경 없음) diff --git a/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/implementation.md b/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/implementation.md deleted file mode 100644 index 2ed3857..0000000 --- a/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/implementation.md +++ /dev/null @@ -1,46 +0,0 @@ -# BAT-007 Implementation - -- Status: completed -- Batch: BAT-007 - -## Plan Summary -- REQ-022: revoke_tokens_by_daemon_id repository + revoke_tokens_for_daemon service + daemon CLI 연결 -- REQ-023: list_service_tokens_with_null_daemon repository + 서비스 헬퍼 + migrate_null_daemon CLI - -## Changed Files - -### Production Code -1. `app/repositories/portfolio_repository.py` — 추상 메서드 2개 추가: `revoke_tokens_by_daemon_id`, `list_service_tokens_with_null_daemon` -2. `app/repositories/postgres.py` — 위 2개 메서드 Postgres 구현 추가 -3. `app/services/auth_service.py` — 서비스 메서드 3개 추가: `revoke_tokens_for_daemon`, `list_null_daemon_service_tokens`, `find_candidate_daemons_for_user` -4. `app/cli/commands/daemon.py` — `update_daemon` 커맨드에 `--force` 옵션 + deactivate 시 service token 자동 revoke 연결 -5. `app/cli/commands/service_tokens.py` — `migrate-null-daemon` 커맨드 추가 (dry-run only) - -### Tests -6. `tests/test_daemon_registry.py` — `_DaemonRepo` fake에 `revoke_tokens_by_daemon_id` / `list_service_tokens_with_null_daemon` 추가; REQ-022 테스트 5건 추가 -7. `tests/test_null_daemon_migration.py` — 신규 파일; REQ-023 AC-1~5 + candidate daemon 출력 테스트 6건 - -## Execution Log - -1. `portfolio_repository.py`: 추상 메서드 2개 추가 -2. `postgres.py`: `revoke_tokens_by_daemon_id` — `UPDATE auth_tokens SET revoked_at=NOW() WHERE daemon_id=%s AND revoked_at IS NULL`; `list_service_tokens_with_null_daemon` — `WHERE token_type='service' AND daemon_id IS NULL AND revoked_at IS NULL ORDER BY created_at ASC` -3. `auth_service.py`: 3개 메서드 위임 구현 -4. `daemon.py`: `--force` / `--deactivate` / `--activate` 옵션 추가; deactivate 경로에서 `repo.revoke_tokens_by_daemon_id(daemon_id)` 호출 후 revoke 건수 출력 -5. `service_tokens.py`: `migrate-null-daemon` 커맨드 — `--dry-run` 없으면 즉시 exit(1) + 수동 안내; `--dry-run` 시 NULL daemon token 목록 + user별 candidate daemon ID 출력 -6. `test_daemon_registry.py`: `_DaemonRepo` fake 확장; 5개 테스트 추가 -7. `test_null_daemon_migration.py`: `_FakeRepo` + `create_app(repository_factory=...)` 패턴으로 CLI runner 구성; 6개 테스트 추가 -8. 수정 사항: `_make_runner`에서 `obj=ctx_obj` 전달 방식은 Typer `main_callback`이 `ctx.obj`를 덮어쓰므로 `create_app(repository_factory=...)` 방식으로 교체 - -## Validation - -``` -.venv/bin/python -m pytest -q -266 passed in 7.86s -``` - -- BAT-007 대상 테스트: 25개 (test_daemon_registry.py 19, test_null_daemon_migration.py 6) — 전부 PASSED -- 전체 회귀: 266 passed, 0 failed - -## Remaining Risks -- Postgres 구현은 단위 테스트(fake repo) 기반 검증; PostgreSQL DB 통합 테스트는 별도 환경 필요 -- `--deactivate` 시 revoke는 CLI 단계에서 수행 — API 경로에서 daemon deactivate가 발생할 경우 revoke 호출 별도 필요 (현재 API 경로 없음, 향후 추가 시 고려) diff --git a/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/index.md b/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/index.md deleted file mode 100644 index d6bedb1..0000000 --- a/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/index.md +++ /dev/null @@ -1,39 +0,0 @@ -# BAT-007: daemon lifecycle token 정책 (deactivation 자동 revoke + NULL daemon 마이그레이션 CLI) - -- Status: released -- Profile: standard -- Source Discovery: dcy-004 -- Included REQ: REQ-022, REQ-023 -- Excluded REQ: (없음 — dcy-004 daemon lifecycle 코드 2건 전부 포함) - -## Summary -- daemon deactivation 시 바인딩 service token을 자동 revoke하고(REQ-022), NULL daemon_id를 가진 legacy active service token의 진단·정리 CLI를 신규 제공한다(REQ-023). -- 이 배치 완료 후 dcy-004 코드 작업 전체(BAT-006 + BAT-007)가 종료되며, 이후 token 관련 baseline 문서 갱신 배치(REQ-019~021, REQ-024)를 진행한다. - -## Scope -- In Scope: - - `app/cli/commands/daemon.py` — `update_daemon` --deactivate 분기에 `revoke_tokens_for_daemon(daemon_id)` 호출 추가 (REQ-022) - - `app/services/auth_service.py` — `revoke_tokens_for_daemon(daemon_id)` 헬퍼 신규 추가 (REQ-022) - - `app/repositories/portfolio_repository.py` — `revoke_tokens_by_daemon_id(daemon_id)` 추상 메서드 추가 (REQ-022) - - `app/repositories/postgres.py` — `revoke_tokens_by_daemon_id(daemon_id)` 구현 (REQ-022) - - `app/cli/commands/service_tokens.py` — `migrate_null_daemon` 서브커맨드 신규 추가 (REQ-023) - - `app/services/auth_service.py` — `list_null_daemon_service_tokens()`, `find_candidate_daemons_for_user(user_id)` 헬퍼 추가 (REQ-023) - - `app/repositories/portfolio_repository.py` — `list_service_tokens_with_null_daemon()` 추상 메서드 추가 (REQ-023) - - `app/repositories/postgres.py` — `list_service_tokens_with_null_daemon()` 구현 (REQ-023) - - `tests/test_daemon_registry.py` — REQ-022 AC-1~4 회귀 케이스 추가 - - `tests/test_null_daemon_migration.py` — 신규: REQ-023 AC-1~5 (FakeRepo 기반) -- Out of Scope: - - token 관련 baseline 문서 갱신 (→ REQ-019~021, REQ-024, 별도 배치) - - daemon 모델 자체 변경 (변경 없음) - - graceful drain 구현 (daemon 측 책임) - -## 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): REQ-022 deactivation 즉시 revoke는 in-flight daemon 거래에 영향 — release plan SOP에 "(1) daemon 프로세스 종료/drain → (2) `pfhub daemon update --deactivate` → (3) 자동 revoke" 순서 명시 필요 (REQ-019 baseline에서 문서화) -- R-2 (Low): REQ-023 DATA-2 admin 테스트 fixture 1건(NULL daemon active service token) — 이 batch release 시점에 운영자가 `pfhub service-token revoke admin` 수동 실행으로 정리 (release plan 명시) -- 선행 의존: BAT-006 (REQ-016/017/018, release-candidate) 완료 상태로 auth/token 기반 충족 diff --git a/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/planning.md b/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/planning.md deleted file mode 100644 index 1d786a2..0000000 --- a/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/planning.md +++ /dev/null @@ -1,48 +0,0 @@ -# BAT-007 Planning - -- Status: confirmed -- Batch: BAT-007 -- Profile: standard - -## Included REQ -- REQ-022: daemon deactivation 시 바인딩 service token 자동 revoke (Interface, High) -- REQ-023: NULL daemon_id legacy token 마이그레이션 CLI (Interface, Medium) - -## Delivery Plan -1. REQ-022: - - `app/repositories/postgres.py` — `revoke_tokens_by_daemon_id(daemon_id)` 구현: `UPDATE auth_tokens SET revoked_at=NOW() WHERE daemon_id= AND revoked_at IS NULL` - - `app/repositories/portfolio_repository.py` — 추상 메서드 추가 - - `app/services/auth_service.py` — `revoke_tokens_for_daemon(daemon_id)` 헬퍼 추가 - - `app/cli/commands/daemon.py:update_daemon` — `deactivate=True` 분기에서 `revoke_tokens_for_daemon` 호출 -2. REQ-023: - - `app/repositories/postgres.py` — `list_service_tokens_with_null_daemon()` 구현: `SELECT ... FROM auth_tokens WHERE daemon_id IS NULL AND token_type='service' AND revoked_at IS NULL` - - `app/repositories/portfolio_repository.py` — 추상 메서드 추가 - - `app/services/auth_service.py` — `list_null_daemon_service_tokens()`, `find_candidate_daemons_for_user(user_id)` 추가 - - `app/cli/commands/service_tokens.py` — `migrate_null_daemon` 서브커맨드: `--dry-run` inventory 출력, `--dry-run` 미포함 시 non-zero exit + 안내 메시지 -3. 테스트: - - `tests/test_daemon_registry.py` 갱신 (REQ-022 AC-1~4) - - `tests/test_null_daemon_migration.py` 신규 (REQ-023 AC-1~5, FakeRepo 기반) - -## Dependencies -- Internal: - - `app/cli/commands/daemon.py:update_daemon` — `--deactivate` 분기 기존 위치 확인 후 revoke 호출 삽입 - - `app/cli/commands/service_tokens.py` — 기존 `service_token_app` Typer에 `migrate_null_daemon` 커맨드 추가 - - `app/repositories/postgres.py` — `revoke_auth_token` 기존 구현 패턴 참조 - - BAT-006 (REQ-016/017/018, release-candidate): auth_service.py token 기반 안정 상태 -- External: - - 없음 - -## Design Gate -- Design Required Before Implementation: yes -- Reason: REQ-022의 트랜잭션 처리 방식(daemon deactivate + token revoke 동일 트랜잭션 여부), REQ-023의 dry-run 출력 형식 및 --dry-run 미포함 시 거부 동작을 design.md에서 확정해야 구현 시 누락 없이 진행 가능 - -## Milestones -1. design.md confirmed — 트랜잭션 전략, dry-run 출력 스펙, 테스트 전략 확정 -2. REQ-022 repository + service 메서드 + daemon CLI 연결 + 회귀 테스트 통과 -3. REQ-023 repository + service 헬퍼 + migrate_null_daemon CLI + 신규 테스트 통과 -4. pytest 전체 통과 (regression 없음) - -## Risks -- R-1 (Medium): REQ-022 트랜잭션 경계 — daemon update와 token revoke를 동일 트랜잭션으로 묶어야 원자성 보장. postgres.py에서 트랜잭션 패턴 확인 필요 -- R-2 (Low): REQ-023 dry-run 출력 — 매핑 후보 daemon 조회에 `list_daemons_for_user(user_id)` 활용 가능 여부 확인 필요 -- R-3 (Low): DATA-2 admin 테스트 fixture (NULL daemon active token 1건) — release plan에 수동 정리 절차 명시 diff --git a/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/verification.md b/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/verification.md deleted file mode 100644 index c2829ef..0000000 --- a/docs/batches/bat-007_20260520_daemon-lifecycle-token-enforcement/verification.md +++ /dev/null @@ -1,41 +0,0 @@ -# BAT-007 Verification - -- Status: release-candidate -- Batch: BAT-007 - -## Acceptance Mapping - -- REQ-022: - - AC-1: active daemon + 바인딩 service token 1개 이상 → `--deactivate --force` → 모든 바인딩 token revoked + daemon is_active=false → `test_deactivate_daemon_revokes_bound_tokens` / `test_deactivate_daemon_revokes_multiple_bound_tokens` PASSED - - AC-2: revoke된 token 인증 거부 — 기존 token 인증 테스트(`test_daemon_registry.py`)에서 `revoked_at is not None` 확인; v1 API 401 경로는 PostgreSQL 통합 테스트에서 별도 검증 예정 - - AC-3: 바인딩 token 0건 daemon → deactivate → no-op → `test_deactivate_daemon_with_no_tokens_is_noop` PASSED - - AC-4: reactivate 후 revoke된 token 자동 복구 없음 → `test_reactivate_daemon_does_not_unrevoke_tokens` PASSED - - AC-5: in-flight 거래 보호 없음 — 테스트 불요 (release SOP 참조) - -- REQ-023: - - AC-1: NULL daemon token 있을 때 dry-run → id/user_id/name 출력 → `test_migrate_null_daemon_dry_run_shows_inventory` PASSED - - AC-2: NULL daemon token 0건 → "no NULL daemon tokens found." + exit 0 → `test_migrate_null_daemon_dry_run_no_tokens_prints_empty_message` PASSED - - AC-3: `--dry-run` 없이 실행 → exit non-zero + 수동 안내 → `test_migrate_null_daemon_without_dry_run_exits_nonzero` PASSED - - AC-4: rebind 후 해당 token inventory 제외 → `test_migrate_null_daemon_after_rebind_token_excluded_from_inventory` PASSED - - AC-5: revoke 후 해당 token inventory 제외 → `test_migrate_null_daemon_after_revoke_token_excluded_from_inventory` PASSED - -## Evidence - -- Test: - ``` - tests/test_daemon_registry.py 19 passed (REQ-022: 5 new + 14 pre-existing) - tests/test_null_daemon_migration.py 6 passed (REQ-023: AC-1~5 + candidate daemon) - Total BAT-007 scope: 25 passed, 0 failed - ``` -- Full regression: - ``` - .venv/bin/python -m pytest -q - 266 passed in 7.86s - ``` -- Manual: CLI dry-run 동작 코드 경로 검토 완료; `create_app(repository_factory=...)` 패턴 기반 CLI 통합 테스트로 실 CLI 경로 커버 확인 -- Logs: 없음 (DB 불필요 단위/CLI 테스트) - -## Result - -- Ready for Release: true -- Blocking Issues: 없음 diff --git a/docs/batches/bat-008_20260521_token-surface-docs/design.md b/docs/batches/bat-008_20260521_token-surface-docs/design.md deleted file mode 100644 index 61342a2..0000000 --- a/docs/batches/bat-008_20260521_token-surface-docs/design.md +++ /dev/null @@ -1,60 +0,0 @@ -# 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 흐름 보강 diff --git a/docs/batches/bat-008_20260521_token-surface-docs/implementation.md b/docs/batches/bat-008_20260521_token-surface-docs/implementation.md deleted file mode 100644 index cdc3ba5..0000000 --- a/docs/batches/bat-008_20260521_token-surface-docs/implementation.md +++ /dev/null @@ -1,32 +0,0 @@ -# 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 단계에서 수행 diff --git a/docs/batches/bat-008_20260521_token-surface-docs/index.md b/docs/batches/bat-008_20260521_token-surface-docs/index.md deleted file mode 100644 index 526acc2..0000000 --- a/docs/batches/bat-008_20260521_token-surface-docs/index.md +++ /dev/null @@ -1,33 +0,0 @@ -# BAT-008: dcy-004 토큰 surface baseline 문서화 - -- Status: released -- 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) — 코드 변경 반영 근거 확보됨. diff --git a/docs/batches/bat-008_20260521_token-surface-docs/planning.md b/docs/batches/bat-008_20260521_token-surface-docs/planning.md deleted file mode 100644 index 70cb901..0000000 --- a/docs/batches/bat-008_20260521_token-surface-docs/planning.md +++ /dev/null @@ -1,40 +0,0 @@ -# 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는 키워드 존재 여부만 확인 → 정책 정확도는 사람 리뷰로 보완 diff --git a/docs/batches/bat-008_20260521_token-surface-docs/verification.md b/docs/batches/bat-008_20260521_token-surface-docs/verification.md deleted file mode 100644 index 0420372..0000000 --- a/docs/batches/bat-008_20260521_token-surface-docs/verification.md +++ /dev/null @@ -1,61 +0,0 @@ -# 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에 포함 예정 diff --git a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/design.md b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/design.md deleted file mode 100644 index 53ecde0..0000000 --- a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/design.md +++ /dev/null @@ -1,78 +0,0 @@ -# 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)에서 처리 diff --git a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/implementation.md b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/implementation.md deleted file mode 100644 index c0029c3..0000000 --- a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/implementation.md +++ /dev/null @@ -1,41 +0,0 @@ -# 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 테스트 통과 완료 diff --git a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/index.md b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/index.md deleted file mode 100644 index a2d1dbd..0000000 --- a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/index.md +++ /dev/null @@ -1,36 +0,0 @@ -# BAT-009: v1 portfolio CRUD core (POST/PATCH/DELETE/GET) - -- Status: released -- 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 기반 스키마 + 토큰 분기 정책 확정. diff --git a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/planning.md b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/planning.md deleted file mode 100644 index 5c3f250..0000000 --- a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/planning.md +++ /dev/null @@ -1,49 +0,0 @@ -# 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` 메서드 추가 필요. diff --git a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/verification.md b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/verification.md deleted file mode 100644 index 460f75a..0000000 --- a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/verification.md +++ /dev/null @@ -1,64 +0,0 @@ -# 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 -- AC-13 (CHG-20260601-01: display_name joined assets.display_name): `tests/test_v1_portfolios_list.py::_make_portfolio(..., display_name=...)` 호환 + 전체 10 PASSED (응답 schema 확장 회귀); 로컬 smoke (2026-06-01 13:18 KST) admin DB session token으로 GET /v1/portfolios 호출, portfolios 2건 모두 `display_name:"삼성전자"` (joined `assets.display_name`)로 응답 확인. - -## Evidence - -- Test: - - `tests/test_v1_portfolios_crud.py`: 22 PASSED (REQ-025/026/027) - - `tests/test_v1_portfolios_list.py`: 10 PASSED (REQ-028 AC-1~11 + AC-13 확장 회귀) - - 전체 스위트: 298 PASSED, 0 FAILED (2026-05-21 초기 verification) - - 전체 스위트: 368 PASSED, 0 FAILED (2026-06-01 CHG-20260601-01 augmentation 회귀) -- Manual: - - 2026-06-01 13:16~18 KST 로컬 smoke (REL-001 Verification Checklist 5개 항목): GET /v1/portfolios 응답에 `display_name` 필드 포함 확인 (relevant evidence: `docs/releases/rel-001_20260521_v1-api-full-surface.md`) -- Logs: 없음 - -## Augmentation History -- 2026-06-01: REQ-028 CHG-20260601-01 — GET /v1/portfolios 응답 schema에 `display_name` 필드 추가. `Portfolio` dataclass + `PortfolioItem` schema + `_SELECT_PORTFOLIO` JOIN `a.display_name` 보강. AC-13 신설, 368 PASSED 회귀 + 로컬 smoke로 응답 노출 확인. 별도 BAT 미할당 — 본 verification에 누적 기록. - -## Result -- Ready for Release: true -- Blocking Issues: 없음 diff --git a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/design.md b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/design.md deleted file mode 100644 index db236c2..0000000 --- a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/design.md +++ /dev/null @@ -1,50 +0,0 @@ -# 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 보존) 추가 diff --git a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/implementation.md b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/implementation.md deleted file mode 100644 index a45d83e..0000000 --- a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/implementation.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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으로 커버됨. diff --git a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/index.md b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/index.md deleted file mode 100644 index 92c83ea..0000000 --- a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/index.md +++ /dev/null @@ -1,33 +0,0 @@ -# BAT-010: v1 portfolio sub-resources (tags/comments + baseline docs) - -- Status: released -- 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 패턴 확립. diff --git a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/planning.md b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/planning.md deleted file mode 100644 index 1bfcc3f..0000000 --- a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/planning.md +++ /dev/null @@ -1,76 +0,0 @@ -# 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` 테이블 존재 여부 확인 필요 diff --git a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/verification.md b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/verification.md deleted file mode 100644 index f0b906e..0000000 --- a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/verification.md +++ /dev/null @@ -1,55 +0,0 @@ -# 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: - - 없음 diff --git a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/design.md b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/design.md deleted file mode 100644 index dfac1ed..0000000 --- a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/design.md +++ /dev/null @@ -1,42 +0,0 @@ -# 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 차단 순서 갱신 diff --git a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/implementation.md b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/implementation.md deleted file mode 100644 index 98aef4c..0000000 --- a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/implementation.md +++ /dev/null @@ -1,36 +0,0 @@ -# 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 배포). diff --git a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/index.md b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/index.md deleted file mode 100644 index 7fc5a5c..0000000 --- a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/index.md +++ /dev/null @@ -1,35 +0,0 @@ -# BAT-011: v1 portfolios ledger path migration - -- Status: released -- 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) ✅ diff --git a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/planning.md b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/planning.md deleted file mode 100644 index dd402f6..0000000 --- a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/planning.md +++ /dev/null @@ -1,39 +0,0 @@ -# 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 단계에서 사전 파악 필요. diff --git a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/verification.md b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/verification.md deleted file mode 100644 index b5c324a..0000000 --- a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/verification.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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 없음. diff --git a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/design.md b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/design.md deleted file mode 100644 index 80c20df..0000000 --- a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/design.md +++ /dev/null @@ -1,48 +0,0 @@ -# BAT-012 Design - -- Status: confirmed -- Batch: BAT-012 - -## Architecture Summary -- assets 테이블에 단일 컬럼(`tag_ids UUID[]`)·GIN 인덱스 추가, `AssetCatalogResponse`에 `tag_ids: list[TagRefItem]` 필드 추가, GET /v1/assets 응답에 archived 합성 로직 삽입을 함께 처리한다. -- archived 표시 포맷은 기존 `app/schemas/v1.py:50-52`의 `TagRefItem(tag_id, archived)` 객체 배열 패턴을 그대로 재사용한다(REQ-003 portfolios.tag_ids 응답과 동일). -- GET /v1/assets 라우트 본체는 이미 admin catalog 조회로 재정의되어 있음(`app/api/v1_assets.py:48-73`, REQ-010 AC-1~8 + AC-9~12의 코드 경로 존재). 본 batch는 (a) 응답에 `tag_ids` 필드 합류, (b) AC-9~12 자동 테스트 신설, (c) tag_ids 컬럼 도입의 backing migration·repository 정합을 책임진다. - -## Changed Areas -- Docs: - - `docs/data-model.md` — assets 엔티티 스키마에 `tag_ids UUID[]` 추가 (별도 docs sync REQ로 분리 가능) - - `docs/interface-contract.md` — GET /v1/assets 응답 본문에 `tag_ids` 필드 명세 추가 -- Modules: - - `migrations/versions/20260521_NNNN_assets_tag_ids_column.py` (신규) — `tag_ids UUID[] NOT NULL DEFAULT '{}'` + `idx_assets_tag_ids_gin` - - `app/domain/models.py:32-39` — `Asset` dataclass에 `tag_ids: list[str]` 필드 추가 (기본값 빈 리스트) - - `app/repositories/postgres.py:82-92` — `_SELECT_ASSET` SQL에 `COALESCE(tag_ids, '{}'::uuid[])` 추가 - - `app/repositories/postgres.py:454-466` — `_row_to_asset`에서 tag_ids 파싱 - - `app/schemas/v1.py:234-241` — `AssetCatalogResponse`에 `tag_ids: list[TagRefItem]` 필드 추가 (기본값 빈 리스트) - - `app/api/v1_assets.py:29-38` — `_asset_response` 헬퍼에 tags 마스터 is_active 조회 + archived 합성 로직 추가 -- Interfaces: - - HTTP: `GET /v1/assets` / `POST /v1/assets` / `PATCH /v1/assets/{asset_id}` / `PATCH /v1/assets/{asset_id}/deactivate` 응답 본문에 `tag_ids: [{tag_id, archived}]` 필드 등장 - -## Key Decisions -1. **archived 표시 포맷 — `TagRefItem` 객체 배열 채택**: 기존 `app/schemas/v1.py:50-52`의 `class TagRefItem { tag_id: str; archived: bool = False }`를 그대로 재사용. REQ-003 portfolios에서 채택한 형식과 정합. `AssetCatalogResponse.tag_ids: list[TagRefItem] = Field(default_factory=list)`. -2. **list_assets 시그니처 — 변경 없음**: 기존 `list_assets() -> list[Asset]`을 그대로 유지. status/market 필터는 라우트 레이어에서 이미 in-memory 필터링(`app/api/v1_assets.py:67-70`)으로 동작 중. 운영 카탈로그 규모(수십~수백 row) 기준 in-memory 필터로 충분. 향후 카탈로그 폭증 시 repository 보강은 별도 REQ로. -3. **GET /v1/assets 라우트 본체 — 변경 없음**: 라우트는 이미 admin catalog로 재정의되어 있음. 본 batch는 응답 schema에 `tag_ids` 필드 추가와 archived 합성만 책임. REQ-010 AC-9~12 코드 경로는 존재하지만 **자동 테스트가 없음** — 본 batch에서 신규 테스트로 메움. -4. **BAT-002 REQ-010 trace 정리 — BAT-012 우선 적용**: BAT-012 verification 통과 후 confirm-req-implemented 단계에서 REQ-010 trace를 BAT-012로 갱신하고 BAT-002 trace 표기를 `superseded by BAT-012`로 명시. BAT-002의 REQ-012/REQ-015 trace는 영향 없음. -5. **archived 합성 위치 — API 레이어 헬퍼**: `_asset_response`에서 `tag_ids: list[str]`을 `list[TagRefItem]`으로 변환. tags 마스터 is_active 조회는 `repo.get_tags_by_ids(tag_ids)` 활용(이미 존재: `app/repositories/postgres.py:1700-1705`). 단일 자산 응답 N개 호출 회피를 위해 `list_catalog_assets`에서는 모든 자산의 tag_ids union을 한 번에 조회 후 매핑(N+1 회피). -6. **`POST /v1/assets/{id}/tags` 라우트 정합화 — 본 batch 범위 밖**: 현행 apply/remove 라우트가 `repo.append_portfolio_tag_id`를 호출하여 portfolios 테이블을 잘못 대상화하는 잠재 버그(REQ-005 follow-up)는 BAT-012 범위 밖. REQ-002 AC-3/AC-4 검증은 DB seed로 직접 수행. apply/remove 정합화는 별도 REQ/Batch에서 처리. -7. **Migration revision down_revision — 신규 head**: alembic heads 출력을 implementation 진입 시 확인하고 가장 최신 revision을 down_revision으로 지정. 동시에 진행 중인 다른 batch 없음(BAT-009~011 모두 release-candidate). - -## Edge Cases -- `tag_ids = []`: 응답 `tag_ids: []` (빈 배열, AC-4). -- `tag_ids`에 마스터에 없는 UUID 포함: `get_tags_by_ids` 결과에 없으면 archived 합성 시 `archived=True`로 처리 또는 무시. **결정**: archived=True로 노출(이력 유지 원칙 — REQ-002 Intent "조용히 제거하지 않고 archived 상태로 반환"). -- `tag_ids`에 is_active=FALSE tag_id 포함: archived=True 응답 (AC-3). -- `status=all` + `market=KRX` 동시 지정: 두 필터 AND. 빈 결과면 `{ items: [], total: 0 }` 200. -- `market` 파라미터에 MarketCode Enum 외 값: schema validation으로 422 — 기존 라우트는 임의 문자열을 받고 비교만 하므로 본 batch에서 명시적 validation 추가 여부 결정 필요. **결정**: 본 batch는 기존 동작 유지(임의 문자열 허용, 매칭 없으면 빈 결과). MarketCode 검증은 별도 REQ로. -- assets 테이블 비어 있음: `{ items: [], total: 0 }` 200. -- non-admin 토큰(user/service/client): `require_admin` 의존성이 403 처리(AC-12). - -## Architecture Impact -- **runtime-flows** — GET /v1/assets 응답에 tag_ids/archived 합성 흐름이 추가됨. 한 줄 갱신 필요. - -## Reference Doc Update Plan -- project-structure.md: none -- runtime-flows.md: GET /v1/assets 흐름 설명에 "tag_ids 배열을 응답으로 노출하고 tags 마스터 is_active를 조회해 archived 합성" 1줄 추가 — 본 batch implementation에서 함께 처리. diff --git a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/implementation.md b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/implementation.md deleted file mode 100644 index ff68ee0..0000000 --- a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/implementation.md +++ /dev/null @@ -1,47 +0,0 @@ -# BAT-012 Implementation - -- Status: completed -- Batch: BAT-012 - -## Plan Summary -- planning.md Delivery Plan에 따라 (1) Alembic revision → (2) 도메인 모델 → (3) repository → (4) schemas → (5) GET 라우트 헬퍼 보강 → (6) 테스트 → (7) docs 갱신 순서로 구현 완료. -- GET /v1/assets 라우트 본체는 사전 augmentation으로 이미 admin catalog 조회로 재정의되어 있었음. 본 batch는 (a) `tag_ids` 응답 필드 합류, (b) archived 합성 N+1 회피 로직, (c) 부재했던 AC-9~12 자동 테스트 신설을 책임. - -## Changed Files -- `migrations/versions/20260521_0023_assets_tag_ids_column.py` (신규) — assets.tag_ids UUID[] + idx_assets_tag_ids_gin -- `app/domain/models.py` — `Asset` dataclass에 `tag_ids: list[str] = field(default_factory=list)` 필드 추가 -- `app/repositories/postgres.py` - - `_SELECT_ASSET`에 `COALESCE(tag_ids, '{}'::uuid[])` 컬럼 추가 - - `_row_to_asset`에서 `tag_ids=[str(t) for t in row[7]] if len(row) > 7 ...` 파싱 - - `insert_asset` RETURNING 절에 `COALESCE(tag_ids, '{}'::uuid[])` 추가 -- `app/schemas/v1.py` — `AssetCatalogResponse`에 `tag_ids: list[TagRefItem] = Field(default_factory=list)` -- `app/api/v1_assets.py` - - `TagRefItem` import - - `_build_tag_refs(tag_ids, tag_active_map)` 신규 헬퍼 (archived 합성: 마스터에 없거나 is_active=False면 archived=True) - - `_asset_response(asset, tag_active_map=None)` 시그니처 보강 (단일 응답 경로에서는 tag_ids 비어있지 않을 때만 DB 조회) - - `list_catalog_assets`에서 모든 자산의 tag_ids union을 한 번에 `get_tags_by_ids`로 조회 후 매핑 (N+1 회피) -- `tests/test_asset_catalog.py` — REQ-002 AC-1~4 + REQ-010 AC-9~12 신규 17건 추가 -- `docs/runtime-flows.md` — GET /v1/assets 흐름 재정의 + Change Log entry - -## Execution Log -1. alembic heads 확인 → 최신 revision `20260520_0022` 파악, 새 revision `20260521_0023`으로 down_revision 지정 -2. 마이그레이션 파일 작성 (REQ-002 AC-1, AC-2): assets.tag_ids 컬럼 + idx_assets_tag_ids_gin -3. `Asset` dataclass에 tag_ids 필드 추가 (`dataclasses.field` import 보강) -4. `_SELECT_ASSET` SQL + `_row_to_asset` + `insert_asset` RETURNING 모두 tag_ids 합류 -5. `AssetCatalogResponse`에 tag_ids 필드 추가 (default_factory=list) -6. `_build_tag_refs` 헬퍼 신설 + `_asset_response`에 archived 합성 로직 + `list_catalog_assets`에서 batch 조회로 N+1 회피 -7. 17건 테스트 추가 (migration file structure 2건, Asset/schema 4건, `_build_tag_refs` 단위 3건, GET integration 8건) -8. `docs/runtime-flows.md` GET /v1/assets 흐름 재작성 + Change Log entry 추가 -9. `python -m pytest tests/test_asset_catalog.py -x` → 50/50 PASSED -10. `python -m pytest` 전체 회귀 → 362/362 PASSED (기존 330 + 신규 32) - -## Validation -- `python -m pytest tests/test_asset_catalog.py` — **50 passed in 1.51s** (기존 33 + 신규 17) -- `python -m pytest` 전체 suite — **362 passed in 13.06s** (기존 330 + 신규 32, 회귀 0) -- Migration 파일 구조 검증은 in-test로 충분 (assert add_column / index / nullable=False / `'{}'::uuid[]` / postgresql_using=gin) -- DB-integration AC(실제 컬럼/인덱스 존재, archived row 동작)은 REL-001 후속 release 배포 시 alembic upgrade 결과로 최종 확인. CHG-20260521-01 BAT-001 패턴과 동일. - -## Remaining Risks -- R-1 잔존 (Low): `POST /v1/assets/{asset_id}/tags` / `DELETE /v1/assets/{asset_id}/tags/{tag_id}` 라우트가 여전히 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출하여 portfolios 테이블을 잘못 대상화 (REQ-005 follow-up). 본 batch는 schema/응답 합류와 GET 회귀 봉합만 책임. apply/remove 라우트 정합화는 별도 REQ/Batch로 처리. 본 batch verification에서는 DB seed 기반 GET 검증으로 AC-3/AC-4 충족. -- R-2 잔존 (Low): BAT-002의 REQ-010 trace는 confirm-batch-verification 단계에서 BAT-012 우선 적용으로 정리 예정 (index.md Notes에 명시). -- R-3 잔존 (Negligible): assets.tag_ids 컬럼·GIN 인덱스 추가 시 lock — assets 테이블 규모가 작아 운영 영향 미미. diff --git a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/index.md b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/index.md deleted file mode 100644 index 4881faf..0000000 --- a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/index.md +++ /dev/null @@ -1,37 +0,0 @@ -# BAT-012: assets tag_ids 컬럼 + admin GET /v1/assets 재정의 - -- Status: released -- Profile: standard -- Source Discovery: dcy-002 (REQ-002), dcy-001 + dcy-003 augmentation (REQ-010) -- Included REQ: REQ-002, REQ-010 -- Excluded REQ: none - -## Summary -- REQ-002 본문 재정의(CHG-20260521-02: portfolios.tag_ids → assets.tag_ids) 후속으로 `assets.tag_ids UUID[]` 컬럼 + `idx_assets_tag_ids_gin` GIN 인덱스를 도입한다. -- REQ-010 augmentation(CHG-20260521-02)에서 미구현으로 남은 `GET /v1/assets` admin catalog 재정의(AC-9~12)를 동시에 구현한다. -- 두 REQ가 `app/schemas/v1.py`(AssetCatalogItem 신설 + tag_ids 필드 합류), `app/repositories/postgres.py`(list_assets 필터 보강 + archived 표시), `tests/test_asset_catalog.py` 변경점을 공유하므로 한 batch에서 sequence를 잡아 처리한다. - -## Scope -- In Scope: - - `migrations/versions/` — `assets.tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼 + `idx_assets_tag_ids_gin` GIN 인덱스 신규 Alembic revision - - `app/domain/models.py` — `Asset` 도메인 모델에 `tag_ids: list[UUID]` 필드 추가 - - `app/repositories/postgres.py` — assets 조회 시 tag_ids 배열 archived 표시 로직 (REQ-001 tags 마스터의 is_active 활용); `list_assets`에 status/market 필터 옵션 보강 - - `app/api/v1_assets.py` — `GET /v1/assets` 라우트를 token_type 분기 portfolio 조회에서 admin permission 검증 + `list_assets` 호출로 재정의 (REL-001 smoke 회귀 봉합) - - `app/schemas/v1.py` — `AssetCatalogItem`, `AssetCatalogListResponse` 신규 정의 (tag_ids 필드 포함) - - `tests/test_asset_catalog.py` — REQ-002 AC-1~4 (schema, GIN, archived, 기본값) + REQ-010 AC-9~12 (admin GET 200, status/market 필터, non-admin 403) 신규 케이스 - - `tests/test_v1.py` — 구 GET /v1/assets portfolio 응답 기대 케이스 정리 -- Out of Scope: - - `assets.tag_ids`에 대한 apply/remove HTTP API — REQ-005에서 이미 정의된 `POST /v1/assets/{asset_id}/tags`는 본 batch가 의존성 토대를 제공하지만 라우트 자체의 회귀 검증은 별도 verification 트랙 - - `pfhub asset tag` CLI 노출 — REQ-007 범위 또는 후속 REQ - - admin asset catalog 응답에 `markets`/`status` 외 필터 신설 — 본 batch는 AC 명시 항목만 - -## Documents -- Planning: [planning.md](./planning.md) -- Design: [design.md](./design.md) -- Implementation: [implementation.md](./implementation.md) -- Verification: [verification.md](./verification.md) - -## Notes -- 선행 완료: REQ-009(assets 테이블, BAT-001) ✅, REQ-001(tags 마스터, BAT-003) ✅, REQ-005(태그 apply/remove API, BAT-004) ✅ -- Trace 정리: REQ-010은 BAT-002(release-candidate)에도 trace가 잔존. 본 batch verification 통과 후 confirm-req-implemented 단계에서 BAT-012 trace를 우선 적용하고 BAT-002의 REQ-010 trace는 augmentation superseded로 표기한다. -- REQ-002의 BAT-003 trace는 CHG-20260521-02에서 무효 처리됨 — 본 batch가 REQ-002의 유일한 active trace. diff --git a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/planning.md b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/planning.md deleted file mode 100644 index 2f67680..0000000 --- a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/planning.md +++ /dev/null @@ -1,79 +0,0 @@ -# BAT-012 Planning - -- Status: confirmed -- Batch: BAT-012 -- Profile: standard - -## Included REQ -- REQ-002: `assets.tag_ids UUID[]` 컬럼 + `idx_assets_tag_ids_gin` 도입 (Data, dcy-002 FR-2) -- REQ-010: `GET /v1/assets` admin catalog 재정의 augmentation (Interface, dcy-001 FR-2 + dcy-003 옵션 B 후속 — CHG-20260521-02 AC-9~12) - -## Delivery Plan -1. **Migration 도입 (REQ-002 AC-1, AC-2)** - - `migrations/versions/20260521_NNNN_assets_tag_ids_column.py` 신규 revision - - `op.add_column("assets", sa.Column("tag_ids", sa.ARRAY(sa.UUID()), nullable=False, server_default=sa.text("ARRAY[]::uuid[]")))` - - `op.create_index("idx_assets_tag_ids_gin", "assets", ["tag_ids"], postgresql_using="gin")` - - downgrade에서 인덱스 → 컬럼 순으로 제거 -2. **도메인 모델 보강 (REQ-002 AC-3, AC-4)** - - `app/domain/models.py` — `Asset` 모델에 `tag_ids: list[UUID]` 필드 추가 (기본값 빈 리스트) -3. **Repository 보강 (REQ-002 AC-3, REQ-010 AC-10, AC-11)** - - `app/repositories/postgres.py` - - assets 조회 시 `tag_ids`를 그대로 로드하고, archived 표시 응답은 schema/API 계층에서 tags 마스터 is_active를 조회해 합성 (REQ-003에서 portfolios용으로 이미 도입된 archived 표시 헬퍼 재사용) - - `list_assets(status: str | None, market: str | None) -> list[Asset]` 시그니처 보강 — status 필터(active/inactive/all 의미는 라우트에서 변환) + ` = ANY(markets)` 필터 -4. **응답 스키마 신설 (REQ-010 AC-9, REQ-002 AC-4 합류)** - - `app/schemas/v1.py` - - `AssetCatalogItem`: `asset_id, symbol, markets, display_name, status, tag_ids (각 tag_id에 archived 표시 메타 동반), created_at, updated_at` - - `AssetCatalogListResponse`: `{ items: list[AssetCatalogItem] }` -5. **GET /v1/assets 라우트 재정의 (REQ-010 AC-9~12)** - - `app/api/v1_assets.py` - - 기존 token_type 분기(daemon/user/admin portfolio 조회) 제거 - - admin permission 검증 → 실패 시 HTTP 403 - - query param `status` (active|inactive|all, 기본 active), `market` (선택, MarketCode 1개) 파싱 - - `list_assets` 호출 후 `AssetCatalogListResponse`로 직렬화 -6. **테스트 신설/갱신** - - `tests/test_asset_catalog.py` - - REQ-002 AC-1: Alembic 적용 후 `tag_ids` 컬럼 존재 (SQLAlchemy Inspector) - - REQ-002 AC-2: `idx_assets_tag_ids_gin` 존재 - - REQ-002 AC-3: is_active=FALSE tag_id 포함 시 archived 표시 동반 반환 - - REQ-002 AC-4: 빈 배열 기본값 반환 - - REQ-010 AC-9: admin GET 200 + `AssetCatalogListResponse` shape (portfolio 필드 부재) - - REQ-010 AC-10: `status=all`/`status=inactive` 필터 동작 - - REQ-010 AC-11: `market=KRX` 필터 동작 - - REQ-010 AC-12: non-admin (user/service/client) 호출 시 HTTP 403 - - `tests/test_v1.py` - - 구 GET /v1/assets에서 portfolio 응답을 기대하는 케이스 정리 (GET /v1/portfolios로 이전 또는 catalog 응답 schema로 교체) -7. **로컬 smoke 재실행 (REL-001 회귀 확인)** - - admin session token으로 `GET /v1/assets` → HTTP 200 + asset catalog 응답 - - service token으로 `GET /v1/assets` → HTTP 403 - - REL-001 smoke 체크리스트의 해당 line 재확인 - -## Dependencies -- Internal: - - REQ-009 (Implemented, BAT-001) — assets 테이블 ✅ - - REQ-001 (Implemented, BAT-003) — tags 마스터 테이블 + is_active 컬럼 ✅ - - REQ-003 (Implemented, BAT-003) — portfolios.tag_ids archived 표시 헬퍼 재사용 가능 ✅ - - REQ-005 (Implemented, BAT-004) — `POST /v1/assets/{asset_id}/tags` 라우트가 본 batch의 컬럼 도입 후 실제 저장 가능 -- External: - - 없음 (trex-basic 등 외부 클라이언트 영향 없음) - -## Design Gate -- Design Required Before Implementation: **yes** -- Reason: - 1. archived 표시 응답 포맷 결정 — `tag_ids: [uuid]` + 별도 `tag_status_map: {uuid: 'active'|'archived'}` vs `tags: [{tag_id, status}]` 객체 배열. REQ-003 portfolios.tag_ids에서 이미 채택한 형식과 정합 필요. - 2. `AssetCatalogItem`에 `tag_ids` 필드를 어느 구조로 노출할지 → 위 결정과 연동. - 3. GET /v1/assets 재정의로 인한 기존 token_type 분기 코드의 호출 그래프 영향 — service-token/user-token 클라이언트 호출 패턴 회귀 영향 분석 (특히 trex-basic이 GET /v1/assets를 호출했는지 BAT-011 후속으로 확인). - 4. BAT-002의 REQ-010 trace 정리 방식 — BAT-002 종결 시점 정책 결정. - -## Milestones -1. design.md confirmed — archived 표시 포맷, list_assets 시그니처, BAT-002 trace 정리 정책 결정 -2. Alembic revision 추가 + 도메인 모델/repository 보강 완료 (REQ-002 AC-1~4 자가검증) -3. AssetCatalogItem/AssetCatalogListResponse + GET 라우트 재정의 완료 (REQ-010 AC-9~12 자가검증) -4. `tests/test_asset_catalog.py` 신규 케이스 전부 pass + `tests/test_v1.py` 회귀 통과 -5. 로컬 smoke 재실행으로 REL-001 회귀 봉합 확인 -6. confirm-batch-verification 통과 후 BAT-012 → release-candidate, REQ-002/REQ-010 → Implemented - -## Risks -- R-1 (Medium): `GET /v1/assets` 라우트 동작 변경 — REL-001 smoke에서 이미 회귀(HTTP 403 "Unsupported token type") 발생한 surface. service-token/user-token이 GET /v1/assets를 호출하는 코드 경로가 잔존하면 본 batch 배포 후 회귀가 다시 발생. 완화: design 단계에서 `grep -rn "GET.*/v1/assets" app/ tests/` 호출 그래프 전수 조사 + 회귀 테스트 명시. -- R-2 (Medium): BAT-002와 BAT-012 사이 REQ-010 trace 중복 — confirm-req-implemented 시 BAT-012 verification을 우선 적용하고 BAT-002의 REQ-010 trace를 augmentation superseded로 표기하지 않으면 release 트레이스가 모호해짐. 완화: design.md `Key Decisions`에 trace 정리 정책 명문화. -- R-3 (Low): assets 테이블에 `tag_ids` 컬럼 + GIN 인덱스 추가 시 lock — assets 테이블 규모가 작아(카탈로그 수십~수백 row 수준) 운영 영향 미미. 완화: 변경 없음. -- R-4 (Low): tags 마스터 is_active 조회로 archived 표시 합성 시 N+1 query 가능 — REQ-003 portfolios에서 이미 도입한 batch 조회 패턴 재사용. 완화: design에서 동일 헬퍼 사용 명시. diff --git a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/verification.md b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/verification.md deleted file mode 100644 index 55b8abd..0000000 --- a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/verification.md +++ /dev/null @@ -1,44 +0,0 @@ -# BAT-012 Verification - -- Status: confirmed -- Batch: BAT-012 - -## Acceptance Mapping -- REQ-002 (assets.tag_ids UUID[] 컬럼): - - AC-1 (`tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼 존재) ↔ `tests/test_asset_catalog.py::test_migration_assets_tag_ids_adds_column_and_gin_index` (migration 파일 구조에서 `add_column`/`UUID`/`nullable=False`/`'{}'::uuid[]` 확인). 실 DB 컬럼 존재는 REL 배포 시 alembic upgrade로 최종 확인. - - AC-2 (`idx_assets_tag_ids_gin` GIN 인덱스 존재) ↔ 동일 테스트의 `postgresql_using="gin"` + `idx_assets_tag_ids_gin` assertion. 실 DB 인덱스 존재는 REL 배포 시 alembic upgrade로 최종 확인. - - AC-3 (is_active=FALSE tag_id 포함 시 archived 표시 동반 반환) ↔ `tests/test_asset_catalog.py::test_get_v1_assets_archived_flag_for_inactive_tag` (실제 API 응답으로 `{tag_id, archived: True}` 확인). 단위는 `test_build_tag_refs_archived_for_inactive_tags`. - - AC-4 (빈 배열 기본값) ↔ `tests/test_asset_catalog.py::test_asset_model_tag_ids_defaults_to_empty_list` + `test_asset_catalog_response_schema_has_tag_ids_field` (default_factory=list) + `test_get_v1_assets_admin_returns_catalog_response`의 a-2 자산 `tag_ids == []` 검증. - -- REQ-010 (admin asset catalog CRUD + GET 재정의 augmentation): - - AC-1~8 (기존 CLI/HTTP CRUD + non-admin 403): BAT-002에서 이미 통과. 본 batch 회귀 suite로 회귀 확인 — `tests/test_asset_catalog.py::test_asset_cli_create_success / test_asset_cli_list_returns_all / test_asset_cli_list_active_only_filter / test_asset_cli_update_success / test_asset_cli_deactivate_success / test_asset_http_create_returns_201 / test_asset_http_update_returns_200 / test_asset_http_deactivate_returns_200 / test_asset_http_non_admin_403` 9건 PASS. - - AC-9 (admin GET 200 + AssetCatalogListResponse shape) ↔ `test_get_v1_assets_admin_returns_catalog_response` (status_code=200, `items`/`total` 키 + portfolio 필드 부재 확인). - - AC-10 (`status=all`/`status=inactive` 필터 + 기본 active) ↔ `test_get_v1_assets_status_filter_all_includes_inactive` + `test_get_v1_assets_status_filter_inactive_only` + `test_get_v1_assets_status_filter_default_active_only` + `test_get_v1_assets_status_filter_rejects_unknown_value` (422 반환). - - AC-11 (`market=KRX` 필터) ↔ `test_get_v1_assets_market_filter` (`markets`에 `KRX` 포함된 자산만 응답). - - AC-12 (non-admin → HTTP 403) ↔ `test_get_v1_assets_user_token_returns_403` + `test_get_v1_assets_service_token_returns_403`. - -## Evidence -- Test: - - `python -m pytest tests/test_asset_catalog.py` → **50 passed in 1.51s** (기존 33 + 신규 17) - - `python -m pytest` 전체 suite → **362 passed in 13.06s** (기존 330 + 신규 32, 회귀 0) -- Manual: - - 로컬 smoke 미실행 — pytest TestClient 기반 통합 테스트로 admin token + service/user token 분기 모두 자동 검증. REL 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인. -- Logs: - - pytest 출력은 implementation.md Execution Log 항목 9~10 참조. - -## Architecture Impact Sync -- design.md `Architecture Impact: runtime-flows` 명시됨. -- `docs/runtime-flows.md` 갱신 완료: - - `Flow: v1 API — GET /v1/assets` 본문을 token_type 분기 portfolio 조회 → admin asset catalog 조회 + tag_ids/archived 합성으로 재작성 - - Change Log entry 추가 (2026-05-21 KST BAT-012) -- `docs/project-structure.md`: Architecture Impact = none, 갱신 불요. -- `docs/data-model.md` (assets 스키마에 tag_ids 컬럼 명시) 및 `docs/interface-contract.md` (GET /v1/assets 응답에 tag_ids 필드 명세) 추가 docs sync는 별도 docs REQ로 분리 (본 batch verification blocker 아님). - -## Result -- Ready for Release: true -- Blocking Issues: - - 없음 (본 batch 범위 내 AC 전수 evidence 확보) -- Follow-up (release 또는 후속 REQ): - - REQ-005 follow-up: `POST/DELETE /v1/assets/{asset_id}/tags`가 portfolios 테이블을 잘못 대상화하는 잠재 버그 — 별도 REQ/Batch에서 정합화. - - BAT-002의 REQ-010 trace 정리: confirm-req-implemented 단계에서 BAT-012 trace 우선 적용 + BAT-002 표기를 `superseded by BAT-012`로 갱신. - - `docs/data-model.md` / `docs/interface-contract.md` docs sync REQ 발의 필요. diff --git a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/design.md b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/design.md deleted file mode 100644 index 71fe3d4..0000000 --- a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/design.md +++ /dev/null @@ -1,45 +0,0 @@ -# BAT-013 Design - -- Status: confirmed -- Batch: BAT-013 - -## Architecture Summary -- 본 batch는 신규 구조나 모듈을 도입하지 않는다. 기존 `app/repositories/postgres.py`에서 portfolios 테이블 대상 tag_ids mutation 메서드(`append_portfolio_tag_id`/`remove_portfolio_tag_id`)를 미러링하여 **assets 테이블 대상 메서드**를 신규 추가하고, `app/api/v1_assets.py`의 `apply_asset_tag`/`remove_asset_tag` 라우트 내부 호출을 새 메서드로 교체한다. -- HTTP contract, request/response schema, 라우트 시그니처, 정책 검증(404/403 분기), AuthContext 흐름은 전부 불변. 변경 표면은 두 라우트 내부의 repository 메서드 1줄씩 + 신규 repository 메서드 정의 + 테스트. -- 본질적으로 dcy-002 FR-2의 원래 의도(admin이 assets에 태그 부여)를 실제 SQL 경로에 반영하는 **정합화** 작업이며, BAT-012에서 도입한 `assets.tag_ids` 컬럼이 처음으로 정상 채워지게 된다. - -## Changed Areas -- Docs: - - `docs/runtime-flows.md` — Flow `v1 API — Tag Apply/Remove`의 L161, L165 `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id` → `repo.append_asset_tag_id`/`repo.remove_asset_tag_id`로 교체. Change Log 1줄 추가. -- Modules: - - `app/repositories/postgres.py` — `append_asset_tag_id(asset_id, tag_id)`, `remove_asset_tag_id(asset_id, tag_id)` 메서드 신규. - - `app/api/v1_assets.py` — L175, L200 내부 호출 교체 (라우트 시그니처/응답 schema/error handling 불변). -- Interfaces: - - HTTP: 무변. `POST /v1/assets/{asset_id}/tags`, `DELETE /v1/assets/{asset_id}/tags/{tag_id}` request/response 동일. - - Repository: `PostgresRepository`에 2개 메서드 신규. MemoryRepository 등 다른 구현체는 본 batch 범위 외 (실 사용 경로가 Postgres만 의존). -- Tests: - - `tests/test_asset_catalog.py` 또는 `tests/test_v1.py` — DB-integration fixture로 POST 후 `assets.tag_ids` mutation 확인 + DELETE 후 제거 확인 회귀 케이스 추가. - -## Key Decisions -1. **메서드 명명**: `append_asset_tag_id`/`remove_asset_tag_id` — 기존 `append_portfolio_tag_id`/`remove_portfolio_tag_id` 명명 패턴과 일관(테이블명_컬럼명_id). 분리된 메서드명을 채택하는 이유는 라우트 코드에서 호출 의도를 명확히 표현하기 위함 + 미래 정책 차이(예: assets에는 admin tag만, portfolios에는 owner tag 허용) 발생 시 두 경로의 분기를 안전하게 다룰 수 있게 하기 위함이다. 단일 `_append_tag_id(table, id, tag_id)` 헬퍼로 합치는 안은 채택하지 않는다 — 동적 테이블명 SQL은 가독성·정적 분석 모두 손해. -2. **멱등성 가드**: `append_asset_tag_id`는 기존 `append_portfolio_tag_id`와 동일하게 `WHERE asset_id = %s AND NOT (%s::uuid = ANY(tag_ids))` 가드를 적용한다. 동일 tag_id 재적용은 no-op (HTTP 200 그대로 반환). AC-1은 "tag_ids 배열에 추가된다"만 요구하고 중복 정책을 명시하지 않으므로 기존 portfolios 측 동작과 평행하게 유지. -3. **에러 분기**: rowcount=0인 경우 기존 portfolios 패턴과 동일하게 asset 존재 여부 추가 확인 후 미존재 시 `ValueError(f"asset을 찾을 수 없습니다: {asset_id}")` raise. 라우트 측은 이미 `repo.get(asset_id)`를 선행 호출해 404를 분기하므로 ValueError가 라우트까지 올라올 가능성은 사실상 race condition 한정. 그래도 가드는 유지(deletion 사이의 race로 빈 update가 발생할 경우 silent success로 빠지지 않도록). -4. **MemoryRepository 보강 여부**: 본 batch에서는 보강하지 않는다. 실제 운영 경로는 Postgres만 사용하며, MemoryRepository에 `append_portfolio_tag_id` 정의가 있는지 확인 후 동일 위치에 신규 메서드 정의가 필요한지만 implementation 단계에서 점검. 만약 MemoryRepository에 portfolios 측 메서드가 없다면 assets 측도 추가하지 않아 일관성 유지. -5. **테스트 위치**: `tests/test_asset_catalog.py`에 추가. BAT-012에서 동일 파일에 DB-integration 패턴(`test_get_v1_assets_*`)이 자리 잡았으므로 fixture·marker 재사용 최적. `tests/test_v1.py`는 기존에 portfolios.tag_ids 의존 fixture가 잔존할 가능성이 있어 신규 작성은 catalog 파일에 모은다. - -## Edge Cases -- **EC-1**: 존재하지 않는 `asset_id`로 POST → 라우트 선행 `repo.get(asset_id)` 단계에서 404 (기존 동작 그대로 회귀). -- **EC-2**: 존재하지 않는 `tag_id`로 POST → 라우트 선행 `repo.get_tag_by_id` 단계에서 404 (기존 동작 회귀). -- **EC-3**: `is_admin_tag=FALSE` 또는 `is_active=FALSE` → 403 (기존 동작 회귀). -- **EC-4**: 동일 tag_id 중복 POST → 멱등 (no-op + HTTP 200) — Key Decision 2. -- **EC-5**: 존재하지 않는 tag_id로 DELETE → 현재 `remove_portfolio_tag_id` 동작과 평행하게, asset이 존재하면 array_remove no-op (HTTP 200, status=removed)로 처리. 라우트 측 사전 검증(`repo.get(asset_id)`)이 asset 존재만 보장하고 tag 존재는 검증하지 않는 기존 의도 유지. -- **EC-6**: 동일 UUID가 우연히 asset과 portfolio 양쪽에 존재 → 본 batch 이후 POST `/v1/assets/{id}/tags`는 assets만 mutation, portfolios는 무변동. 회귀 테스트로 명시 검증. -- **EC-7**: `tag_ids` 컬럼 부재(스키마 마이그레이션 미적용 환경) → BAT-012 선행 가정. 본 batch는 컬럼 존재를 전제로 한다. (회귀 시 DB-integration fixture가 alembic upgrade head를 거치는지 implementation 단계에서 확인.) - -## Architecture Impact -- runtime-flows - -## Reference Doc Update Plan -- project-structure.md: none (파일·디렉토리·모듈 경계 무변). -- runtime-flows.md: L161, L165의 `repo.append_portfolio_tag_id(asset_id, ...)`/`repo.remove_portfolio_tag_id(asset_id, ...)`를 `repo.append_asset_tag_id`/`repo.remove_asset_tag_id`로 교체. Change Log에 `2026-05-21 KST: BAT-013 (REQ-005 CHG-20260521-02) — /v1/assets/{id}/tags apply/remove SQL 대상 assets 테이블로 정합화. portfolios 테이블 호출은 portfolio 라우트 전용으로 한정.` 추가. -- **별도 후속 항목 (out of scope, follow-up REQ 후보)**: `docs/runtime-flows.md` L67-77 (CLI Flow `pfhub tag apply asset` / `pfhub tag remove asset`)도 동일 `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id`를 호출하고 있어 동일한 mismatch가 CLI 레이어에 잔존. REQ-007/008 또는 신규 REQ로 처리. 본 batch는 REQ-005 HTTP API 범위에 한정. diff --git a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/implementation.md b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/implementation.md deleted file mode 100644 index 9d39ce1..0000000 --- a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/implementation.md +++ /dev/null @@ -1,48 +0,0 @@ -# BAT-013 Implementation - -- Status: completed -- Batch: BAT-013 - -## Plan Summary -- `app/repositories/postgres.py`에 `append_asset_tag_id`/`remove_asset_tag_id` 메서드 신규 (기존 portfolios 패턴 미러링, assets 테이블 대상). -- `app/api/v1_assets.py` `apply_asset_tag`(L175) / `remove_asset_tag`(L200) 내부 호출 교체. -- `tests/test_asset_catalog.py`에 BAT-013 회귀 케이스 추가 (메서드 존재·SQL 대상 테이블·에러 분기·멱등 가드 검증). -- `tests/test_tags_http_api.py` 기존 AC-1/AC-5 mock 단언을 신규 메서드로 갱신 + portfolios 메서드 무호출 단언 추가. -- `docs/runtime-flows.md` Flow `v1 API — Tag Apply/Remove` L161/L165 갱신 + Change Log 1줄 추가. - -## Changed Files -- `app/repositories/postgres.py` — `append_asset_tag_id`/`remove_asset_tag_id` 메서드 신규 (총 30줄 추가). 기존 portfolios 메서드 무변동. -- `app/api/v1_assets.py` — L175 / L200 호출 교체 (2줄). 라우트 시그니처·정책 검증·에러 분기 무변동. -- `tests/test_asset_catalog.py` — BAT-013 회귀 7건 신규 (메서드 존재 2건 + SQL 대상 검증 2건 + 에러 분기 2건 + 멱등 가드 1건). -- `tests/test_tags_http_api.py` — `_make_db_repo` 헬퍼에 `append_asset_tag_id`/`remove_asset_tag_id` MagicMock 보강; `test_apply_asset_tag_admin_success` / `test_remove_asset_tag_admin_success` 단언 교체 + portfolios 메서드 무호출 단언 추가. -- `docs/runtime-flows.md` — Flow `v1 API — Tag Apply/Remove` L161 / L165 메서드명 교체; Change Log에 BAT-013 항목 1줄 추가. - -## Execution Log -1. Repository 신규 메서드 추가 (`postgres.py` L1755 직후, `rotate_service_token` 앞에 삽입). -2. 라우트 호출 교체 (`apply_asset_tag` → `append_asset_tag_id`; `remove_asset_tag` → `remove_asset_tag_id`). -3. 기존 BAT-004 mock 테스트(`test_tags_http_api.py:343, 437`) 단언 갱신 — `mock_repo.append_portfolio_tag_id.assert_called_once_with` → `mock_repo.append_asset_tag_id.assert_called_once_with`. portfolios 호출은 `assert_not_called()`로 명시. -4. `_make_db_repo` 헬퍼에 신규 메서드 MagicMock 보강 (`return_value = None`). -5. BAT-013 회귀 테스트 7건 추가 (`tests/test_asset_catalog.py` 말미). 클래스명은 `PostgresPortfolioRepository`(실제 모듈 클래스). `__new__`로 인스턴스 생성 후 `_connect`/`ensure_schema` 패치하여 cursor mock으로 SQL 문자열 캡처. -6. `docs/runtime-flows.md` Flow 본문 L161/L165 갱신 + Change Log 추가. -7. pytest 1차: `tests/test_asset_catalog.py tests/test_tags_http_api.py` — 처음에 `ImportError: cannot import name 'PostgresRepository'` 발견 → 실제 클래스명 `PostgresPortfolioRepository`로 정정 후 79 passed. -8. pytest 전체: **369 passed in 11.48s** (BAT-012 직후 362 baseline 대비 +7 신규). - -## Validation -- 단위/회귀 검증: - - `tests/test_asset_catalog.py::test_postgres_repository_has_append_asset_tag_id_method` ✅ - - `tests/test_asset_catalog.py::test_postgres_repository_has_remove_asset_tag_id_method` ✅ - - `tests/test_asset_catalog.py::test_append_asset_tag_id_targets_assets_table_not_portfolios` ✅ - - `tests/test_asset_catalog.py::test_remove_asset_tag_id_targets_assets_table_not_portfolios` ✅ - - `tests/test_asset_catalog.py::test_append_asset_tag_id_raises_when_asset_not_found` ✅ - - `tests/test_asset_catalog.py::test_remove_asset_tag_id_raises_when_asset_not_found` ✅ - - `tests/test_asset_catalog.py::test_append_asset_tag_id_idempotent_guard_present` ✅ - - `tests/test_tags_http_api.py::test_apply_asset_tag_admin_success` (갱신) — `append_asset_tag_id`로 호출 단언 + portfolios 미호출 ✅ - - `tests/test_tags_http_api.py::test_remove_asset_tag_admin_success` (갱신) — `remove_asset_tag_id`로 호출 단언 + portfolios 미호출 ✅ - - 기존 AC-2~4, AC-6~11 케이스 회귀 ✅ (정책 검증 분기 무영향 확인) -- 전체 회귀: `pytest -q` → **369 passed** (BAT-012 직후 362 baseline 대비 +7 신규). -- DB-integration 실 mutation 검증: 본 batch는 cursor mock 기반 SQL 문자열 검증으로 대체. 실 PostgreSQL 환경 검증은 REL 배포 시 smoke로 수행 예정. - -## Remaining Risks -- **R1 (Low, 잔존)**: `pfhub tag apply asset` / `pfhub tag remove asset` CLI도 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출함(`docs/runtime-flows.md` L67-77 참조). REQ-005 scope는 HTTP API 한정이라 본 batch는 손대지 않음. **후속 REQ 후보**로 별도 발의 필요 — Feedback Handoff 또는 confirm-batch-verification 결과에 명시. -- **R2 (Low, 잔존)**: 실 PostgreSQL 환경에서 `assets.tag_ids` 실 mutation 검증은 REL 배포 시 smoke로 처리. unit-level SQL 문자열 검증으로 1차 안전망 확보. -- **R3 (Resolved)**: 클래스명 오류(`PostgresRepository` vs `PostgresPortfolioRepository`)는 implementation 단계에서 즉시 정정. diff --git a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/index.md b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/index.md deleted file mode 100644 index 0c518ad..0000000 --- a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/index.md +++ /dev/null @@ -1,35 +0,0 @@ -# BAT-013: asset tag apply/remove SQL 정합화 (REQ-005) - -- Status: released -- Profile: batch-lite -- Source Discovery: dcy-002 (REQ-005) -- Included REQ: REQ-005 -- Excluded REQ: none - -## Summary -- REQ-005 CHG-20260521-02 후속. `POST /v1/assets/{asset_id}/tags`와 `DELETE /v1/assets/{asset_id}/tags/{tag_id}` 라우트가 내부적으로 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출해 **portfolios 테이블**을 mutation하는 불일치를 해소한다. -- `app/repositories/postgres.py`에 `append_asset_tag_id`/`remove_asset_tag_id` 메서드를 신규 추가하고, `app/api/v1_assets.py`의 라우트 호출을 새 메서드로 교체한다. -- BAT-012로 도입된 `assets.tag_ids` 컬럼이 비로소 정상 채워지도록 apply/remove 경로를 정합화한다. - -## Scope -- In Scope: - - `app/repositories/postgres.py` — `append_asset_tag_id(asset_id, tag_id)`, `remove_asset_tag_id(asset_id, tag_id)` 메서드 신규 (assets 테이블 대상 `array_append`/`array_remove`) - - `app/api/v1_assets.py` — `apply_asset_tag`(L175) / `remove_asset_tag`(L200) 내부 호출 교체 (HTTP contract·라우트 surface 불변) - - `tests/test_asset_catalog.py` 또는 `tests/test_v1.py` — assets.tag_ids 실 mutation 회귀 케이스 추가; portfolios.tag_ids 무변동 확인 -- Out of Scope: - - HTTP API surface 변경 (요청/응답 schema 동일) - - portfolios 태그 apply/remove 경로(REQ-004) — 기존 동작 그대로 유지 - - BAT-004의 portfolio tagging 회귀 — 영향 없음 - - DB 마이그레이션 (assets.tag_ids 컬럼은 BAT-012/20260521_0023에서 이미 도입됨) - -## Documents -- Planning: [planning.md](./planning.md) -- Design: [design.md](./design.md) -- Implementation: [implementation.md](./implementation.md) -- Verification: [verification.md](./verification.md) - -## Notes -- 선행 완료: BAT-012(assets.tag_ids 컬럼/GIN 인덱스 도입) ✅, BAT-004(라우트·정책 검증 시점의 기존 구현) ✅ -- profile 선정 근거: 단일 Approved REQ + HTTP contract 무변 + DB 스키마 무변 → minor-change 후보. 라우트 내부 SQL 대상 테이블만 교체하는 좁은 범위 변경이라 `batch-lite`로 출발. -- design 생성 여부는 planning Design Gate에서 판단; 기존 `append_portfolio_tag_id` 패턴을 그대로 미러링하므로 design 생략 후보가 유력. -- BAT-004 trace는 REQ-005 evidence 무효(`Implemented Invalidated: yes`, REQ-005 CHG-20260521-02 참조). 본 batch 완료 시 REQ-005 trace는 BAT-013으로 갱신; BAT-004의 REQ-005 trace는 superseded로 표기. diff --git a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/planning.md b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/planning.md deleted file mode 100644 index 563e2c2..0000000 --- a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/planning.md +++ /dev/null @@ -1,58 +0,0 @@ -# BAT-013 Planning - -- Status: confirmed -- Batch: BAT-013 -- Profile: batch-lite - -## Included REQ -- REQ-005 (Interface, Approved) — 태그 적용·해제 HTTP API. CHG-20260521-02 augmentation으로 AC-1/AC-5의 "assets 테이블 tag_ids" 의미 명문화. BAT-013에서 실제 SQL 대상 테이블 교체. - -## Delivery Plan -1. **Repository 메서드 추가** (`app/repositories/postgres.py`) - - `append_asset_tag_id(asset_id: str, tag_id: str) -> None` - - 기존 `append_portfolio_tag_id` 패턴 미러링 (UPDATE assets SET tag_ids = array_append..., NOT (tag_id = ANY(tag_ids)) 가드, rowcount=0 시 존재 확인 후 ValueError). - - `remove_asset_tag_id(asset_id: str, tag_id: str) -> None` - - 기존 `remove_portfolio_tag_id` 패턴 미러링 (UPDATE assets SET tag_ids = array_remove..., rowcount=0 시 ValueError). -2. **라우트 호출 교체** (`app/api/v1_assets.py`) - - L175: `repo.append_portfolio_tag_id(asset_id, body.tag_id)` → `repo.append_asset_tag_id(asset_id, body.tag_id)` - - L200: `repo.remove_portfolio_tag_id(asset_id, tag_id)` → `repo.remove_asset_tag_id(asset_id, tag_id)` -3. **회귀 테스트 추가** (`tests/test_asset_catalog.py` 우선; 적합치 않으면 `tests/test_v1.py`) - - AC-1 회귀: POST /v1/assets/{id}/tags 200 후 PostgresRepository.get(asset_id).tag_ids 배열에 tag_id 포함 확인. - - AC-5 회귀: DELETE 후 동일 배열에서 tag_id 제거 확인. - - 부수 확인: 동일 호출 후 portfolios.tag_ids는 무변동(또는 해당 portfolio_id가 없어 mutation 안 일어남). -4. **BAT-004 기존 fixture 점검** - - BAT-004 verification에서 사용한 AC-1/AC-5 케이스가 portfolios.tag_ids 의존 fixture에 묻혀 통과한 흔적이 있는지 확인. - - 발견 시 assets 전용 fixture로 교체 또는 명확히 split. -5. **회귀 suite 실행** - - `pytest -q` 전체 → 362 baseline 유지 + 신규 회귀 통과 확인. - -## Dependencies -- Internal: - - REQ-002 / BAT-012 (assets.tag_ids 컬럼 + GIN 인덱스) — 완료. 본 batch의 SQL 대상 컬럼. - - REQ-001 / BAT-003 (tags 마스터 테이블) — 완료. 정책 검증(tag 존재/admin/active)에 사용. -- External: - - 없음. - -## Design Gate -- Design Required Before Implementation: **no** -- Reason: - - 변경은 기존 `append_portfolio_tag_id`/`remove_portfolio_tag_id` 패턴의 1:1 미러링이며, 새 구조나 설계 결정 없음. - - HTTP contract·schema·라우트 시그니처 모두 불변. - - DB 스키마 변경 없음 (assets.tag_ids는 BAT-012에서 도입 완료). - - 정책 검증 로직(tag is_admin_tag/is_active 확인, 404/403 분기)은 기존 코드 그대로 유지. - - implementation 진입 전 design 산출물 생성 시 잉여 작업이 됨. - -## Milestones -1. Repository 메서드 2종 추가 + 단위 동작 확인 -2. 라우트 호출 교체 (2 lines) -3. 회귀 테스트 추가 (AC-1/AC-5, 최소 2~4 케이스) -4. pytest 전체 통과 + BAT-004 잔존 fixture 점검 결과 기록 -5. `draft-batch-verification` → `confirm-batch-verification` → `confirm-req-implemented` - -## Risks -- **R1 (Low)**: BAT-004의 AC-1/AC-5 기존 테스트가 portfolios.tag_ids 검증으로 통과한 흔적이 남아 있어 본 batch 회귀 케이스와 의도 충돌. - - **대응**: 기존 케이스 식별 후 assets 전용 회귀로 교체 또는 split. 의도가 명확하지 않으면 BAT-004 측 케이스 제거 후 본 batch 케이스로 일원화. -- **R2 (Low)**: PostgresRepository가 없는 in-memory 환경에서 회귀 테스트 미작동. - - **대응**: 기존 BAT-012 회귀(test_asset_catalog.py)가 사용하는 DB-integration fixture 패턴 재사용. DB-integration이 어렵다면 `MemoryRepository`에도 동일 메서드를 보강(필요 시). -- **R3 (Low)**: 동일 호출자가 동일 tag_id를 중복 append 시 멱등성 가드 부재. - - **대응**: 기존 `append_portfolio_tag_id`가 `NOT (... = ANY(tag_ids))` 가드를 가짐 → 동일 패턴 채택. diff --git a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/verification.md b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/verification.md deleted file mode 100644 index caa026d..0000000 --- a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/verification.md +++ /dev/null @@ -1,47 +0,0 @@ -# BAT-013 Verification - -- Status: confirmed -- Batch: BAT-013 - -## Acceptance Mapping -- REQ-005 (태그 적용·해제 HTTP API) — CHG-20260521-02 명문화 범위: - - AC-1 (admin + admin 활성 tag → POST /v1/assets/{id}/tags 200, **assets 테이블 tag_ids에 추가**): - - HTTP 200 + status="applied" → `tests/test_tags_http_api.py::test_apply_asset_tag_admin_success` (라우트 호출 단언 갱신: `mock_repo.append_asset_tag_id.assert_called_once_with(...)` + `mock_repo.append_portfolio_tag_id.assert_not_called()`). - - SQL 대상 테이블 검증 → `tests/test_asset_catalog.py::test_append_asset_tag_id_targets_assets_table_not_portfolios` (`UPDATE assets` 포함, `UPDATE portfolios` 부재). - - 메서드 존재 → `tests/test_asset_catalog.py::test_postgres_repository_has_append_asset_tag_id_method`. - - 멱등 가드 → `tests/test_asset_catalog.py::test_append_asset_tag_id_idempotent_guard_present` (`NOT ... = ANY(tag_ids)`). - - AC-2 (admin + is_admin_tag=FALSE → 403): `tests/test_tags_http_api.py::test_apply_asset_tag_non_admin_tag_returns_403` (BAT-004 기존 케이스, 정책 검증 라우트 분기 무영향 회귀). - - AC-3 (admin + is_active=FALSE → 403): `tests/test_tags_http_api.py::test_apply_asset_tag_inactive_returns_403`. - - AC-4 (non-admin → 403): `tests/test_tags_http_api.py::test_apply_asset_tag_non_admin_token_returns_403`. - - AC-5 (admin → DELETE 200, **assets 테이블 tag_ids에서 제거**): - - HTTP 200 + status="removed" → `tests/test_tags_http_api.py::test_remove_asset_tag_admin_success` (라우트 호출 단언 갱신: `mock_repo.remove_asset_tag_id.assert_called_once_with(...)` + `mock_repo.remove_portfolio_tag_id.assert_not_called()`). - - SQL 대상 테이블 검증 → `tests/test_asset_catalog.py::test_remove_asset_tag_id_targets_assets_table_not_portfolios`. - - 메서드 존재 → `tests/test_asset_catalog.py::test_postgres_repository_has_remove_asset_tag_id_method`. - - asset 부재 시 ValueError → `tests/test_asset_catalog.py::test_remove_asset_tag_id_raises_when_asset_not_found` (라우트는 사전 `repo.get(asset_id)`로 404 분기). - - AC-6~11 (portfolio 측 apply/remove + 404 분기): 본 batch 범위 외(portfolios 경로). BAT-004 기존 케이스 회귀 — `tests/test_tags_http_api.py::test_apply_portfolio_tag_owner_own_tag / test_apply_portfolio_tag_owner_admin_tag / test_apply_portfolio_tag_other_user_non_admin_tag_returns_403 / test_remove_portfolio_tag_owner_success / test_apply_portfolio_tag_nonexistent_tag_returns_404 / *_nonexistent_portfolio_*` PASS (assets 경로 변경이 portfolios 경로에 무영향임을 회귀 확인). - - EC-1 (asset_id 부재 시 404): 라우트 사전 `repo.get(asset_id)` 분기 무변동 + `tests/test_asset_catalog.py::test_append_asset_tag_id_raises_when_asset_not_found` (repository 레이어 안전망). - -## Evidence -- Test: - - `.venv/bin/python -m pytest tests/test_asset_catalog.py tests/test_tags_http_api.py -q` → **79 passed in 2.65s** (BAT-013 신규 7건 + BAT-004 기존 회귀 + BAT-012 기존 회귀 모두 통과). - - `.venv/bin/python -m pytest -q` (전체) → **369 passed in 11.48s** (BAT-012 직후 362 baseline 대비 +7 신규, 회귀 0). -- Manual: - - 로컬 smoke 미실행 — TestClient 기반 mock + cursor-mock SQL 검증으로 1차 안전망 확보. 실 PostgreSQL `assets.tag_ids` mutation 검증은 REL 배포 시 smoke로 수행 예정. -- Logs: - - pytest 출력은 implementation.md Execution Log 항목 7~8 참조. - -## Architecture Impact Sync -- design.md `Architecture Impact: runtime-flows` 명시됨. -- `docs/runtime-flows.md` 갱신 완료: - - Flow `v1 API — Tag Apply/Remove` L161, L165의 `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id` → `repo.append_asset_tag_id`/`repo.remove_asset_tag_id`로 교체 + BAT-013 정합화 사유 inline 주석. - - Change Log entry 추가 (2026-05-21 KST BAT-013). -- `docs/project-structure.md`: Architecture Impact = none, 갱신 불요. - -## Result -- Ready for Release: true -- Blocking Issues: - - 없음 (본 batch 범위 내 AC 전수 evidence 확보) -- Follow-up (release 또는 후속 REQ): - - **CLI 동일 mismatch 잔존**: `pfhub tag apply asset` / `pfhub tag remove asset` CLI(`docs/runtime-flows.md` L67-77)도 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출함. REQ-005 scope는 HTTP API 한정이라 본 batch는 손대지 않음. **REQ-007/008 augmentation 또는 신규 REQ 후보**. - - 실 PostgreSQL `assets.tag_ids` mutation 회귀: REL 배포 직후 smoke로 admin POST → GET /v1/assets에서 tag_ids 채워짐 확인. - - BAT-004의 REQ-005 trace는 본 batch 완료 후 `confirm-req-implemented` 단계에서 `superseded by BAT-013`로 표기 권장. diff --git a/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/implementation.md b/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/implementation.md deleted file mode 100644 index c7149c7..0000000 --- a/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/implementation.md +++ /dev/null @@ -1,46 +0,0 @@ -# BAT-014 Implementation - -- Status: completed -- Batch: BAT-014 - -## Plan Summary -- planning.md Delivery Plan에 따라 (1) `PortfolioItem` schema에 `symbol` 추가 → (2) `_portfolio_to_item()` serialization 보강 → (3) CRUD/list 테스트 expectation 갱신 → (4) `docs/interface-contract.md` 계약 반영 → (5) pytest 검증 순서로 구현 완료. -- 사용자가 명시한 핵심 요구대로 `GET /v1/portfolios` 는 **asset join 결과를 응답**해야 하므로, repository shape(`_SELECT_PORTFOLIO` + `_row_to_portfolio`)를 유지한 채 응답 schema/문서/테스트를 정합화했다. - -## Changed Files -- `app/schemas/v1.py` - - `PortfolioItem` 에 `symbol: str | None = None` 필드 추가 -- `app/api/v1_portfolios.py` - - `_portfolio_to_item()` 에 `symbol=portfolio.symbol` 추가 -- `tests/test_v1_portfolios_list.py` - - GET `/v1/portfolios` 응답의 `symbol == "005930"` 검증 추가 - - 기존 `asset_id` 검증 케이스에도 `symbol` assertion 추가 -- `tests/test_v1_portfolios_crud.py` - - POST/PATCH/DELETE 성공 응답에서 `symbol == "005930"` 검증 추가 -- `docs/interface-contract.md` - - POST/PATCH/DELETE/GET `/v1/portfolios` 응답 설명에 `symbol` 필드 반영 - - GET `/v1/portfolios` 가 server-side `portfolios JOIN assets` 결과를 직렬화한다는 설명 추가 - -## Execution Log -1. BAT-014 planning/index와 REQ-025/026/027/028/032를 읽고 구현 범위를 확정했다. -2. `app/schemas/v1.py` 에 `PortfolioItem.symbol` 필드를 추가했다. -3. `app/api/v1_portfolios.py` `_portfolio_to_item()` 에 `symbol=portfolio.symbol` 매핑을 추가했다. -4. `tests/test_v1_portfolios_crud.py` 의 POST/PATCH/DELETE 성공 케이스에 `symbol == "005930"` assertion을 추가했다. -5. `tests/test_v1_portfolios_list.py` 의 service-token list 케이스와 `asset_id` 검증 케이스에 `symbol == "005930"` assertion을 추가했다. -6. `docs/interface-contract.md` 의 POST/PATCH/DELETE/GET `/v1/portfolios` 명세를 갱신해 `symbol` 과 join 기반 응답 계약을 명문화했다. -7. `app/repositories/postgres.py` 를 재검토해 `_row_to_portfolio()` 가 `symbol=row[4]` 를 매핑하고, `get()` / `create_portfolio()` / `update_portfolio()` / `soft_delete_portfolio()` / `list_portfolios()` 가 모두 `_SELECT_PORTFOLIO` 기반 join 결과를 사용함을 확인했다. -8. `./.venv/bin/python -m pytest tests/test_v1_portfolios_crud.py tests/test_v1_portfolios_list.py -q` 실행 → **32 passed in 2.13s**. -9. `./.venv/bin/python -m pytest -q` 전체 회귀 실행 → **371 passed in 11.00s**. - -## Validation -- Targeted regression: - - `./.venv/bin/python -m pytest tests/test_v1_portfolios_crud.py tests/test_v1_portfolios_list.py -q` → **32 passed in 2.13s** -- Full regression: - - `./.venv/bin/python -m pytest -q` → **371 passed in 11.00s** -- Repository join evidence: - - `_row_to_portfolio()` 가 `symbol=row[4]` 를 `Portfolio.symbol` 로 매핑 - - `get()` / `create_portfolio()` / `update_portfolio()` / `soft_delete_portfolio()` / `list_portfolios()` 가 `_SELECT_PORTFOLIO` join 결과를 재사용 - -## Remaining Risks -- R1 (Low): 이번 batch의 symbol 회귀는 FastAPI/TestClient + fake repo 중심이다. 다만 repository 코드 inspection으로 실제 Postgres 경로도 `assets.symbol` join 결과를 응답까지 유지함을 확인했다. -- R2 (Negligible): pytest 출력에 기존 `HTTP_422_UNPROCESSABLE_ENTITY` deprecation warning 2~3건이 남아 있으나 BAT-014 범위와 직접 관련은 없고 기능 실패는 없다. diff --git a/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/index.md b/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/index.md deleted file mode 100644 index 9d9f07d..0000000 --- a/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/index.md +++ /dev/null @@ -1,33 +0,0 @@ -# BAT-014: PortfolioItem symbol 응답 계약 보강 (REQ-025, REQ-026, REQ-027, REQ-028, REQ-032) - -- Status: released -- Profile: batch-lite -- Source Discovery: dcy-003 -- Included REQ: REQ-025, REQ-026, REQ-027, REQ-028, REQ-032 -- Excluded REQ: REQ-029, REQ-030, REQ-031 - -## Summary -- trex-orbit 팀 이슈(`docs/issues/20260524_portfolio-hub-ticket-symbol-in-portfolios.md`) 후속으로, `PortfolioItem` 기반 v1 portfolio 응답 계약에 `symbol` 필드를 추가한다. -- 특히 `GET /v1/portfolios` 는 클라이언트 캐시 복원이 아니라 **`portfolios JOIN assets` 결과를 그대로 응답**해야 하며, `symbol` 은 linked asset row의 `assets.symbol` 이어야 한다. -- POST/PATCH/DELETE `/v1/portfolios` 응답도 동일한 `PortfolioItem` contract를 따르도록 정합성을 맞추고, baseline 문서(`docs/interface-contract.md`)에 이를 반영한다. - -## Scope -- In Scope: - - `app/schemas/v1.py` — `PortfolioItem.symbol: str | None = None` 추가 - - `app/api/v1_portfolios.py` — `_portfolio_to_item()` 에 `symbol=portfolio.symbol` 반영 - - `app/repositories/postgres.py` — GET/list/create/update/delete 응답이 asset join 결과를 유지하는지 검증 - - `tests/test_v1_portfolios_list.py` — GET `/v1/portfolios` 응답 `symbol` 검증 추가 - - `tests/test_v1_portfolios_crud.py` — POST/PATCH/DELETE 응답 `symbol` 검증 추가 - - `docs/interface-contract.md` — `PortfolioItem` 응답 예시/설명에 `symbol` 및 GET join 기반 응답 설명 추가 -- Out of Scope: - - portfolio write semantics 변경 (ownership, soft delete, disabled 보호 정책 자체는 불변) - - `/v1/portfolios/{id}/ledger/trades`, `/tags`, `/comments` 계약 변경 - - assets catalog schema 또는 DB migration - -## Documents -- Planning: [planning.md](./planning.md) - -## Notes -- profile 선정 근거: follow-up change는 기존 Implemented REQ의 응답 contract augmentation이며 범위가 좁고 additive 성격이 강해 `batch-lite` 로 시작한다. -- 다만 `docs/interface-contract.md` 갱신은 필수이므로 문서 정합성까지 한 배치에서 닫는다. -- BAT-009 / BAT-010 verification evidence는 본 batch 완료 후 superseded 표기가 필요할 수 있다. diff --git a/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/planning.md b/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/planning.md deleted file mode 100644 index 3fe5e36..0000000 --- a/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/planning.md +++ /dev/null @@ -1,50 +0,0 @@ -# BAT-014 Planning - -- Status: draft -- Batch: BAT-014 -- Profile: batch-lite - -## Included REQ -- REQ-025: POST /v1/portfolios 응답에 `PortfolioItem.symbol` 포함 -- REQ-026: PATCH /v1/portfolios/{portfolio_id} 응답에 `PortfolioItem.symbol` 포함 -- REQ-027: DELETE /v1/portfolios/{portfolio_id} (HTTP 200 응답) 에 `PortfolioItem.symbol` 포함 -- REQ-028: GET /v1/portfolios 응답이 `portfolios JOIN assets` 결과를 기반으로 `symbol` 을 직접 반환 -- REQ-032: baseline 문서에 위 계약 반영 - -## Delivery Plan -1. `app/schemas/v1.py` 의 `PortfolioItem` 에 `symbol: str | None = None` 추가 -2. `app/api/v1_portfolios.py` 의 `_portfolio_to_item()` 에 `symbol=portfolio.symbol` 추가 -3. `app/repositories/postgres.py` 의 `_SELECT_PORTFOLIO` / `_row_to_portfolio` / `get()` / `list_portfolios()` 흐름을 재확인해 GET `/v1/portfolios` 가 asset join 결과를 응답까지 유지하는지 검증 -4. `tests/test_v1_portfolios_list.py` 에서 GET 응답 item의 `symbol` 존재 및 기대값(`005930`) 검증 추가 -5. `tests/test_v1_portfolios_crud.py` 에서 POST/PATCH/DELETE 응답의 `symbol` 존재 및 기대값 검증 추가 -6. `docs/interface-contract.md` 에 POST/PATCH/DELETE/GET `/v1/portfolios` 응답 설명의 `symbol` 반영 및 GET의 asset join 기반 응답 설명 추가 -7. 회귀 테스트 실행 및 결과를 후속 verification 단계에 기록 - -## Dependencies -- Internal: - - BAT-009 (v1 portfolio CRUD core) — 기존 구현과 테스트 기반 - - BAT-010 (portfolio v1 baseline docs) — interface-contract baseline 업데이트 trace -- External: - - 없음 - -## Design Gate -- Design Required Before Implementation: **no** -- Reason: - - 변경은 기존 CRUD/list 응답 contract에 additive field 하나를 추가하고 문서/테스트를 보강하는 수준이다. - - `app/repositories/postgres.py` 는 이미 `_SELECT_PORTFOLIO` 에서 `JOIN assets a ON p.asset_id = a.asset_id` 및 `a.symbol` 선택을 수행하고 있어, 새 쿼리 전략이나 구조 재설계가 필요하지 않다. - - 사용자가 명시한 핵심 제약(`GET /v1/portfolios` 는 asset join 결과를 응답해야 함)도 현재 repository shape와 정합한다. 따라서 design 산출물 없이 구현/검증으로 바로 진행 가능하다. - -## Milestones -1. Schema/API serialization 반영 -2. CRUD/list 테스트 보강 -3. baseline 문서 갱신 -4. 회귀 테스트 실행 -5. verification 근거 정리 후 REQ 재Implemented 검토 - -## Risks -- **R1 (Low)**: 테스트 fixture가 in-memory fake repo를 사용하므로 repository-level join 보장을 직접 증명하지 못할 수 있음. - - **대응**: GET contract 문서와 repository 코드 확인 결과를 verification에 함께 기록하고, 필요 시 repo integration test 추가를 검토. -- **R2 (Low)**: `PortfolioItem` 에 `symbol` 추가 시 기존 스냅샷/응답 dict equality 테스트가 깨질 수 있음. - - **대응**: 테스트 expectation을 additive field 기준으로 갱신. -- **R3 (Low)**: baseline 문서가 POST/PATCH/DELETE/GET 중 일부 응답만 갱신되어 source of truth 불일치가 남을 수 있음. - - **대응**: `docs/interface-contract.md` 네 경로를 한 턴에 모두 갱신. diff --git a/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/verification.md b/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/verification.md deleted file mode 100644 index 4386aca..0000000 --- a/docs/batches/bat-014_20260524_portfolioitem-symbol-response-contract/verification.md +++ /dev/null @@ -1,79 +0,0 @@ -# BAT-014 Verification - -- Status: confirmed -- Batch: BAT-014 - -## Acceptance Mapping - -### REQ-025: POST /v1/portfolios -- AC-1 (admin + user_id → target owner): `tests/test_v1_portfolios_crud.py::test_post_admin_creates_for_target_user` PASSED — `owner_user_id == _OTHER_USER_ID`. -- AC-2 (session/env token → own user/fallback): `tests/test_v1_portfolios_crud.py::test_post_user_creates_for_self` PASSED. -- AC-3 (service token → 403): `tests/test_v1_portfolios_crud.py::test_post_service_token_returns_403` PASSED. -- AC-5 (inactive/missing asset → 422): `tests/test_v1_portfolios_crud.py::test_post_inactive_asset_returns_422`, `tests/test_v1_portfolios_crud.py::test_post_missing_asset_returns_422` PASSED. -- AC-6 (no token → 401): `tests/test_v1_portfolios_crud.py::test_post_no_bearer_returns_401` PASSED. -- AC-7 (non-admin body user_id ignored): `tests/test_v1_portfolios_crud.py::test_post_non_admin_user_id_ignored` PASSED. -- AC-8 (POST 응답 `symbol` == joined `assets.symbol`): `tests/test_v1_portfolios_crud.py::test_post_admin_creates_for_target_user` PASSED — response body `symbol == "005930"`. - -### REQ-026: PATCH /v1/portfolios/{portfolio_id} -- AC-1 (admin → any portfolio): `tests/test_v1_portfolios_crud.py::test_patch_admin_updates_any_portfolio` PASSED — `broker == "ebest"`. -- AC-2 (session token + own portfolio → 200): `tests/test_v1_portfolios_crud.py::test_patch_user_updates_own_portfolio` PASSED. -- AC-3 (session token + other portfolio → 403): `tests/test_v1_portfolios_crud.py::test_patch_user_other_portfolio_returns_403` PASSED. -- AC-4 (service token → 403): `tests/test_v1_portfolios_crud.py::test_patch_service_token_returns_403` PASSED. -- AC-6 (no token → 401): `tests/test_v1_portfolios_crud.py::test_patch_no_bearer_returns_401` PASSED. -- AC-7 (non-existent → 404): `tests/test_v1_portfolios_crud.py::test_patch_nonexistent_portfolio_returns_404` PASSED. -- AC-8 (disabled + non-status field → 409): `tests/test_v1_portfolios_crud.py::test_patch_disabled_portfolio_non_status_returns_409` PASSED. -- AC-9 (disabled + status=enabled → 200): `tests/test_v1_portfolios_crud.py::test_patch_disabled_portfolio_status_restore_returns_200` PASSED. -- AC-10 (PATCH 응답 `symbol` == joined `assets.symbol`): `tests/test_v1_portfolios_crud.py::test_patch_admin_updates_any_portfolio`, `tests/test_v1_portfolios_crud.py::test_patch_disabled_portfolio_status_restore_returns_200` PASSED — both assert `symbol == "005930"`. - -### REQ-027: DELETE /v1/portfolios/{portfolio_id} -- AC-1 (session + own → 200, disabled, daemon_id NULL): `tests/test_v1_portfolios_crud.py::test_delete_user_own_active_portfolio_soft_deletes` PASSED. -- AC-3 (admin + other user → 200): `tests/test_v1_portfolios_crud.py::test_delete_admin_other_user_portfolio` PASSED. -- AC-4 (session + other → 403): `tests/test_v1_portfolios_crud.py::test_delete_user_other_portfolio_returns_403` PASSED. -- AC-5 (service token → 403): `tests/test_v1_portfolios_crud.py::test_delete_service_token_returns_403` PASSED. -- AC-7 (no token → 401): `tests/test_v1_portfolios_crud.py::test_delete_no_bearer_returns_401` PASSED. -- AC-8 (non-existent → 404): `tests/test_v1_portfolios_crud.py::test_delete_nonexistent_portfolio_returns_404` PASSED. -- AC-11 (DELETE 200 응답 `symbol` == joined `assets.symbol`): `tests/test_v1_portfolios_crud.py::test_delete_user_own_active_portfolio_soft_deletes`, `tests/test_v1_portfolios_crud.py::test_delete_admin_other_user_portfolio` PASSED — both assert `symbol == "005930"`. - -### REQ-028: GET /v1/portfolios -- AC-1 (service + daemon_id → own daemon portfolios): `tests/test_v1_portfolios_list.py::test_get_service_token_daemon_bound_returns_own_portfolios` PASSED. -- AC-2 (service no daemon → 403): `tests/test_v1_portfolios_list.py::test_get_service_token_no_daemon_returns_403` PASSED. -- AC-3 (session → own portfolios): `tests/test_v1_portfolios_list.py::test_get_user_token_returns_own_portfolios` PASSED. -- AC-4 (admin no ?user_id → default branch): `tests/test_v1_portfolios_list.py::test_get_admin_default_returns_own_portfolios` PASSED. -- AC-5 (admin + ?user_id → target): `tests/test_v1_portfolios_list.py::test_get_admin_with_user_id_returns_target_portfolios` PASSED. -- AC-7 (disabled excluded by default): `tests/test_v1_portfolios_list.py::test_get_excludes_disabled_by_default` PASSED. -- AC-8 (`include_disabled=true` → all): `tests/test_v1_portfolios_list.py::test_get_include_disabled_returns_all` PASSED. -- AC-9 (client token → 403): `tests/test_v1_portfolios_list.py::test_get_client_token_returns_403` PASSED. -- AC-10 (no token → 401): `tests/test_v1_portfolios_list.py::test_get_no_bearer_returns_401` PASSED. -- AC-11 (`asset_id` response field): `tests/test_v1_portfolios_list.py::test_get_response_has_asset_id_field` PASSED. -- AC-12 (`symbol` == joined `assets.symbol`): `tests/test_v1_portfolios_list.py::test_get_service_token_daemon_bound_returns_own_portfolios`, `tests/test_v1_portfolios_list.py::test_get_response_has_asset_id_field` PASSED — both assert `symbol == "005930"`. - -### REQ-032: portfolio v1 baseline 문서 갱신 -- AC-1 (`docs/interface-contract.md` 에 v1 portfolio route entries 존재): 문서 유지 + POST/PATCH/DELETE/GET `/v1/portfolios` 섹션 갱신 완료. -- AC-6 (사람 리뷰 기준 baseline 충돌 없음): 이번 batch 범위에서 `docs/interface-contract.md` 의 portfolio response contract를 코드와 정합화했다. -- AC-7 (`symbol` 필드 + GET join 설명): `docs/interface-contract.md` 에서 POST/PATCH/DELETE/GET `/v1/portfolios` 응답 설명에 `symbol` 추가, GET 섹션에 `portfolios JOIN assets` 기반 직렬화 설명 추가 완료. - -## Evidence -- Test: - - `./.venv/bin/python -m pytest tests/test_v1_portfolios_crud.py tests/test_v1_portfolios_list.py -q` → **32 passed in 2.13s** - - `./.venv/bin/python -m pytest -q` → **371 passed in 11.00s** -- Manual / code inspection: - - `app/repositories/postgres.py::_row_to_portfolio()` 는 `symbol=row[4]` 를 `Portfolio.symbol` 로 매핑한다. - - `app/repositories/postgres.py::get()` / `create_portfolio()` / `update_portfolio()` / `soft_delete_portfolio()` / `list_portfolios()` 는 `_SELECT_PORTFOLIO` 기반 join 결과를 재사용한다. - - 따라서 GET 뿐 아니라 POST/PATCH/DELETE 응답도 linked asset row의 `assets.symbol` 을 유지한다. -- Logs: - - pytest 출력은 `implementation.md` Execution Log 8~9 항목 참조. - -## Architecture Impact Sync -- Profile이 `batch-lite` 이고 planning.md 의 Design Gate는 `no` 다. -- 실제 구현은 schema/API/test/docs contract augmentation에 한정되며 신규 쿼리 전략/모듈/마이그레이션이 없다. -- `app/repositories/postgres.py` 의 기존 join shape를 재사용했으므로 **no architecture impact confirmed**. -- Baseline docs sync는 REQ-032 범위에 맞춰 `docs/interface-contract.md` 갱신으로 충족했다. `docs/project-structure.md` / `docs/runtime-flows.md` / `docs/data-model.md` 는 이번 변경으로 추가 수정이 필요한 구조 변화가 없다. - -## Result -- Ready for Release: true -- Blocking Issues: - - 없음 (본 batch 범위 내 AC evidence 확보) -- Follow-up: - - BAT-009 verification의 REQ-025/026/027/028 response-contract evidence는 BAT-014 기준으로 superseded 처리. - - BAT-010 verification의 REQ-032 interface-contract evidence는 BAT-014 기준으로 superseded 처리. - - 기존 422 deprecation warning(`HTTP_422_UNPROCESSABLE_ENTITY`) 정리는 별도 housekeeping 성격의 후속 change로 분리 가능. diff --git a/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/design.md b/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/design.md deleted file mode 100644 index c83681c..0000000 --- a/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/design.md +++ /dev/null @@ -1,32 +0,0 @@ -# BAT-015 Design - -- Status: confirmed -- Batch: BAT-015 - -## Architecture Summary -- REL-001 잔여 정합화 3건을 단일 batch로 묶어 처리한다. DB schema 마이그레이션 1건(REQ-033), Pydantic 스키마 강화 1건(REQ-034), Health 응답 + baseline 문서 갱신 1건(REQ-035). 변경 면적은 좁지만 cross-cutting nature(데이터 + 인증 + 운영)로 한 batch에 응집된다. - -## Changed Areas -- Docs: `docs/data-model.md`(ledger_trades 컬럼), `docs/interface-contract.md`(/health 응답), `docs/runtime-flows.md`(health probe 의미 명문화 — baseline 갱신) -- Modules: `app/repositories/postgres.py`(ledger_trades SQL), `app/domain/models.py`(LedgerTrade dataclass, 있다면), `app/api/v1_portfolios.py`(ledger handler 응답 매핑, 있다면), `app/schemas/auth.py`(LoginRequest model_config), `app/api/health.py`(HealthResponse + handler), `migrations/versions/`(신규 revision) -- Interfaces: `POST /v1/portfolios/{id}/ledger/trades` 응답 schema 일관성, `POST /auth/login` 422 거부 동작, `GET /health` 응답 확장 - -## Key Decisions -1. **REQ-033 마이그레이션 전략**: 단일 `ALTER TABLE ledger_trades RENAME COLUMN asset_id TO portfolio_id` 트랜잭션 사용. 별도 컬럼 추가 + backfill + drop 패턴은 채택하지 않음(데이터 의미가 이미 portfolio_id로 일관되어 backfill 불요, dcy-005 A-1 가정 + 사용자 confirm). -2. **REQ-034 enforcement 위치**: `LoginRequest.model_config = ConfigDict(extra="forbid")` 스키마 단계에서 처리. `AuthService.login`에 별도 검증 로직 추가 안 함(스키마 단계에서 Pydantic 표준 422 응답이 더 명확). -3. **REQ-035 db_enabled 의미**: `settings.db_enabled`(환경변수) 값 노출. actual DB ping은 본 batch 범위 밖이며 `docs/runtime-flows.md`에 의미 명문화로 해소. -4. **baseline 갱신 위치**: `docs/runtime-flows.md`의 "Health Probe Flow"(또는 유사 섹션)에 `db_enabled` 의미 노트 1단락 추가. `docs/project-structure.md`는 영향 없음. -5. **마이그레이션 revision 번호**: 기존 head(`20260521_0023`) 다음으로 `20260601_NNNN_ledger_trades_portfolio_id_rename.py`. revision 본문은 `op.execute("ALTER TABLE ... RENAME COLUMN ...")` 또는 `op.alter_column(...)` 패턴. - -## Edge Cases -- REQ-034: 기존 `LoginRequest`에 정의된 필드만 보내는 client는 영향 없음. `username`/`password` 외 어떤 키든 422로 거부됨. -- REQ-035: `PORTFOLIOHUB_DB_ENABLED` 환경변수가 미설정(default `false`)인 경우 `db_enabled: false` 응답. 본 워크스페이스 `.env`는 `true`로 설정되어 있음. -- REQ-033: `ledger_trades`에 데이터가 없는 환경(개발 초기)에서도 ALTER COLUMN RENAME은 즉시 완료. 대량 데이터 환경 staging은 dry-run에서 측정. -- 3건 통합: 동일 release에 묶일 예정이지만 각 변경이 서로 의존하지 않으므로 implementation 순서 자유. planning에서 권장 순서(REQ-034 → REQ-035 → REQ-033) 명시. - -## Architecture Impact -- runtime-flows - -## Reference Doc Update Plan -- project-structure.md: none (구조 변경 없음, 기존 모듈에 한정된 변경) -- runtime-flows.md: REQ-035에서 `/health` probe flow 섹션에 "`db_enabled`는 환경변수 `PORTFOLIOHUB_DB_ENABLED` 설정값 노출이며 actual DB connectivity ping이 아니다. production-grade readiness probe는 별도 `/readiness` endpoint 분리로 후속 처리"의 의미 명문화 노트 1단락 추가 diff --git a/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/implementation.md b/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/implementation.md deleted file mode 100644 index 4b00396..0000000 --- a/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/implementation.md +++ /dev/null @@ -1,53 +0,0 @@ -# BAT-015 Implementation - -- Status: completed -- Batch: BAT-015 - -## Plan Summary -- 변경 순서: REQ-034 → REQ-035 → REQ-033 (planning 권장 순서대로 진행) -- 각 REQ 구현 후 부분 회귀 (pytest 전체 + 해당 영역 smoke) -- 최종 batch 통합 회귀 + 로컬 smoke - -## Changed Files -- REQ-034: - - `app/schemas/auth.py` — `LoginRequest.model_config = ConfigDict(extra="forbid")` 추가 - - `tests/test_auth.py` — `test_login_rejects_no_expiry_extra_field`, `test_login_rejects_arbitrary_extra_field` 신규 -- REQ-035: - - `app/api/health.py` — `HealthResponse.db_enabled` 필드 추가 + `healthcheck()` 핸들러가 `settings.db_enabled` 노출 - - `tests/test_app.py` — `test_healthcheck`에 db_enabled 확인 추가, `test_health_exposes_db_enabled_true`, `test_health_exposes_db_enabled_false` 신규 - - `docs/runtime-flows.md` — health endpoint 섹션(line 97)에 `db_enabled` 의미 명문화 노트 추가 - - `docs/interface-contract.md` — `/health` payload schema에 `db_enabled` 필드 추가 + 의미 노트 -- REQ-033: - - `migrations/versions/20260601_0024_ledger_trades_portfolio_id_rename.py` — 신규 alembic revision (ALTER COLUMN RENAME with has_table guard) - - `app/domain/models.py` — `LedgerTrade.asset_id` → `portfolio_id` - - `app/repositories/portfolio_repository.py` — `insert_ledger_trade` interface 시그니처 갱신 - - `app/repositories/postgres.py` — `insert_ledger_trade` INSERT/SELECT/RETURNING SQL 컬럼명 + `_row_to_ledger_trade` 매핑 - - `app/schemas/v1.py` — `LedgerTradeResponse.asset_id` → `portfolio_id` - - `app/api/v1_portfolios.py` — ledger handler에서 `insert_ledger_trade(portfolio_id=...)` + 응답 매핑 - - `tests/test_v1_portfolios_ledger.py` — `LedgerTrade(portfolio_id=...)` + 응답 `body["portfolio_id"]` 검증 - - `tests/test_v1.py` — `_make_ledger_trade` + `body["portfolio_id"]` 검증 - - `tests/test_daemon_assignment.py` — mock `trade.portfolio_id` 갱신 - - `docs/data-model.md` — LedgerTrade entity, Key Fields, Uniqueness Constraints, State Rules의 `asset_id` 표기 모두 `portfolio_id`로 갱신 + revision 20260601_0024 명시 - - `docs/interface-contract.md` — `POST /v1/portfolios/{id}/ledger/trades` 응답 schema에 `portfolio_id` 명시 + REQ-033 cross-reference - -## Execution Log -1. REQ-034: `LoginRequest`에 `extra="forbid"` 적용 → `tests/test_auth.py` 16 passed -2. REQ-035: `HealthResponse` 확장 + 핸들러 → `tests/test_app.py` 9 passed → baseline 갱신 -3. REQ-033: alembic revision 20260601_0024 작성 → `alembic upgrade head` 적용(local DB에 `ledger_trades` 테이블 부재로 `has_table` guard에 의해 안전 skip) → repository/도메인/스키마/라우트 정합 → `tests/test_v1_portfolios_ledger.py` 10 passed -4. 통합 회귀 1차: 5 failed 발견 (test_v1.py + test_daemon_assignment.py 잔존 `asset_id` mock) → 갱신 → 통합 회귀 2차: **372 passed, 0 failed** -5. 로컬 smoke: - - GET /health → HTTP 200 + `db_enabled: true` (REQ-035 AC-1) - - POST /auth/login + `no_expiry: true` → HTTP 422 + `extra_forbidden` (REQ-034 AC-2) - - codebase grep `ledger_trades.*asset_id` → 0건 (REQ-033 AC-3) - - `docs/runtime-flows.md` baseline 노트 grep → 매치 (REQ-035 AC-4) - - `docs/interface-contract.md` /health payload grep → 매치 (REQ-035 AC-5) - -## Validation -- pytest 전체: **372 passed, 0 failed** (BAT-015 base 368 → +4: test_auth 2건 + test_app 2건 신규) -- 로컬 smoke 5건 모두 통과 -- AC 매핑은 verification.md에서 정리 -- 실 DB AC (REQ-033 AC-1 `\d ledger_trades`, AC-4 staging dry-run lock 측정): 본 워크스페이스 `ledger_trades` 테이블 미생성 상태로 N/A. REL-002 staging 시점에 별도 수집. - -## Remaining Risks -- R-실 DB ledger_trades 테이블 미생성: REQ-033 마이그레이션은 `has_table` guard로 안전하지만, 본 워크스페이스에서 ledger_trades 적재 흐름이 활용되지 않는 상태. BAT-011 release-candidate verification은 mock 기반이라 이 gap이 release 단계에서 발견되지 않았음. **본 batch 범위 밖**, 별도 후속 항목으로 등록 후보(REL-002 또는 후속 Discovery). -- (workspace_purpose 메모리: 본 워크스페이스 개발용 → prod 영향 검증 N/A) diff --git a/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/index.md b/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/index.md deleted file mode 100644 index be04bcb..0000000 --- a/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/index.md +++ /dev/null @@ -1,34 +0,0 @@ -# BAT-015: REL-001 Feedback 정합화 (ledger 컬럼명 / LoginRequest 엄격화 / health db_enabled) - -- Status: released -- Profile: standard -- Source Discovery: dcy-005 -- Included REQ: REQ-033, REQ-034, REQ-035 -- Excluded REQ: 없음 - -## Summary -- dcy-005(rel-001-feedback-cleanup) 산출물 3건을 단일 batch로 묶어 일괄 정합화한다. 3건 모두 변경 면적이 좁고 같은 release(REL-002 분리 예정) target으로 일관된 정합화 묶음 trigger를 가진다. - -## Scope -- In Scope: - - REQ-033: `ledger_trades.asset_id` → `portfolio_id` 컬럼명 정합화 (Alembic 신규 revision + postgres.py SQL 갱신 + 도메인 모델/라우트 정합) - - REQ-034: `LoginRequest`에 `model_config = ConfigDict(extra="forbid")` 적용 + pytest 422 회귀 - - REQ-035: `HealthResponse`에 `db_enabled: bool` 필드 추가 + `healthcheck()` 핸들러 갱신 + `docs/runtime-flows.md` baseline 명문화 + `docs/interface-contract.md` /health 응답 schema 갱신 -- Out of Scope: - - `ledger_trades` 의미 재정의(asset 단위 vs portfolio 단위 — REQ-033 portfolio_id 의미 확정 가정) - - session token TTL 정책 변경(REQ-021 본문 자체 갱신은 본 batch 범위 밖) - - `/readiness` endpoint 분리(production-grade readiness probe, 별도 후속 Discovery로 처리) - - REL-001 verification 재실행 (별도 REL-002로 분리 — dcy-005 OQ-3 결정) - -## Documents -- Planning: [planning.md](./planning.md) -- Design: [design.md](./design.md) -- Implementation: [implementation.md](./implementation.md) -- Verification: [verification.md](./verification.md) - -## Notes -- 공통 trigger: REL-001 Feedback Handoff 3건 정합화 묶음 -- 공통 release: REL-002 분리 배포 예정(dcy-005 OQ-3 결정, 2026-06-01) -- REQ-033 마이그레이션 staging dry-run을 verification 단계에서 수행해 운영 lock 시간 < 1초(NFR-1) 측정 -- REQ-035는 baseline 문서(`docs/runtime-flows.md`) 갱신 요구를 포함하므로 design 단계에서 architecture impact를 `runtime-flows`로 표기 -- 본 워크스페이스는 개발용이므로 prod 운영 영향 검증은 release 단계에서 별도 수행 (memory: workspace_purpose) diff --git a/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/planning.md b/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/planning.md deleted file mode 100644 index 2b641c7..0000000 --- a/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/planning.md +++ /dev/null @@ -1,50 +0,0 @@ -# BAT-015 Planning - -- Status: confirmed -- Batch: BAT-015 -- Profile: standard - -## Included REQ -- REQ-033 (Migration): ledger_trades.asset_id → portfolio_id 컬럼명 정합화 -- REQ-034 (Interface): LoginRequest 추가 필드 거부 enforcement -- REQ-035 (Interface): GET /health 응답에 db_enabled 필드 추가 - -## Delivery Plan -- 순서: REQ-034 → REQ-035 → REQ-033 - - REQ-034 우선: 1줄 schema 변경 + pytest 422 회귀 (저위험·빠른 evidence) - - REQ-035 후속: HealthResponse 확장 + baseline 문서 갱신 (저위험·문서 변경 포함) - - REQ-033 마지막: Migration 동반 (가장 큰 변경, alembic revision + DB schema, staging dry-run 필요) -- 선행 조건: - - dcy-005 confirmed (2026-06-01) ✓ - - REQ-033/034/035 Approved (2026-06-01) ✓ - - 본 워크스페이스 개발용 메모리 확인 — prod 영향 검증은 release 단계 ✓ -- batch 통합 회귀: 3건 변경 후 전체 pytest 회귀 + 로컬 smoke (`/health`, `/auth/login`, `/v1/portfolios/{id}/ledger/trades`) - -## Dependencies -- Internal: - - dcy-005: Discovery 본문에 OQ-1/OQ-2/OQ-3 결정 명문화 완료 - - REQ-021: 본 batch가 REQ-021 AC-5 follow-up을 해소(REQ-034). REQ-021 본문 변경은 본 batch 범위 밖이며 cross-reference만 추가. - - REQ-029: 본 batch가 REQ-029 Notes follow-up을 해소(REQ-033). REQ-029 본문 변경은 본 batch 범위 밖. - - REL-001: 별도 release로 분리 배포 진행 중. 본 batch는 REL-002로 분리. -- External: - - 없음 (외부 client/integration 의존 없음) - -## Design Gate -- Design Required Before Implementation: yes -- Reason: REQ-033이 DB schema 마이그레이션을 동반하고 REQ-035가 baseline 문서(`docs/runtime-flows.md`) 갱신을 요구한다. Architecture impact가 있으므로 design 단계에서 architecture 결정과 baseline 갱신 계획을 명문화한 뒤 implementation 진입. - -## Milestones -1. Design 단계: REQ-033 마이그레이션 전략 결정(ALTER COLUMN RENAME vs 별도 컬럼 추가+backfill), REQ-035 baseline 문서 갱신 위치 결정 -2. Implementation 단계: REQ-034 → REQ-035 → REQ-033 순서로 구현 -3. Verification 단계: pytest 회귀(전체 369+ passed 기대) + REQ-033 staging dry-run + 로컬 smoke (`/health` 응답, `/auth/login` 422, `/v1/portfolios/{id}/ledger/trades` 회귀) -4. confirm-batch-verification 후 confirm-req-implemented 3건 일괄 전환 - -## Risks -- R-1 (Low): REQ-033 ALTER COLUMN RENAME이 ledger_trades 테이블에 lock을 유발해 운영 영향 발생. - - 대응: staging dry-run으로 lock 시간 측정(< 1초 권장), 짧은 단일 트랜잭션 패턴 적용. 본 워크스페이스는 개발용이라 prod 영향 검증은 release 단계. -- R-2 (Low): REQ-034 `extra="forbid"`가 기존 ignored field를 보내던 외부 client를 silently 깨뜨림. - - 대응: 본 워크스페이스 개발용이라 외부 monitoring 영향 없음. 회귀 테스트에서 기존 username/password만 보내는 케이스 통과 확인. -- R-3 (Low): REQ-035 `HealthResponse` 확장이 strict JSON schema validation 사용 monitoring 도구를 깨뜨림. - - 대응: 본 워크스페이스 개발용이라 영향 없음. prod 배포 시 사전 점검(REL-002 verification). -- R-4 (Low): 3건 변경이 통합 회귀에서 예상치 못한 상호 영향 발생. - - 대응: 각 REQ 구현 후 pytest 회귀 incremental 실행, batch 마지막에 전체 회귀. diff --git a/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/verification.md b/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/verification.md deleted file mode 100644 index d742f43..0000000 --- a/docs/batches/bat-015_20260601_rel-001-feedback-cleanup/verification.md +++ /dev/null @@ -1,60 +0,0 @@ -# BAT-015 Verification - -- Status: confirmed -- Batch: BAT-015 - -## Acceptance Mapping - -### REQ-033: ledger_trades.asset_id → portfolio_id 컬럼명 정합화 -- AC-1 (`\d ledger_trades`에 `portfolio_id` 존재, `asset_id` 부재): **Deferred to REL-002 staging** — 로컬 DB에 `ledger_trades` 테이블이 미생성 상태(BAT-011 release-candidate는 mock 기반 verification으로 진행됨). 마이그레이션 `20260601_0024`는 `has_table` 가드로 안전 skip되며, 코드 작성은 완료([migrations/versions/20260601_0024_ledger_trades_portfolio_id_rename.py:36-40](../../../migrations/versions/20260601_0024_ledger_trades_portfolio_id_rename.py#L36-L40)). 실 DB 검증은 REL-002 staging에서 수행. -- AC-2 (INSERT/SELECT 회귀): `tests/test_v1_portfolios_ledger.py` 10 PASSED (mock 기반). `insert_ledger_trade(portfolio_id=...)` 시그니처 + 응답 schema `LedgerTradeResponse.portfolio_id` 일관 검증. -- AC-3 (codebase grep `ledger_trades\..*asset_id` 0건): PASSED. `grep -rn "ledger_trades\..*asset_id\|asset_id.*ledger_trades\|insert_ledger_trade.*asset_id\|trade\.asset_id" app/` → 0건 (2026-06-05 implementation smoke). -- AC-4 (마이그레이션 lock < 1초, NFR-1): **Deferred to REL-002 staging** — workspace_purpose 메모리 정합(개발용 워크스페이스). staging dry-run으로 측정. -- AC-5 (전체 pytest 회귀 통과): PASSED. **372 passed, 0 failed** (5월 21일 base 368 → +4 누적: test_auth +2, test_app +2). - -### REQ-034: LoginRequest 추가 필드 거부 enforcement -- AC-1 (정상 username/password → HTTP 200): `tests/test_auth.py` 기존 회귀 16 PASSED (정상 login flow는 기존 케이스로 회귀 보호). -- AC-2 (`no_expiry: true` → HTTP 422 + `extra_forbidden`): `tests/test_auth.py::test_login_rejects_no_expiry_extra_field` PASSED. 로컬 smoke 응답 `{"detail":[{"type":"extra_forbidden","loc":["body","no_expiry"],"msg":"Extra inputs are not permitted","input":true}]}` 확인 (2026-06-05). -- AC-3 (임의 추가 필드 → HTTP 422): `tests/test_auth.py::test_login_rejects_arbitrary_extra_field` PASSED. -- AC-4 (grep `extra.*forbid` app/schemas/auth.py 매치): PASSED. [app/schemas/auth.py:14](../../../app/schemas/auth.py#L14) `model_config = ConfigDict(extra="forbid")` 매치. -- AC-5 (전체 pytest 회귀 통과): PASSED. 372 passed, 0 failed. - -### REQ-035: GET /health 응답에 db_enabled 필드 추가 -- AC-1 (PORTFOLIOHUB_DB_ENABLED=true → `db_enabled: true`): `tests/test_app.py::test_health_exposes_db_enabled_true` PASSED. 로컬 smoke 응답 `{"status":"ok","service":"portfolio-hub","version":"0.1.0","checked_at":"2026-06-05T13:44:04.724864+09:00","db_enabled":true}` 확인 (2026-06-05). -- AC-2 (PORTFOLIOHUB_DB_ENABLED=false → `db_enabled: false`): `tests/test_app.py::test_health_exposes_db_enabled_false` PASSED. -- AC-3 (5필드 노출 + 기존 4필드 회귀): `tests/test_app.py::test_healthcheck` 갱신 PASSED (`db_enabled` 추가 검증 포함). 기존 `status/service/version/checked_at` 동작 회귀 유지. -- AC-4 (baseline grep `db_enabled.*설정값|setting value|ping|readiness` docs/runtime-flows.md): PASSED. [docs/runtime-flows.md:97](../../../docs/runtime-flows.md#L97)에 의미 명문화 노트 1단락 추가. -- AC-5 (interface-contract grep `/health.*db_enabled` docs/interface-contract.md): PASSED. [docs/interface-contract.md:48](../../../docs/interface-contract.md#L48) `GET /health payload` schema에 `db_enabled` 필드 추가. - -## Evidence - -- Test: - - 전체 pytest: **372 passed, 0 failed, 3 warnings** (2026-06-05, deprecation warnings는 starlette HTTP_422_UNPROCESSABLE_ENTITY alias, 기능 무관) - - 신규 회귀 4건: `test_login_rejects_no_expiry_extra_field`, `test_login_rejects_arbitrary_extra_field`, `test_health_exposes_db_enabled_true`, `test_health_exposes_db_enabled_false` - - 영향 받은 기존 회귀 갱신: `test_v1_portfolios_ledger.py` (portfolio_id 응답 필드), `test_v1.py::test_v1_ledger_trade_*` (`_make_ledger_trade` + body 키), `test_daemon_assignment.py::test_ledger_trade_201_when_daemon_matches` (mock attribute), `test_app.py::test_healthcheck` (`db_enabled` 필드 확인 추가) -- Manual: - - 2026-06-05 13:44 KST 로컬 smoke 5건 (implementation.md Execution Log step 5 참조): - - GET /health → 200 + `db_enabled: true` - - POST /auth/login + `no_expiry: true` → 422 + extra_forbidden - - codebase grep ledger_trades.asset_id → 0건 - - docs/runtime-flows.md db_enabled 의미 노트 grep → 매치 - - docs/interface-contract.md /health payload `db_enabled` grep → 매치 -- Logs: - - alembic upgrade head (2026-06-05): `Running upgrade 20260521_0023 -> 20260601_0024, Rename ledger_trades.asset_id to portfolio_id for column-name semantic alignment (REQ-033)` — `has_table` guard로 안전 skip 확인 - -## Architecture Impact Synchronization (Baseline) - -- design.md Architecture Impact: `runtime-flows` -- Reference Doc Update Plan 이행: - - `docs/project-structure.md`: 갱신 불요 (영향 없음, 기존 모듈 한정 변경) — 변경 없음 확인 ✓ - - `docs/runtime-flows.md`: `db_enabled` 의미 명문화 노트 [line 97](../../../docs/runtime-flows.md#L97) 추가 ✓ -- 추가 baseline 갱신: `docs/data-model.md` LedgerTrade entity 컬럼명/제약 갱신, `docs/interface-contract.md` /health payload + LedgerTradeResponse schema 갱신 - -## Result -- Ready for Release: true -- Blocking Issues: - - 없음 (Deferred 2건은 blocker 아님, REL-002 staging에 이연 evidence) -- Deferred Evidence (release execution 시 수집): - - REQ-033 AC-1 (`\d ledger_trades` 실 DB 컬럼 검증) — REL-002 staging - - REQ-033 AC-4 (마이그레이션 lock < 1초 staging dry-run) — REL-002 staging - - 본 워크스페이스 ledger_trades 테이블 미생성 상태 자체는 본 batch 범위 밖 (BAT-011 mock 기반 verification의 후속 항목 후보) diff --git a/docs/batches/bat-016_20260605_ledger-accounting-consolidation/design.md b/docs/batches/bat-016_20260605_ledger-accounting-consolidation/design.md deleted file mode 100644 index f7f372c..0000000 --- a/docs/batches/bat-016_20260605_ledger-accounting-consolidation/design.md +++ /dev/null @@ -1,37 +0,0 @@ -# BAT-016 Design - -- Status: confirmed -- Batch: BAT-016 - -## Architecture Summary -- 신 path(`POST /v1/portfolios/{id}/ledger/trades`)를 portfolio 회계의 단일 권위 endpoint로 재정의하고, 두 ledger 테이블을 portfolio_ledger 단일 회계 원장으로 통합한다. ledger write가 portfolio 잔고를 단일 트랜잭션으로 갱신하고 응답에 거래 후 권위 상태를 포함한다. 마이그레이션(DB) + 도메인/repository 통합(app) + endpoint 동작 변경 + 레거시 deprecated 표기의 3 REQ 묶음. - -## Changed Areas -- Docs: `docs/data-model.md`(portfolio_ledger 통합 스키마, ledger_trades entity 제거), `docs/interface-contract.md`(신 path 회계 동작+응답, 레거시 deprecated), `docs/runtime-flows.md`(ledger write flow 회계 단계) -- Modules: `migrations/versions/`(신규 revision), `app/repositories/postgres.py`(ensure_schema + insert 통합 + FOR UPDATE + ON CONFLICT partial), `app/repositories/portfolio_repository.py`, `app/domain/models.py`(LedgerEntry 통합), `app/domain/services.py`(음수 허용 회계 헬퍼), `app/api/v1_portfolios.py`(handler), `app/schemas/v1.py`(응답), `app/api/portfolios.py`(레거시 deprecated) -- Interfaces: `POST /v1/portfolios/{id}/ledger/trades`(응답 확장 + 회계), `POST /portfolios/{id}/buy`·`/sell`(deprecated 표기) - -## Key Decisions -> **갱신 (2026-06-05, verify 후 재구현)**: `portfolio_ledger`가 TimescaleDB hypertable임이 확인되어 partial UNIQUE index/PK 기반 idempotency가 불가능 → application-level idempotency로 재정의. 아래 결정 1/2/5 + Edge Cases가 이를 반영하도록 수정됨. - -1. **마이그레이션 전략 (REQ-036)**: 단일 alembic revision(down_revision=`20260601_0024`)에서 컬럼 추가 → backfill(action→side, recorded_at→executed_at) → 타입 변경(Float→NUMERIC(20,8)) → action 컬럼 제거 → 보조 인덱스 신설 → ledger_trades DROP 순. **partial UNIQUE index는 생성하지 않는다(hypertable 제약).** `ledger_entry_id`는 Identity 컬럼(비-PK). -2. **order_id/execution_id NULL 허용 + application-level idempotency (OQ-7 (ii) 재정의)**: `portfolio_ledger`는 TimescaleDB hypertable(파티션 컬럼 `recorded_at`)이라 `(portfolio_id, order_id, execution_id)` partial unique index/PK를 만들 수 없다(`cannot create a unique index without the column recorded_at`). 따라서 OQ-7 (ii)의 "DB partial UNIQUE + ON CONFLICT"는 불가 → **`SELECT ... FOR UPDATE`(portfolios row 락) 안에서 SELECT-before-insert**로 idempotency 구현. broker write(order_id 존재)만 기존 row 조회 후 dedup, 레거시/수동 write(order_id NULL)는 append-only. portfolios row lock이 동시 write를 직렬화하여 race-safe. REQ-036/037 CHG-20260605-03 정합. -3. **도메인 모델 통합 (REQ-037 FR-13)**: `LedgerEntry`를 단일 모델로 유지하고 broker 필드(ledger_entry_id, order_id, execution_id, side, executed_at)를 흡수. `LedgerTrade` dataclass 삭제. 기존 `LedgerEntry`(잔고 snapshot 필드)와 `LedgerTrade`(broker 필드)의 합집합. -4. **회계 헬퍼 분리 (REQ-037 FR-6, R-8)**: 음수 검증을 제거한 ledger 전용 회계 함수를 추출. 기존 `apply_buy`/`apply_sell`(음수 거부)은 레거시 buy/sell 전용으로 보존. 공통 회계 공식(부호/fee/tax)은 가능한 한 공유 함수로 추출해 divergence 방지. -5. **단일 트랜잭션 + row lock (REQ-037 FR-6/FR-7)**: `SELECT ... FOR UPDATE`로 portfolios row 잠금 → (broker write면) 기존 ledger row SELECT-before-insert → 없으면 회계 계산 + portfolio_ledger INSERT + portfolios UPDATE → COMMIT. 트랜잭션은 `conn.transaction()` 사용(psycopg3 `with conn:`는 connection을 close하므로 공유 connection 유지를 위해 transaction 블록 사용). duplicate(기존 row 존재)면 INSERT/UPDATE 없이 portfolios 현재 권위 상태로 응답(OQ-4 (b)). -6. **레거시 deprecated (REQ-038)**: FastAPI route `deprecated=True` + 응답 헤더 `Deprecation: true`. 동작 무변경. 레거시 write는 portfolio_ledger에 order_id/execution_id NULL append. - -## Edge Cases -- duplicate 호출(SELECT-before-insert로 기존 row 발견): INSERT/UPDATE 없음 → status="duplicate" + portfolios 현재 권위 상태 응답. portfolio_ledger row 재생성 없음. -- 음수 잔고: buy로 cash 음수 또는 sell로 quantity 음수 → 거부하지 않고 그대로 기록/응답(OQ-3). -- 레거시 write의 NULL order_id: idempotency 조회 대상 외 → append-only(같은 buy 두 번 = row 2건). -- action 자유형식 값(예: 'manual'): 마이그레이션 backfill 시 side 매핑 규칙 외 → 개발 워크스페이스 분포 단순(확인 후 처리). prod는 release 이연. -- 도메인 모델 통합 시 광범위 참조(R-1): test_v1_portfolios_ledger / test_v1 / test_daemon_assignment 등 LedgerTrade/LedgerEntry 참조 전수 갱신. - -## Architecture Impact -- both (data-model + runtime-flows) - -## Reference Doc Update Plan -- project-structure.md: none (신규 모듈/디렉토리 없음, 기존 모듈 변경에 한정) -- runtime-flows.md: ledger write flow 섹션에 회계 적용(단일 트랜잭션 + FOR UPDATE + portfolio_ledger insert + portfolios UPDATE) + portfolio_ledger 단일 원장 + 레거시 deprecated 명문화 -- data-model.md: portfolio_ledger entity에 신규 컬럼/제약/타입 추가 + ledger_trades entity 섹션 제거 (REQ-036 Impacted Area) diff --git a/docs/batches/bat-016_20260605_ledger-accounting-consolidation/implementation.md b/docs/batches/bat-016_20260605_ledger-accounting-consolidation/implementation.md deleted file mode 100644 index 6bb54f7..0000000 --- a/docs/batches/bat-016_20260605_ledger-accounting-consolidation/implementation.md +++ /dev/null @@ -1,63 +0,0 @@ -# BAT-016 Implementation - -- Status: implemented -- Batch: BAT-016 - -## Plan Summary -- 순서: REQ-036(마이그레이션 + ensure_schema) → REQ-037(repository/도메인/응답/회계) → REQ-038(deprecated) -- 각 REQ 후 부분 회귀, batch 마지막 통합 회귀를 수행했다. - -## Changed Files -- `migrations/versions/20260605_0025_ledger_consolidation.py` -- `app/repositories/postgres.py` -- `app/repositories/portfolio_repository.py` -- `app/domain/models.py` -- `app/domain/services.py` call-site compatible model usage retained through `LedgerEntry.action` compatibility -- `app/schemas/v1.py` -- `app/api/v1_portfolios.py` -- `app/api/portfolios.py` -- `tests/test_v1_portfolios_ledger.py` -- `tests/test_v1.py` -- `tests/test_daemon_assignment.py` -- `tests/test_auth.py` - -## Execution Log -1. `portfolio_ledger` schema was consolidated to carry trade-write identifiers/state directly: `ledger_entry_id`, `order_id`, `execution_id`, `side`, `executed_at`, `cash_balance_after`, and `asset_quantity_after`. -2. Legacy `ledger_trades` references were removed from application code; `ensure_schema()` now migrates old `action` data into `side`, drops `action`, and drops `ledger_trades` when present. -3. `LedgerEntry` became the canonical domain object for recent trades and authoritative ledger-trade writes. -4. `POST /v1/portfolios/{portfolio_id}/ledger/trades` now writes to `portfolio_ledger`, supports idempotent lookup by broker identifiers, computes cash/security balances, and returns accounting state. -5. Legacy `/portfolios/{portfolio_id}/buy` and `/portfolios/{portfolio_id}/sell` routes were marked `deprecated=True` and emit `Deprecation: true` on legacy responses/errors. -6. Regression tests were updated to assert the consolidated model and deprecation header behavior. - -## Validation -- Targeted regression: - - `.venv/bin/python -m pytest -q tests/test_domain_services.py tests/test_v1_portfolios_ledger.py tests/test_daemon_assignment.py tests/test_v1.py` - - Result: `47 passed in 3.66s` -- Full suite: - - `.venv/bin/python -m pytest -q` - - Result: `373 passed, 3 warnings in 14.78s` - -## Rework (2026-06-05, post-verification defect fix) -초기 구현이 mock-only 검증을 통과했으나 실 DB에서 신 path가 깨지는 결함 2건이 verify 단계에서 발견되어 재구현했다. - -### 결함 1 — hypertable에서 불가능한 `ON CONFLICT` (치명) -- `insert_ledger_trade`가 `ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL DO NOTHING`를 사용했으나, `portfolio_ledger`는 TimescaleDB hypertable(파티션 컬럼 `recorded_at`)이라 해당 partial unique index를 만들 수 없다(`cannot create a unique index without the column "recorded_at"`). 인덱스가 없으니 ON CONFLICT는 실 DB에서 `there is no unique or exclusion constraint matching the ON CONFLICT specification` 에러. 모든 ledger 테스트가 MagicMock repo라 실 SQL이 실행되지 않아 미검출. -- 수정: ON CONFLICT 제거. 기존 `SELECT ... FOR UPDATE` 락 안에서 **application-level idempotency(SELECT-before-insert)**로 교체. broker write(order_id 존재)만 `(portfolio_id, order_id, execution_id)`로 기존 row 조회 후 있으면 duplicate 처리, 없으면 INSERT. portfolios row lock이 동시 write를 직렬화하므로 race-safe. duplicate는 OQ-4(b)에 따라 현재 권위 portfolio 상태를 반환. -- 설계 정합: dcy-006 OQ-7 (ii) "partial UNIQUE index"는 hypertable에서 불가 → REQ-036/REQ-037 change-req(CHG-20260605-03)로 application-level idempotency로 재정의. - -### 결함 2 — 공유 connection close (잠재) -- `with self._connect() as conn:`는 psycopg3에서 블록 종료 시 connection을 **close**한다. repo는 connection을 재사용(self._connection)하므로 insert_ledger_trade 후 repo가 unusable(다음 호출 `the connection is closed`). 앱은 request당 repo를 새로 만들어 가렸지만 잠재 결함. -- 수정: `with conn.transaction():`(commit하되 connection 유지)로 교체. repo 다른 메서드들과 connection 수명 일관. - -### 실 DB 검증 추가 -- mock-only 검증의 맹점을 닫기 위해 `tests/test_ledger_consolidation_integration.py` 신규(실 Postgres 의존, DB 미가용 시 skip). new(회계+portfolio 갱신) / duplicate(재insert 없음 + 현 권위 상태) / 음수 허용을 실 DB에서 검증. - -## Validation (rework) -- mock 회귀: `tests/test_v1_portfolios_ledger.py tests/test_daemon_assignment.py tests/test_domain_services.py` → 24 passed -- 실 DB 통합: `tests/test_ledger_consolidation_integration.py` → 3 passed (실 Postgres) -- 전체: `.venv/bin/python -m pytest -q` → **376 passed** (기존 373 + 통합 3) -- 실 DB 직접 검증: 수정 전 `ON CONFLICT ... WHERE order_id IS NOT NULL` → `there is no unique or exclusion constraint matching...` 에러 재현 확인. 수정 후 통합 테스트로 정상 동작 확인. - -## Remaining Risks -- alembic 마이그레이션(`20260605_0025`)은 원본 portfolio_ledger 스키마(action/Float 컬럼) 기준으로 작성되어 fresh DB(alembic 경로)에서 1회 정상 적용된다. 단 ensure_schema가 이미 실행된 개발 DB에서는 컬럼 중복으로 충돌 — ensure_schema vs alembic 이중 경로의 기존 패턴. prod 적용은 release staging에서 별도 검증(workspace_purpose 메모리). -- `ledger_entry_id`는 hypertable 제약상 단독 PRIMARY KEY 불가 → Identity 컬럼(비-PK)으로 유지. REQ-036 AC-1 change-req로 정합화. diff --git a/docs/batches/bat-016_20260605_ledger-accounting-consolidation/index.md b/docs/batches/bat-016_20260605_ledger-accounting-consolidation/index.md deleted file mode 100644 index bb28133..0000000 --- a/docs/batches/bat-016_20260605_ledger-accounting-consolidation/index.md +++ /dev/null @@ -1,34 +0,0 @@ -# BAT-016: ledger 회계 책임 단일화 + portfolio_ledger 통합 - -- Status: released -- Profile: standard -- Source Discovery: dcy-006 -- Included REQ: REQ-036, REQ-037, REQ-038 -- Excluded REQ: 없음 - -## Summary -- dcy-006(ledger trade write 회계 책임 단일화) 산출물 3건을 단일 batch로 묶는다. 신 path(`POST /v1/portfolios/{id}/ledger/trades`)를 portfolio 회계의 단일 권위 endpoint로 재정의하고, 두 ledger 테이블(portfolio_ledger/ledger_trades)을 portfolio_ledger 단일 회계 원장으로 통합하며, 응답에 거래 후 권위 상태를 포함해 trex-core daemon의 drift를 차단한다(issue #8). - -## Scope -- In Scope: - - REQ-036 (Migration): portfolio_ledger에 broker 컬럼(order_id/execution_id NULL, side, executed_at, ledger_entry_id PK) + partial UNIQUE index + NUMERIC(20,8) 타입 변경 + action→side + ledger_trades DROP + data-model.md - - REQ-037 (Interface): 신 path 단일 트랜잭션 회계(음수 허용) + row lock + cash_balance_after/asset_quantity_after 응답 + duplicate 현 상태 + 도메인 모델/repository 통합(LedgerEntry 단일화, LedgerTrade 삭제) + interface-contract.md/runtime-flows.md - - REQ-038 (Interface): 레거시 buy/sell deprecated 표기(`deprecated=True` + `Deprecation: true` 헤더), 동작 보존 -- Out of Scope: - - 레거시 buy/sell endpoint 실제 제거 (후속 정리 batch/Discovery — dcy-006 Out of Scope) - - prod 환경 ledger_trades 데이터 backfill (release 단계 별도 검토) - - 응답 추가 필드(total_value_after, profit_buffer_after 등) — 본 issue는 cash/quantity만 - - REL-001/REL-002 일정과 혼재 (별도 REL-003 등으로 분리 — dcy-006 A-4) - -## Documents -- Planning: [planning.md](./planning.md) -- Design: [design.md](./design.md) -- Implementation: [implementation.md](./implementation.md) -- Verification: [verification.md](./verification.md) - -## Notes -- 공통 trigger: issue #8 trex-core drift 방지 + 회계 원장 단일화 -- 공통 release: 별도 REL(REL-003 등) 분리 배포 (dcy-006 A-4 동의) -- **REQ-036(마이그레이션) 선행** — REQ-037/038은 portfolio_ledger 신 스키마 위에서 동작. 구현 순서 REQ-036 → REQ-037 → REQ-038. -- 본 워크스페이스는 개발용 — prod race condition / 마이그레이션 정밀도(DOUBLE→NUMERIC) / action 분포 검증은 release 단계 위임(workspace_purpose 메모리, dcy-006 R-5/R-6/R-7). -- 대규모 변경(마이그레이션 + 도메인 모델 통합 + 응답 확장 + 회계 동작 변경)이라 standard profile + 통합 회귀 필수. diff --git a/docs/batches/bat-016_20260605_ledger-accounting-consolidation/planning.md b/docs/batches/bat-016_20260605_ledger-accounting-consolidation/planning.md deleted file mode 100644 index d034cf0..0000000 --- a/docs/batches/bat-016_20260605_ledger-accounting-consolidation/planning.md +++ /dev/null @@ -1,49 +0,0 @@ -# BAT-016 Planning - -- Status: confirmed -- Batch: BAT-016 -- Profile: standard - -## Included REQ -- REQ-036 (Migration): portfolio_ledger 회계 원장 통합 마이그레이션 -- REQ-037 (Interface): 신 path 회계 책임 단일화 + 응답에 portfolio 상태 포함 -- REQ-038 (Interface): 레거시 buy/sell endpoint deprecated 표기 - -## Delivery Plan -- 순서: **REQ-036 → REQ-037 → REQ-038** (마이그레이션 선행 필수) - - REQ-036: portfolio_ledger 신 스키마 마이그레이션 + ensure_schema 갱신 + ledger_trades DROP. 이 스키마가 REQ-037/038의 기반. - - REQ-037: insert_ledger_trade 재정의(portfolio_ledger insert + portfolios UPDATE 단일 트랜잭션 + FOR UPDATE + ON CONFLICT partial), 도메인 모델 통합(LedgerEntry 단일화), 응답 확장, 회계 헬퍼(음수 허용). - - REQ-038: 레거시 buy/sell `deprecated=True` + 헤더, 신 스키마에서 레거시 append 동작 보존 확인. -- 선행 조건: - - dcy-006 confirmed (2026-06-05) ✓ - - REQ-036/037/038 Approved (2026-06-05) ✓ -- batch 통합 회귀: 3건 구현 후 전체 pytest + 로컬 smoke (신 path 201 + cash/quantity_after, duplicate 200, 음수 허용, 레거시 buy deprecated 헤더) - -## Dependencies -- Internal: - - REQ-036 → REQ-037/038 선행 (portfolio_ledger 신 스키마) - - REQ-029/REQ-033: 기존 ledger path/컬럼명 정합 — 본 batch가 그 위에 회계 책임 + 테이블 통합을 쌓음 - - dcy-006: OQ-1~7 결정 본문 반영 완료 - - BAT-015(release-candidate): 분리된 별도 batch — 본 batch와 독립 -- External: - - trex-core daemon (slot-core, 별도 repo) — 본 응답 schema로 스냅샷 갱신 구현. issue #8 comment(2026-06-05)로 응답 명세 사전 공유 완료. - -## Design Gate -- Design Required Before Implementation: yes -- Reason: DB schema 마이그레이션(REQ-036, 컬럼 추가 + partial UNIQUE + 타입 변경 + 테이블 DROP) + 도메인 모델 통합(REQ-037, LedgerEntry/LedgerTrade 통합) + 회계 트랜잭션 설계(단일 트랜잭션 + FOR UPDATE + 음수 헬퍼 분리)가 architecture/data 영향을 가진다. design 단계에서 마이그레이션 전략, 도메인 모델 통합 형태, 회계 헬퍼 추출 방식, baseline 갱신 계획을 명문화한 뒤 implementation 진입. - -## Milestones -1. Design: 마이그레이션 전략(action 분포/backfill/partial UNIQUE) + 도메인 모델 통합 형태 + 회계 헬퍼 분리 + runtime-flows/data-model 갱신 계획 확정 -2. Implementation: REQ-036(마이그레이션 + ensure_schema) → REQ-037(repository/도메인/응답/회계) → REQ-038(deprecated) -3. Verification: 전체 pytest 회귀 + 마이그레이션 schema 검증(AC-1~7) + 로컬 smoke(신 path 회계/응답/duplicate/음수 + 레거시 deprecated) + 동시성 회귀 -4. confirm-batch-verification → confirm-req-implemented 3건 일괄 - -## Risks -- R-1 (Medium): 도메인 모델 통합(LedgerEntry/LedgerTrade)이 광범위한 import/usage를 건드려 회귀 실패 다발. (BAT-015 REQ-033에서 LedgerTrade 필드 rename 시 test_v1/test_daemon_assignment 잔존 참조로 5건 실패한 선례) - - 대응: grep 기반 전수 참조 확인 후 일괄 갱신. 구현 후 전체 회귀 incremental. -- R-2 (Medium): 단일 트랜잭션(portfolio_ledger insert + portfolios UPDATE + FOR UPDATE) 도입이 기존 테스트 mock 구조와 충돌. - - 대응: repository 통합 함수 단위로 mock 갱신. 동시성은 별도 회귀. -- R-3 (Low): partial UNIQUE + ON CONFLICT inference SQL이 기존 idempotency 테스트와 다른 동작. - - 대응: REQ-037 AC-2(duplicate) + AC-3(음수) 회귀로 검증. -- R-4 (Low): 본 워크스페이스 ledger_trades 미생성/portfolio_ledger 데이터 단순 — 마이그레이션 backfill 검증이 prod와 다를 수 있음. - - 대응: AC-6(backfill) 회귀는 개발 DB 기준. prod 정밀도/action 분포는 release staging 이연(dcy-006 R-5/R-6). diff --git a/docs/batches/bat-016_20260605_ledger-accounting-consolidation/verification.md b/docs/batches/bat-016_20260605_ledger-accounting-consolidation/verification.md deleted file mode 100644 index 6e7524e..0000000 --- a/docs/batches/bat-016_20260605_ledger-accounting-consolidation/verification.md +++ /dev/null @@ -1,43 +0,0 @@ -# BAT-016 Verification - -- Status: confirmed -- Batch: BAT-016 - -> **재검증 (2026-06-05)**: 초기 검증은 mock-only 테스트만 근거로 "passed" 표기했으나, verify 단계에서 신 path가 실 DB에서 깨지는 결함 2건이 발견됨(ON CONFLICT on hypertable 불가, 공유 connection close). 재구현 + 실 DB 통합 테스트 후 아래 재검증. - -## Acceptance Mapping -- REQ-036: - - AC-1: `\d portfolio_ledger`에 `ledger_entry_id`(Identity, 비-PK — CHG-20260605-03), `order_id`/`execution_id`(NULL), `side`/`executed_at`(NOT NULL) 존재. 마이그레이션 `20260605_0025` + `ensure_schema()` 일치. - - AC-2: 수치 8컬럼 NUMERIC(20,8) — 실 DB `\d portfolio_ledger`로 확인. - - AC-3 (CHG-20260605-03 재정의): partial unique index/PK는 **존재하지 않음**(hypertable 제약). idempotency는 application layer + `tests/test_ledger_consolidation_integration.py::test_duplicate_broker_write_is_idempotent`(실 DB)로 검증 — duplicate 시 row 1건 유지 확인. - - AC-4: `action` 컬럼 제거 + `side` 대체 — 확인. - - AC-5: `ledger_trades` 테이블 부재 — 실 DB 확인. - - AC-6~7: ensure_schema/마이그레이션 backfill 정합 + 전체 회귀 통과. -- REQ-037: - - AC-1: `test_new_trade_updates_portfolio_and_returns_state`(실 DB) — buy 10@80,000 fee 100 → cash_balance_after=199,900, asset_quantity_after=10, portfolios 동일 갱신. - - AC-2 (OQ-4 b): `test_duplicate_broker_write_is_idempotent`(실 DB) — 재호출 시 row 재생성 없이 현재 권위 상태 반환. - - AC-3 (OQ-3 음수 허용): `test_negative_balance_allowed`(실 DB) — oversell → asset_quantity_after=-999, 거부 없음. - - AC-4: 응답에 기존 필드 + cash_balance_after/asset_quantity_after 추가 — schema + 실 DB 응답 확인. - - AC-5: daemon 3단계 인가 보존 — `tests/test_daemon_assignment.py` 회귀. - - AC-6: `LedgerTrade` 참조 0건 — grep 확인, `LedgerEntry` 단일. - - AC-7: row lock(`SELECT ... FOR UPDATE`) + `conn.transaction()`로 원자성/직렬화. -- REQ-038: - - AC-1~2: 레거시 buy/sell `deprecated=True` + `Deprecation: true` 헤더 — `app/api/portfolios.py` 확인. - - AC-3~4: `tests/test_auth.py::test_legacy_buy_sell_routes_emit_deprecation_header` 통과. - -## Evidence -- Test: - - 실 DB 통합: `.venv/bin/python -m pytest -q tests/test_ledger_consolidation_integration.py` → `3 passed` (실 Postgres — new/duplicate/음수) - - mock 회귀: `tests/test_v1_portfolios_ledger.py tests/test_daemon_assignment.py tests/test_domain_services.py` → 24 passed - - 전체: `.venv/bin/python -m pytest -q` → **376 passed, 3 warnings** (기존 373 + 통합 3) -- Manual / 실 DB 직접 검증: - - 수정 전: `ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL` → 실 DB에서 `there is no unique or exclusion constraint matching the ON CONFLICT specification` 에러 재현. - - hypertable 제약 확인: `CREATE UNIQUE INDEX ... WHERE order_id IS NOT NULL` → `cannot create a unique index without the column "recorded_at"`. - - 수정 후: 통합 테스트로 new/duplicate/음수 정상 동작. -- Logs: - - pytest 출력 + docker psql 검증 출력 (터미널). - -## Result -- Ready for Release: true (실 DB 통합 검증 기준) -- Blocking Issues: none -- Deferred (prod staging): alembic `20260605_0025`의 fresh-DB 적용 + DOUBLE→NUMERIC 정밀도/action 분포는 release staging에서 검증(workspace_purpose 메모리). ensure_schema가 이미 적용된 개발 DB에서는 alembic 경로 충돌 가능 — 기존 ensure_schema vs alembic 이중 경로 패턴. diff --git a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/design.md b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/design.md deleted file mode 100644 index ec4dbd4..0000000 --- a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/design.md +++ /dev/null @@ -1,133 +0,0 @@ -# BAT-017 Design - -## Design Gate 결정 - -- Design Gate: 통과 (simple, 기존 패턴 재사용, M1 fast-track) -- design.md는 계층 경계와 인터페이스 계약을 명시한다. - -## 계층 경계 - -``` -API 계층 (app/api/v1_portfolios.py) - └─ require_read 인증 + _get_portfolio_or_404 소유권 확인 - └─ 날짜 파라미터 파싱/검증 (Query) - └─ Repository 위임 (list_trades_by_date / get_all_trades_for_pnl) - └─ Domain 위임 (calculate_realized_pnl) - -Domain 계층 (app/domain/pnl.py) - └─ calculate_realized_pnl(rows: list[dict]) → WacPnlResult - └─ 순수 Python, psycopg/DB 무의존 - └─ WAC 누적 계산 + data_completeness_warning 판단 - -Repository 계층 (app/repositories/postgres.py) - └─ list_trades_by_date(portfolio_id, date, from_, to_) → list[LedgerEntry] - └─ get_all_trades_for_pnl(portfolio_id, from_, to_, symbol) → list[LedgerEntry] - - from_/to_는 매도 집계 기간 - - buy 이력은 집계 기간 이전 전체 조회(WAC 정확성) - -Schema 계층 (app/schemas/v1.py) - └─ TradeHistoryItem, TradeHistoryResponse - └─ RealizedPnlItem, RealizedPnlResponse - - data_completeness_warning: str | None - - cost_basis_from: date | None -``` - -## WAC 계산 함수 인터페이스 - -```python -# app/domain/pnl.py - -@dataclass -class PnlItem: - symbol: str | None - executed_at: datetime - quantity: float - sell_price: float - cost_basis_per_unit: float - fee_amount: float - tax_amount: float - realized_pnl: float - -@dataclass -class WacPnlResult: - items: list[PnlItem] - total_realized_pnl: float - cost_basis_from: datetime | None - data_completeness_warning: str | None - -def calculate_realized_pnl(rows: list[dict]) -> WacPnlResult: - """ - 입력: portfolio_ledger 행 목록 (시간 순 정렬) - 각 dict: side, quantity, price, fee_amount, tax_amount, executed_at, order_id, execution_id - 출력: WacPnlResult - """ -``` - -## HTTP API 계약 - -### GET /v1/portfolios/{portfolio_id}/trades - -- 인증: `require_read` (read_token 이상) -- 쿼리 파라미터: - - `date` (str, optional): YYYY-MM-DD — 단일 날짜 (from=to로 처리) - - `from` (str, optional): YYYY-MM-DD — 범위 시작 (경계 포함) - - `to` (str, optional): YYYY-MM-DD — 범위 종료 (경계 포함) - - `date`와 `from`/`to` 중 하나라도 없으면 422 -- 응답: `TradeHistoryResponse` - -### GET /v1/portfolios/{portfolio_id}/pnl/realized - -- 인증: `require_read` (read_token 이상) -- 쿼리 파라미터: - - `from` (str, required): YYYY-MM-DD - - `to` (str, required): YYYY-MM-DD - - `symbol` (str, optional): 종목 필터 -- 응답: `RealizedPnlResponse` - -## Repository 쿼리 설계 - -### list_trades_by_date -```sql -SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, - quantity::float8, price::float8, fee_amount::float8, tax_amount::float8, - executed_at, recorded_at, cash_balance_after::float8, asset_quantity_after::float8, - total_value_after::float8, profit_buffer_after::float8, strategy_set_id, note -FROM portfolio_ledger -WHERE portfolio_id = %s - AND executed_at >= %s -- from_ (date boundary: 00:00:00 UTC) - AND executed_at < %s -- to_ + 1 day (exclusive upper bound) -ORDER BY executed_at ASC, ledger_entry_id ASC -``` - -### get_all_trades_for_pnl -```sql --- WAC 정확성을 위해 집계 기간 이전 buy 이력도 포함 --- symbol JOIN은 asset 테이블을 통해 수행하지 않고, --- portfolio_ledger에 symbol 컬럼이 없으므로 portfolio의 symbol을 통해 필터 --- (portfolio 1개 = 1 종목이므로 symbol 파라미터는 portfolio 단위 필터) -SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, - quantity::float8, price::float8, fee_amount::float8, tax_amount::float8, - executed_at, recorded_at, cash_balance_after::float8, asset_quantity_after::float8, - total_value_after::float8, profit_buffer_after::float8, strategy_set_id, note -FROM portfolio_ledger -WHERE portfolio_id = %s - AND side IN ('buy', 'sell') -ORDER BY executed_at ASC, ledger_entry_id ASC --- 매도 집계는 Python 계층(WAC 함수)에서 from_/to_ 기간 필터 적용 -``` - -## 날짜 파라미터 파싱 규칙 - -- `date` 파라미터가 있으면 from_=date, to_=date (하루 범위) -- `from` + `to` 파라미터가 있으면 그대로 사용 -- `date`도 없고 `from`/`to`도 없으면 HTTP 422 반환 -- `from`만 있거나 `to`만 있는 경우: 422 반환 -- 날짜 형식 파싱 실패 시: HTTP 422 반환 (YYYY-MM-DD 아닌 경우) - -## data_completeness_warning 트리거 - -REQ-041 AC-3/AC-4 기준: -1. running_qty=0인 상태에서 sell 발생 (buy 이력 없음) -2. sell 수량이 running_qty를 초과 (running_qty < sell_qty) - -경고 시 계산은 중단하지 않고 최선 추정 계속 (WAC=0 또는 현재 WAC 유지). diff --git a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/implementation.md b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/implementation.md deleted file mode 100644 index cecb2cf..0000000 --- a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/implementation.md +++ /dev/null @@ -1,60 +0,0 @@ -# BAT-017 Implementation - -## Status: Complete - -## Changed Files - -### New Files - -| File | Purpose | -| --- | --- | -| `app/domain/pnl.py` | WAC realized PnL calculation (REQ-041) — pure Python, no DB dependency | -| `tests/test_wac_calculation.py` | WAC unit tests — 20 tests, DB-free (REQ-042) | -| `tests/test_v1_portfolios_trades.py` | GET /trades integration tests — 12 tests (REQ-042) | -| `tests/test_v1_portfolios_pnl.py` | GET /pnl/realized integration tests — 18 tests (REQ-042) | - -### Modified Files - -| File | Changes | -| --- | --- | -| `app/schemas/v1.py` | Added `date` import; added `TradeHistoryItem`, `TradeHistoryResponse`, `RealizedPnlItem`, `RealizedPnlResponse` | -| `app/api/v1_portfolios.py` | Added `date`, `datetime`, `timedelta`, `timezone` imports; added `calculate_realized_pnl` import; added schema imports; added `_parse_date_param()` helper; added `list_portfolio_trades()` endpoint (REQ-039); added `get_realized_pnl()` endpoint (REQ-040) | -| `app/repositories/postgres.py` | Added `list_trades_by_date()` method (REQ-039); added `get_all_trades_for_pnl()` method (REQ-040/041) | -| `app/repositories/portfolio_repository.py` | Added `list_trades_by_date()` and `get_all_trades_for_pnl()` abstract method signatures | -| `docs/batches/index.md` | Added BAT-017 register row | - -### Batch Documents - -| File | Purpose | -| --- | --- | -| `docs/batches/bat-017_.../index.md` | Batch index | -| `docs/batches/bat-017_.../planning.md` | Planning | -| `docs/batches/bat-017_.../design.md` | Design | -| `docs/batches/bat-017_.../implementation.md` | This file | -| `docs/batches/bat-017_.../verification.md` | Verification | - -## Key Implementation Decisions - -### WAC 계산 함수 위치 -- `app/domain/pnl.py` 신규 — domain 계층 (REQ-041 Notes: domain 계층 권장) -- `app/services/portfolio_service.py` 수정 없음 — WAC 함수는 domain에 독립 배치 - -### `_parse_date_param` 날짜 형식 강제 -- Python 3.11+ `date.fromisoformat()`은 `YYYYMMDD`(대시 없음)도 수용함 -- REQ-039 AC-4 준수를 위해 `re.fullmatch(r"\d{4}-\d{2}-\d{2}", value)` 사전 검증 추가 - -### `get_all_trades_for_pnl` 쿼리 설계 -- WAC 정확성 확보를 위해 집계 기간(`from`/`to`) 제약 없이 portfolio의 모든 buy/sell 이력 조회 -- Python domain 함수에서 `from_dt`/`to_dt` 파라미터로 sell 집계 필터 적용 - -### `list_trades_by_date` 날짜 경계 -- `date` 단일: `[date 00:00 UTC, date+1day 00:00 UTC)` 반개구간 -- `to_` 포함 경계: `to_ + timedelta(days=1)` exclusive upper bound 적용 - -### data_completeness_warning 경고 -- running_qty=0에서 sell 발생 또는 sell qty > running_qty 시 경고 필드 설정 -- 계산은 중단하지 않고 최선 추정 계속 (REQ-041 AC-3/AC-4) - -### 서비스/클라이언트 토큰 접근 제한 -- 두 엔드포인트 모두 `token_type in ("service", "client")` → 403 -- DB 토큰 또는 env 토큰(read 이상)만 접근 가능 diff --git a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/index.md b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/index.md deleted file mode 100644 index 455aa78..0000000 --- a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/index.md +++ /dev/null @@ -1,42 +0,0 @@ -# BAT-017: 일일 거래내역 + 실현 PnL 조회 API (dcy-007 M1 fast-track) - -- Status: released -- Profile: standard -- Source Discovery: dcy-007 -- Included REQ: REQ-039, REQ-040, REQ-041, REQ-042 -- Excluded REQ: 없음 - -## Summary -- dcy-007(일일 거래내역 + 실현 PnL 조회 API) 산출물 4건을 단일 batch로 묶는다. -- GET /v1/portfolios/{id}/trades (날짜/범위 필터) 엔드포인트를 신설하고(REQ-039), - GET /v1/portfolios/{id}/pnl/realized (WAC 기반 실현 PnL 집계) 엔드포인트를 신설한다(REQ-040). -- WAC(weighted average cost) cost_basis 산출 로직을 service/domain 계층에 순수 Python으로 격리 구현해 단위 테스트가 DB 없이 가능하도록 한다(REQ-041). -- AC 기댓값 수기 대조·날짜 필터·422·R-1 경고 필드 테스트 커버리지를 확보한다(REQ-042). - -## Scope -- In Scope: - - REQ-039 (Interface): GET /v1/portfolios/{id}/trades (date 단일/from+to 범위 필터, read_token 이상 인증, 소유권 403, 404, 날짜누락 422) - - REQ-040 (Interface): GET /v1/portfolios/{id}/pnl/realized (from+to 필수, symbol 선택, WAC 기반 실현 PnL, data_completeness_warning, cost_basis_from) - - REQ-041 (Integration): WAC cost_basis 산출 로직 — app/domain/pnl.py 신규(순수 Python, DB 무의존, R-1 완화 경고 로직 포함) - - REQ-042 (Testing): tests/test_wac_calculation.py, tests/test_v1_portfolios_trades.py, tests/test_v1_portfolios_pnl.py 신규 - - app/repositories/postgres.py: list_trades_by_date, get_all_trades_for_pnl 메서드 추가 - - app/repositories/portfolio_repository.py: abstract method 추가 - - app/schemas/v1.py: TradeHistoryItem, TradeHistoryResponse, RealizedPnlItem, RealizedPnlResponse 스키마 추가 -- Out of Scope: - - 미실현 PnL(현재가 연동) — dcy-007 OQ-2 Out of Scope - - 전체 portfolio 집계 PnL 뷰 — dcy-007 OQ-2 Out of Scope - - DB 마이그레이션 — 기존 portfolio_ledger 스키마로 조회 가능 - - 페이지네이션 — 이번 범위 외 이연 - - 성능 테스트(p95 500ms) — 통합/성능 테스트 환경에서 별도 검증 - -## Documents -- Planning: [planning.md](./planning.md) -- Design: [design.md](./design.md) -- Implementation: [implementation.md](./implementation.md) -- Verification: [verification.md](./verification.md) - -## Notes -- 6/22 적용 M1 fast-track — 단일 batch, 단일 구현 주기. -- DB 마이그레이션 불필요(기존 portfolio_ledger 스키마). -- 구현 순서: REQ-041(WAC 도메인 로직) → REQ-039/040(API 계층) → REQ-042(테스트). -- WAC 기댓값 199,300 검증: buy 100주@10,000 + buy 100주@12,000 → sell 50주@15,000 fee=500 tax=200. diff --git a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/planning.md b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/planning.md deleted file mode 100644 index cfa3211..0000000 --- a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/planning.md +++ /dev/null @@ -1,64 +0,0 @@ -# BAT-017 Planning - -## Included REQ - -| REQ | Type | Summary | -| --- | --- | --- | -| REQ-039 | Interface | GET /v1/portfolios/{id}/trades — 날짜/범위 필터 체결 내역 | -| REQ-040 | Interface | GET /v1/portfolios/{id}/pnl/realized — WAC 실현 PnL 집계 | -| REQ-041 | Integration | WAC cost_basis 산출 로직 (service/domain 격리) | -| REQ-042 | Testing | 테스트 커버리지 (WAC 기댓값 대조, 날짜 필터, 422, R-1 경고) | - -## Delivery Plan - -### 구현 순서 -1. **REQ-041 먼저** — WAC 도메인 로직(`app/domain/pnl.py`) 신규. 순수 Python, DB 무의존. REQ-040 API가 이에 의존하므로 선행 필수. -2. **REQ-039** — GET /trades 엔드포인트 + repository 메서드(`list_trades_by_date`) + 스키마. -3. **REQ-040** — GET /pnl/realized 엔드포인트 + repository 메서드(`get_all_trades_for_pnl`) + 스키마. -4. **REQ-042** — 테스트 파일 3종 신규 작성. - -### 파일 변경 목록 -- **신규 생성**: - - `app/domain/pnl.py` — WAC 계산 함수 (REQ-041) - - `tests/test_wac_calculation.py` — WAC 순수 단위 테스트 (REQ-042) - - `tests/test_v1_portfolios_trades.py` — GET /trades 통합 테스트 (REQ-042) - - `tests/test_v1_portfolios_pnl.py` — GET /pnl/realized 통합 테스트 (REQ-042) -- **수정**: - - `app/schemas/v1.py` — TradeHistoryItem, TradeHistoryResponse, RealizedPnlItem, RealizedPnlResponse 추가 - - `app/api/v1_portfolios.py` — GET /trades, GET /pnl/realized 엔드포인트 추가 - - `app/repositories/postgres.py` — list_trades_by_date, get_all_trades_for_pnl 메서드 추가 - - `app/repositories/portfolio_repository.py` — abstract method 추가 - - `docs/batches/index.md` — BAT-017 register 행 추가 - -## Dependencies - -- 기존 portfolio_ledger 테이블(TimescaleDB hypertable) — 스키마 변경 없음 -- idx_portfolio_ledger_portfolio_executed 인덱스 — 날짜 범위 쿼리 활용 -- 기존 require_read 인증 의존성 — 변경 없음 -- REQ-041 WAC 로직은 REQ-040 엔드포인트의 service 계층 호출 대상 - -## Design Gate - -**Design 필요: YES** - -근거: 신규 엔드포인트 2개(HTTP 계약 확장), 신규 스키마 4종, 신규 domain 계층 모듈. 인터페이스 계약 및 계층 경계를 design.md에 명시해야 한다. - -그러나 M1 fast-track 지시로 planning → design → implementation을 연속 수행한다. 설계는 simple하고 기존 코드 패턴을 그대로 따른다: -- WAC 함수: 순수 Python 함수, 입/출력 타입 명확, DB 무의존 -- 두 엔드포인트: 기존 `_get_portfolio_or_404` + `require_read` 패턴 재사용 -- Repository: 기존 `list_recent_trades` 패턴 확장 - -## Milestones - -1. app/domain/pnl.py + test_wac_calculation.py 구현 및 단위 테스트 통과 -2. GET /trades 엔드포인트 + 통합 테스트 구현 -3. GET /pnl/realized 엔드포인트 + 통합 테스트 구현 -4. 전체 pytest 실행 — 신규 3개 파일 + 기존 회귀 확인 - -## Risks - -| Risk | 완화 | -| --- | --- | -| repository 메서드가 WAC 산출용 집계기간 이전 buy도 조회해야 함 | get_all_trades_for_pnl은 집계 기간과 무관하게 해당 portfolio+symbol의 전체 이력을 조회해 WAC 정확성 확보 | -| TestClient 통합 테스트가 DB 의존 | MagicMock 기반 repo 주입 패턴(기존 test_v1_portfolios_ledger.py 참조)으로 DB 없이 실행 | -| R-1 이력 불완전 경고 조건 | running_qty < sell_qty 또는 running_qty=0에서 sell 발생 시 data_completeness_warning 반환 (REQ-041 AC-3/AC-4) | diff --git a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/verification.md b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/verification.md deleted file mode 100644 index fdf962c..0000000 --- a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/verification.md +++ /dev/null @@ -1,166 +0,0 @@ -# BAT-017 Verification - -## Status: approved (confirm-batch-verification) - -## 실행 명령 - -```bash -.venv/bin/python3 -m pytest tests/test_wac_calculation.py tests/test_v1_portfolios_trades.py tests/test_v1_portfolios_pnl.py -v -``` - -## 실제 pytest 결과 (2026-06-18) - -``` -================================================================================== test session starts ================================================================================== -platform linux -- Python 3.12.3, pytest-8.4.2, pluggy-1.6.0 -rootdir: /home/genichin/workspace/trex-lead/repos/portfolio-hub -collected 50 items - -tests/test_wac_calculation.py::TestWac199300::test_total_realized_pnl PASSED -tests/test_wac_calculation.py::TestWac199300::test_cost_basis_per_unit PASSED -tests/test_wac_calculation.py::TestWac199300::test_no_warning_when_history_complete PASSED -tests/test_wac_calculation.py::TestWac199300::test_cost_basis_from_is_first_buy PASSED -tests/test_wac_calculation.py::TestWac199300::test_sell_item_fields PASSED -tests/test_wac_calculation.py::TestBuyOnlyNoSell::test_empty_items PASSED -tests/test_wac_calculation.py::TestBuyOnlyNoSell::test_cost_basis_from_set PASSED -tests/test_wac_calculation.py::TestBuyOnlyNoSell::test_no_warning PASSED -tests/test_wac_calculation.py::TestEmptyInput::test_empty_rows PASSED -tests/test_wac_calculation.py::TestSellWithNoBuyHistory::test_warning_is_set PASSED -tests/test_wac_calculation.py::TestSellWithNoBuyHistory::test_calculation_continues PASSED -tests/test_wac_calculation.py::TestSellExceedsRunningQty::test_warning_when_oversell PASSED -tests/test_wac_calculation.py::TestSellExceedsRunningQty::test_calculation_not_aborted PASSED -tests/test_wac_calculation.py::TestMultipleBuySellCycles::test_wac_is_cumulative PASSED -tests/test_wac_calculation.py::TestDateFilter::test_filter_includes_only_sells_in_range PASSED -tests/test_wac_calculation.py::TestDateFilter::test_filter_both_sells_in_range PASSED -tests/test_wac_calculation.py::TestDateFilter::test_wac_uses_all_buys_regardless_of_filter PASSED -tests/test_wac_calculation.py::TestSymbolFilter::test_symbol_filter_excludes_other_symbols PASSED -tests/test_wac_calculation.py::TestSymbolFilter::test_no_symbol_filter_includes_all PASSED -tests/test_wac_calculation.py::TestNoPsycopgInDomainModule::test_pnl_module_has_no_db_import PASSED -tests/test_v1_portfolios_trades.py::test_trades_single_date_returns_matching_count PASSED -tests/test_v1_portfolios_trades.py::test_trades_single_date_item_fields PASSED -tests/test_v1_portfolios_trades.py::test_trades_range_filter_returns_items PASSED -tests/test_v1_portfolios_trades.py::test_trades_no_date_params_returns_422 PASSED -tests/test_v1_portfolios_trades.py::test_trades_bad_date_format_returns_422 PASSED -tests/test_v1_portfolios_trades.py::test_trades_slash_date_format_returns_422 PASSED -tests/test_v1_portfolios_trades.py::test_trades_empty_range_returns_200_empty PASSED -tests/test_v1_portfolios_trades.py::test_trades_non_owner_returns_403 PASSED -tests/test_v1_portfolios_trades.py::test_trades_nonexistent_portfolio_returns_404 PASSED -tests/test_v1_portfolios_trades.py::test_trades_endpoint_in_openapi PASSED -tests/test_v1_portfolios_trades.py::test_trades_from_only_without_to_returns_422 PASSED -tests/test_v1_portfolios_trades.py::test_trades_to_only_without_from_returns_422 PASSED -tests/test_v1_portfolios_pnl.py::TestWac199300EndToEnd::test_total_realized_pnl_is_199300 PASSED -tests/test_v1_portfolios_pnl.py::TestWac199300EndToEnd::test_cost_basis_per_unit_is_11000 PASSED -tests/test_v1_portfolios_pnl.py::TestWac199300EndToEnd::test_items_contain_required_fields PASSED -tests/test_v1_portfolios_pnl.py::TestWac199300EndToEnd::test_data_completeness_warning_is_null_when_complete PASSED -tests/test_v1_portfolios_pnl.py::TestWac199300EndToEnd::test_cost_basis_from_is_first_buy_date PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_missing_from_returns_422 PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_missing_to_returns_422 PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_both_missing_returns_422 PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_no_sells_returns_200_with_zero PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_empty_ledger_returns_200_with_zero PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_sell_without_buy_triggers_warning PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_complete_history_warning_is_null PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_oversell_triggers_warning PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_symbol_filter_passed_to_repo PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_symbol_filter_excludes_other_symbol_sells PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_non_owner_returns_403 PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_nonexistent_portfolio_returns_404 PASSED -tests/test_v1_portfolios_pnl.py::test_pnl_endpoint_in_openapi PASSED - -50 passed, 8 warnings in 3.43s -``` - -**결과: 50 passed, 0 failed** - -## 기존 회귀 테스트 결과 - -```bash -.venv/bin/python3 -m pytest \ - --ignore=tests/test_wac_calculation.py \ - --ignore=tests/test_v1_portfolios_trades.py \ - --ignore=tests/test_v1_portfolios_pnl.py -``` - -``` -377 passed, 3 warnings in 17.25s -``` - -**결과: 377 passed, 0 failed — 기존 테스트 회귀 없음** - -## 환경 제약 사항 - -- DB 통합 테스트 환경(`pfhub_DB_ENABLED=true`, TimescaleDB 컨테이너) 없이 실행. -- 통합 테스트(`test_v1_portfolios_trades.py`, `test_v1_portfolios_pnl.py`)는 TestClient + MagicMock repo 주입 방식으로 DB 없이 실행 가능. -- WAC 단위 테스트(`test_wac_calculation.py`)는 순수 Python — DB 불필요 확인. -- 성능 테스트(AC-9: p95 500ms) 및 DB 인덱스 활용 검증은 통합/성능 테스트 환경에서 별도 수행 필요. - -## REQ별 AC 충족 매핑 - -### REQ-039: GET /v1/portfolios/{id}/trades - -| AC | 테스트 | 결과 | -| --- | --- | --- | -| AC-1: date 필터 + 200 + 항목 필드 확인 | `test_trades_single_date_returns_matching_count`, `test_trades_single_date_item_fields` | PASS | -| AC-2: from/to 범위 필터 + 200 + 경계 포함 | `test_trades_range_filter_returns_items` | PASS | -| AC-3: 날짜 파라미터 누락 → 422 | `test_trades_no_date_params_returns_422` | PASS | -| AC-4: 날짜 형식 오류 → 422 | `test_trades_bad_date_format_returns_422`, `test_trades_slash_date_format_returns_422` | PASS | -| AC-5: 0건 → 200 + empty | `test_trades_empty_range_returns_200_empty` | PASS | -| AC-6: 비소유자 → 403 | `test_trades_non_owner_returns_403` | PASS | -| AC-7: 존재하지 않는 portfolio → 404 | `test_trades_nonexistent_portfolio_returns_404` | PASS | -| AC-8: OpenAPI 문서화 | `test_trades_endpoint_in_openapi` | PASS | -| AC-9: p95 500ms | 성능 환경 별도 (환경 제약) | 환경 제약 | - -### REQ-040: GET /v1/portfolios/{id}/pnl/realized - -| AC | 테스트 | 결과 | -| --- | --- | --- | -| AC-1: 199,300 WAC + cost_basis_per_unit=11,000 | `TestWac199300EndToEnd::test_total_realized_pnl_is_199300`, `test_cost_basis_per_unit_is_11000` | PASS | -| AC-2: symbol 필터 | `test_pnl_symbol_filter_passed_to_repo`, `test_pnl_symbol_filter_excludes_other_symbol_sells` | PASS | -| AC-3: from/to 누락 → 422 | `test_pnl_missing_from_returns_422`, `test_pnl_missing_to_returns_422`, `test_pnl_both_missing_returns_422` | PASS | -| AC-4: 매도 0건 → 200 + total=0 + items=[] | `test_pnl_no_sells_returns_200_with_zero`, `test_pnl_empty_ledger_returns_200_with_zero` | PASS | -| AC-5: 이력 불완전 → data_completeness_warning 비어 있지 않음 | `test_pnl_sell_without_buy_triggers_warning`, `test_pnl_oversell_triggers_warning` | PASS | -| AC-6: 이력 완전 → data_completeness_warning null | `test_pnl_complete_history_warning_is_null`, `TestWac199300EndToEnd::test_data_completeness_warning_is_null_when_complete` | PASS | -| AC-7: 비소유자 → 403 | `test_pnl_non_owner_returns_403` | PASS | -| AC-8: 존재하지 않는 portfolio → 404 | `test_pnl_nonexistent_portfolio_returns_404` | PASS | -| AC-9: OpenAPI 문서화 (data_completeness_warning 포함) | `test_pnl_endpoint_in_openapi` | PASS | -| AC-10: p95 500ms | 성능 환경 별도 (환경 제약) | 환경 제약 | - -### REQ-041: WAC cost_basis 산출 로직 - -| AC | 테스트 | 결과 | -| --- | --- | --- | -| AC-1: WAC=11,000 + realized_pnl=199,300 | `TestWac199300::test_total_realized_pnl`, `test_cost_basis_per_unit` | PASS | -| AC-2: buy만 있고 sell 없음 → items=[] | `TestBuyOnlyNoSell::test_empty_items` | PASS | -| AC-3: sell 시작(buy 없음) → warning 비어 있지 않음 | `TestSellWithNoBuyHistory::test_warning_is_set`, `test_calculation_continues` | PASS | -| AC-4: sell qty > running_qty → warning | `TestSellExceedsRunningQty::test_warning_when_oversell` | PASS | -| AC-5: 반복 buy/sell → WAC 누적 정확성 | `TestMultipleBuySellCycles::test_wac_is_cumulative` | PASS | -| AC-6: 빈 입력 → items=[], total=0, cost_basis_from=None | `TestEmptyInput::test_empty_rows` | PASS | -| AC-7: DB import 없음 (grep) | `TestNoPsycopgInDomainModule::test_pnl_module_has_no_db_import` | PASS | -| AC-8: DB 없이 실행 | 전체 WAC 단위 테스트 DB 미사용 — PASS | PASS | - -### REQ-042: 테스트 커버리지 - -| AC | 결과 | -| --- | --- | -| AC-1: test_wac_calculation.py — WAC 시나리오 포함 199,300 | 20 tests PASS | -| AC-2: test_v1_portfolios_trades.py — date/from-to/422/403/404 | 12 tests PASS | -| AC-3: test_v1_portfolios_pnl.py — 199,300/422/0건/warning | 18 tests PASS | -| AC-4: 전체 50 tests PASSED | PASS | -| AC-5: WAC 단위 테스트 DB 없이 통과 | PASS | -| AC-6: 기존 377 tests 회귀 없음 | PASS | - -## 남은 한계 (환경 제약 및 OoS) - -1. **성능 테스트(AC-9/AC-10)**: p95 500ms — TimescaleDB 인덱스 환경에서 별도 실행 필요. 이번 batch 검증 범위 외. -2. **DB 직접 통합 테스트**: `pfhub_DB_ENABLED=true` + Docker 인프라 없이 실행. MagicMock 기반 단위/통합 테스트로 행동 검증 완료. -3. **symbol 컬럼 부재**: `portfolio_ledger`에 symbol 컬럼 없음. 현재 portfolio 단위(1 portfolio = 1 symbol) 가정. 멀티 symbol portfolio 지원 시 별도 설계 필요. -4. **DeprecationWarning**: `HTTP_422_UNPROCESSABLE_ENTITY` → `HTTP_422_UNPROCESSABLE_CONTENT` (FastAPI 최신 버전 경고). 기존 코드 패턴 유지(breaking change 없음). 별도 cleanup batch에서 처리 권장. - -## Approval - -- Status: **approved** (confirm-batch-verification) → batch `release-candidate` -- Approver: 팀장 (Claude), 2026-06-18 -- Independent QC: `qc-reviewer` verdict **PASS-WITH-NOTES**. 50 passed 독립 재현 + 전체 427 passed 회귀 없음, WAC=11,000/realized=199,300 단위+e2e 진짜 검증, R-1 경고필드(buy없음·oversell·완전이력 null) 정상, NFR-3 격리(domain DB import 0) 확인, 스코프·DB 마이그레이션 무, 위생 이상 없음. -- 수용한 잔여(비차단 follow-up): (1) symbol 필터 `symbol_param != portfolio_symbol` edge 테스트 공백 — AC-2는 충족이나 완전 커버 아님. (2) `HTTP_422_UNPROCESSABLE_ENTITY` deprecated(기존 패턴). 둘 다 별도 cleanup batch 또는 후속 처리. -- 성능 AC-9/AC-10(p95 500ms)은 TimescaleDB 환경 필요 — 환경 제약 미검증(코드결함 아님). -- REQ-039~042: confirm-req-implemented로 `Implemented` 전환. diff --git a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/implementation.md b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/implementation.md deleted file mode 100644 index 9bf6a5a..0000000 --- a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/implementation.md +++ /dev/null @@ -1,33 +0,0 @@ -# BAT-018 Implementation - -## 구현 결정 사항 - -### domain 함수 선택: 신규 함수 분리 (OQ-2 위임 → 분리 채택) -- `calculate_current_wac(rows) -> CurrentWacResult` 신규 함수를 `app/domain/pnl.py`에 추가. -- `calculate_realized_pnl`의 WAC 누적 로직과 동일 규칙을 따르되, 매도 PnL 집계 없이 최종 running_wac/running_qty만 반환. -- 이유: 단일책임 원칙 + 기존 함수 파라미터 과부하 방지. 두 함수는 입력/출력 목적이 다름(매도 PnL vs 현재 평단). - -### 소유권 분기 방식 -- 핸들러 내에서 `auth.token_type == "service"` 분기로 처리. -- service: `managed_by_daemon_id is None` → 403, `auth.daemon_id != managed_by_daemon_id` → 403. -- user/admin: `auth.permission != Permission.ADMIN and portfolio.owner_user_id != auth.user_id` → 403. -- `_get_portfolio_or_404` 헬퍼는 user token 소유권 검사를 포함하므로 서비스 토큰에는 미사용(직접 `repo.get()` 호출). - -### current_quantity 출처 -- `portfolio.asset_quantity` 직접 읽기 (REQ-043 Notes 준수). -- WAC 산출은 ledger replay 독립 계산(calculate_current_wac). - -## 변경 파일 목록 - -| 파일 | 변경 유형 | 내용 | -| --- | --- | --- | -| `app/domain/pnl.py` | 수정 | `CurrentWacResult` dataclass + `calculate_current_wac` 함수 추가 | -| `app/schemas/v1.py` | 수정 | `HoldingsCostBasisResponse` 스키마 추가 | -| `app/api/v1_portfolios.py` | 수정 | `calculate_current_wac` import, `HoldingsCostBasisResponse` import, `get_holdings_cost_basis` 핸들러 추가 | -| `tests/test_v1_portfolios_holdings.py` | 신규 | AC-1~11 커버 테스트 20개 | -| `docs/batches/index.md` | 수정 | BAT-018 register 행 추가 | -| `docs/batches/bat-018_*/index.md` | 신규 | batch index 문서 | -| `docs/batches/bat-018_*/planning.md` | 신규 | batch planning 문서 | - -## 커밋 SHA -- `748aa5d` — feat(holdings): GET /v1/portfolios/{id}/holdings/cost-basis — REQ-043, BAT-018 diff --git a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/index.md b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/index.md deleted file mode 100644 index de16a66..0000000 --- a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/index.md +++ /dev/null @@ -1,36 +0,0 @@ -# BAT-018: 현재 보유 평단(WAC) 조회 엔드포인트 신설 (REQ-043) - -- Status: release-candidate -- Profile: batch-lite -- Source Discovery: dcy-008 (현재 보유 종목별 평단(WAC) 조회 API) -- Included REQ: REQ-043 -- Excluded REQ: none - -## Summary -- `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` 신규 엔드포인트 신설. -- 현재 미매도 보유 종목의 (symbol, current_quantity, cost_basis_per_unit=WAC, total_cost_basis, cost_basis_from, data_completeness_warning) 반환. -- WAC 산출은 `app/domain/pnl.py`의 기존 running_wac 로직(REQ-041)을 재사용하는 `calculate_current_wac` 신규 함수로 분리(단일책임 원칙). -- 인증: `require_read` + service token(daemon scope) 소유권 정책(REQ-029 선례, `daemon_id == managed_by_daemon_id` 일치). -- trex-core dcy-034 FR-7(cold-start 슬롯 평단 seed)의 선행 의존. - -## Scope -- In Scope: - - `app/domain/pnl.py` — `calculate_current_wac` 신규 함수 추가 (running_wac 로직 재사용) - - `app/schemas/v1.py` — `HoldingsCostBasisResponse` 스키마 신규 추가 - - `app/api/v1_portfolios.py` — `GET /holdings/cost-basis` 엔드포인트 추가 (service/user token 소유권 분기) - - `tests/test_v1_portfolios_holdings.py` — AC-1~11 커버 신규 테스트 파일 - - `docs/batches/index.md` — BAT-018 register 행 추가 -- Out of Scope: - - 미실현 손익(현재가 결합) — portfolio-hub 시세 비소유 - - DB 스키마 변경/마이그레이션 — 기존 portfolio_ledger 재사용 - - repository 신규 메서드 — `get_all_trades_for_pnl` 재사용(신규 SQL 불필요) - -## Documents -- Planning: [planning.md](./planning.md) -- Design: none (batch-lite, design gate skip) -- Implementation: [implementation.md](./implementation.md) -- Verification: [verification.md](./verification.md) - -## Notes -- profile 선정 근거: 단일 Approved REQ(REQ-043) + read-only(쓰기·마이그레이션 없음) + 기존 WAC 로직/인증 패턴 재사용 → batch-lite 적합. -- design 생략 근거: 신규 엔드포인트이나 기존 `get_realized_pnl` 패턴을 그대로 따르고, WAC 함수는 기존 `calculate_realized_pnl` running_wac 추출로 구현 결정이 단순함. diff --git a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/planning.md b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/planning.md deleted file mode 100644 index f48dc67..0000000 --- a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/planning.md +++ /dev/null @@ -1,60 +0,0 @@ -# BAT-018 Planning - -- Status: confirmed -- Batch: BAT-018 -- Profile: batch-lite - -## Included REQ -- REQ-043 (Interface, Approved) — GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단(WAC) read 엔드포인트 신설. - -## Delivery Plan - -### 구현 순서 -1. **domain 함수 추가** (`app/domain/pnl.py`) - - `calculate_current_wac(rows)` 신규 함수 — 기존 `calculate_realized_pnl`의 running_wac 누적 로직 재사용, 매도 완료 후 잔여 running_wac/running_qty/cost_basis_from/data_completeness_warning 반환. - - 반환 타입: 새 `CurrentWacResult` dataclass (wac, quantity, cost_basis_from, data_completeness_warning). -2. **스키마 추가** (`app/schemas/v1.py`) - - `HoldingsCostBasisResponse` 스키마 신규 추가 (REQ-043 응답 필드 전체 포함). -3. **엔드포인트 추가** (`app/api/v1_portfolios.py`) - - `GET /{portfolio_id}/holdings/cost-basis` 핸들러 — `require_read` 의존성. - - 소유권 분기: service token → `daemon_id == managed_by_daemon_id` 일치 확인(불일치/null → 403). user token → admin 외 비소유자 403. - - `portfolio.asset_quantity`에서 current_quantity 직접 읽기. - - WAC: `get_all_trades_for_pnl` 재사용 → `calculate_current_wac` 호출. -4. **테스트** (`tests/test_v1_portfolios_holdings.py` 신규) - - AC-1~11 커버. MagicMock repo 주입 패턴(기존 test_v1_portfolios_pnl.py 참조). - -### 파일 변경 목록 -- **신규 생성**: - - `tests/test_v1_portfolios_holdings.py` — AC-1~11 테스트 - - `docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/` — batch 문서 -- **수정**: - - `app/domain/pnl.py` — `CurrentWacResult` dataclass + `calculate_current_wac` 함수 추가 - - `app/schemas/v1.py` — `HoldingsCostBasisResponse` 추가 - - `app/api/v1_portfolios.py` — 엔드포인트 추가, import 갱신 - - `docs/batches/index.md` — BAT-018 register 행 추가 - -## Dependencies -- `app/domain/pnl.py`의 `calculate_realized_pnl` running_wac 로직(REQ-041) — 이미 구현됨. -- `app/repositories/portfolio_repository.py`의 `get_all_trades_for_pnl` — 이미 구현됨. -- `require_read` 인증 의존성 — 변경 없음. -- `portfolio.asset_quantity` 컬럼 — 이미 존재. - -## Design Gate -- Design Required Before Implementation: **no** -- Reason: - - 기존 `get_realized_pnl` 핸들러(L710)의 직계 변형. HTTP contract·schema·계층 경계 모두 기존 패턴 답습. - - WAC 함수: `calculate_realized_pnl`의 running_wac 추출 — 구조적 신규 설계 없음. - - 소유권 정책: REQ-029 선례(`_require_service_token` + `managed_by_daemon_id` 검사)를 read 엔드포인트에 적용, 분기만 추가. - -## Milestones -1. domain 함수(`calculate_current_wac`) + 단위 테스트 작성 -2. 스키마(`HoldingsCostBasisResponse`) 추가 -3. 엔드포인트(`GET /holdings/cost-basis`) 추가 -4. 통합 테스트(`test_v1_portfolios_holdings.py`) AC-1~11 작성 -5. `pytest` 전체 실행 — 신규 + 기존 회귀 확인 - -## Risks -| Risk | 완화 | -| --- | --- | -| R-1 (Low): current_quantity와 WAC 수량 불일치 시 data_completeness_warning 조건 | `portfolio.asset_quantity`는 직접 읽고, WAC는 ledger replay 독립 계산. 불일치 시 warning 트리거 → REQ-043 Notes 항목 준수. | -| R-2 (Low): service token 인증 분기가 기존 user token 패턴과 충돌 | `_get_portfolio_or_404`는 user token 전용 소유권 검사. service token 분기는 핸들러 내 명시적 분기로 별도 처리. | diff --git a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/verification.md b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/verification.md deleted file mode 100644 index df9ef95..0000000 --- a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/verification.md +++ /dev/null @@ -1,60 +0,0 @@ -# BAT-018 Verification - -- Status: draft-batch-verification -- Verified At: 2026-06-25 -- Profile: batch-lite (app-service) - -## pytest 실행 결과 (원문) - -``` -============================================================= test session starts ============================================================= -platform linux -- Python 3.12.3, pytest-8.4.2, pluggy-1.6.0 -- /home/genichin/workspace/trex-lead/repos/portfolio-hub/.venv/bin/python3 -cachedir: .pytest_cache -rootdir: /home/genichin/workspace/trex-lead/repos/portfolio-hub/.claude/worktrees/agent-adf14aa7889b45655 -configfile: pyproject.toml -plugins: anyio-4.3.0 -collecting ... collected 447 items - -447 passed, 11 warnings in 23.24s -``` - -신규 테스트 20개: 전부 PASS -기존 회귀 427개: 전부 PASS (0 failed, 0 error) -경고 11개: 기존 DeprecationWarning (HTTP_422_UNPROCESSABLE_ENTITY deprecated) — 본 batch 변경과 무관, 선행 코드에서 유입. - -## AC 커버리지 - -| AC | 테스트 함수 | 결과 | -| --- | --- | --- | -| AC-1 (buy-only WAC=11,000) | TestAC1WacBuysOnly::test_cost_basis_per_unit_is_11000 + 4개 | PASS | -| AC-2 (부분매도 WAC 불변, qty=50) | TestAC2PartialSellWacUnchanged::* (3개) | PASS | -| AC-3 (user owner → 200) | test_ac3_user_owner_read_token_returns_200 | PASS | -| AC-4 (비소유자 user → 403) | test_ac4_user_non_owner_returns_403 | PASS | -| AC-5 (daemon mismatch → 403) | test_ac5_daemon_mismatch_returns_403 | PASS | -| AC-6 (null daemon → 403) | test_ac6_null_daemon_returns_403 | PASS | -| AC-7 (미존재 portfolio → 404) | test_ac7_nonexistent_portfolio_returns_404 | PASS | -| AC-8 (빈 이력, qty=0 → 200 null) | test_ac8_empty_ledger_returns_200_null_fields | PASS | -| AC-9 (sell without buy → warning) | test_ac9_sell_without_buy_triggers_warning | PASS | -| AC-10 (no token → 401) | test_ac10_no_auth_token_returns_401 | PASS | -| AC-11 (OpenAPI 문서화) | test_ac11_endpoint_in_openapi | PASS | - -## 추가 테스트 (커버리지 보강) - -| 테스트 | 내용 | -| --- | --- | -| test_response_contains_all_required_fields | 7개 응답 필드 전부 존재 확인 | -| test_cost_basis_from_is_first_buy_date | cost_basis_from 비null 확인 | -| test_portfolio_id_in_response_matches_request | 응답 portfolio_id 일치 확인 | - -## 회귀 영향 - -- 기존 테스트 427개 전부 통과 — 본 batch 변경(신규 함수/엔드포인트/스키마 추가)이 기존 코드에 미치는 회귀 없음. -- 변경 파일(app/domain/pnl.py, app/schemas/v1.py, app/api/v1_portfolios.py)은 모두 additive 변경(기존 코드 수정 없음, 신규 추가만). - -## 검증 결론 - -- AC-1~11 전부 PASS. -- 기존 회귀 0 실패. -- HTTP API 계약: `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` 신규 추가(호환성 깨짐 없음, read-only). -- DB 마이그레이션: 없음(쓰기/스키마 변경 없음). -- 상태: `draft-batch-verification` 완료 → 팀장 `confirm-batch-verification` 대기. diff --git a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/design.md b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/design.md deleted file mode 100644 index 2d037b7..0000000 --- a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/design.md +++ /dev/null @@ -1,77 +0,0 @@ -# BAT-019 Design - -- Status: confirmed -- Batch: BAT-019 - -## Architecture Summary - -이 batch는 두 가지 독립적인 변경으로 구성된다. - -1. **REQ-044**: `GET /v1/assets`의 인증 게이트를 `require_admin` → `require_read`로 완화. - 기존 `AssetCatalogListResponse` 응답 구조, 필터 파라미터, 라우터 등록은 모두 불변. - -2. **REQ-045**: `GET /v1/daemons` 신규 엔드포인트. - caller(`auth.user_id`) 소유 데몬만 반환. `require_read` 인증. - 신규 파일: `app/api/v1_daemons.py`, 신규 스키마: `DaemonResponse`, `DaemonListResponse`. - `app/main.py`에 라우터 등록. - -## Changed Areas - -- Docs: `docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/` -- Modules: - - `app/api/v1_assets.py` (require_admin → require_read, list_catalog_assets만) - - `app/api/v1_daemons.py` (신규) - - `app/schemas/v1.py` (DaemonResponse, DaemonListResponse 추가) - - `app/main.py` (v1_daemons 라우터 include_router 추가) -- Interfaces: - - `GET /v1/assets`: 기존 인터페이스 불변, 인증 게이트만 완화 - - `GET /v1/daemons`: 신규 엔드포인트 - -## Key Decisions - -1. **REQ-044 — 권한 완화 범위**: `list_catalog_assets`(GET /v1/assets) 1개만 `require_read`로 교체. - POST /v1/assets, PATCH /{id}, PATCH /{id}/deactivate, POST /{id}/tags, DELETE /{id}/tags/{tid}는 `require_admin` 유지. - 응답 스키마 `AssetCatalogListResponse`는 불변. - -2. **REQ-045 — 경로**: `GET /v1/daemons`. - `/v1/user/daemons` 대신 `/v1/daemons` 채택 — 기존 `/v1/tags`, `/v1/assets`, `/v1/portfolios` 패턴과 일치. - -3. **REQ-045 — 응답 스키마**: `DaemonListResponse{daemons: list[DaemonResponse], total: int}`. - `DaemonResponse` 필드: `id`(str), `daemon_slug`(str), `display_name`(str|None), `is_active`(bool). - 기존 `TagListResponse{tags: list[TagItem], total: int}` 패턴과 동일 구조. - -4. **REQ-045 — env/service 토큰(user_id=None) 처리**: `auth.user_id`가 None이면 빈 목록 `[]` + `total: 0`을 HTTP 200으로 반환. - 이유: 환경 변수 토큰 또는 user_id 없는 서비스 토큰은 특정 사용자 소유 데몬이 없으므로 에러보다 빈 응답이 자연스럽다. - 기존 `/v1/user_info`의 env 토큰 처리(403 Service token required)와 다른 접근이며, 데몬 목록은 사용자 식별 없이도 빈 목록을 반환하는 것이 호출자 친화적이다. - -5. **REQ-045 — active 필터**: 기본적으로 소유 데몬 전체 반환(active/inactive 모두). `is_active` 필드로 구분 표시. - 이유: caller가 비활성 데몬도 인지해야 할 수 있으며(재활성화 등), 필터는 UI 레이어에서 처리 가능. - -6. **REQ-045 — AuthService 활용**: `AuthService.find_candidate_daemons_for_user(user_id)` 호출. - 단, 라우터에서 직접 repository를 호출하는 기존 패턴(v1_assets, v1_tags 등)과 일관성을 위해 - 라우터에서 직접 `repo.list_daemons_for_user(user_id)`를 호출하는 방식 채택 - (service 레이어 DI overhead 없이 기존 패턴 유지). - -7. **REQ-044 테스트 영향**: 기존 테스트 중 `test_get_v1_assets_user_token_returns_403`, - `test_get_v1_assets_service_token_returns_403`는 이번 완화로 동작이 바뀜. - — user 토큰(permission="read"): 이제 200이어야 함 → 테스트 업데이트. - — service 토큰(permission="trade-write"): trade-write는 read 포함(_has_permission 로직상)이므로 200이어야 함 → 테스트 업데이트. - 또한 `test_v1_assets_rejects_env_token`(test_v1.py): env read 토큰도 이제 200이어야 함 → 업데이트. - `test_v1_assets_returns_asset_list`: read 토큰이 이제 403이 아닌 200 → 업데이트. - `test_v1_assets_market_filter`, `test_v1_assets_asset_id_is_uuid`: 동일. - -## Edge Cases - -- `user_id=None`(env 토큰): 빈 목록 200 반환 (결정 #4). -- 데몬 없는 사용자: 빈 목록 `[]` 200 반환. -- admin 토큰도 caller 스코프: admin 토큰의 `user_id`가 None이면 빈 목록, user_id가 있으면 해당 user 소유 데몬만. -- `list_daemons_for_user` DB 미활성(db_enabled=False): 빈 목록 반환 또는 InMemory 구현에 따름. - -## Architecture Impact - -- none (라우터 추가는 runtime-flows 수준 변경이 아님; project-structure에 신규 파일 기록 수준) - -## Reference Doc Update Plan - -- project-structure.md: none (파일 추가 수준, 별도 문서화 불필요) -- runtime-flows.md: none diff --git a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/implementation.md b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/implementation.md deleted file mode 100644 index 0dff2e3..0000000 --- a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/implementation.md +++ /dev/null @@ -1,39 +0,0 @@ -# BAT-019 Implementation - -- Status: completed -- Batch: BAT-019 - -## Plan Summary - -- REQ-044: `app/api/v1_assets.py`의 `list_catalog_assets` 의존성을 `require_admin` → `require_read`로 교체. -- REQ-045: `app/schemas/v1.py`에 `DaemonResponse`/`DaemonListResponse` 추가, `app/api/v1_daemons.py` 신규 생성, `app/main.py`에 라우터 등록. -- 테스트: `tests/test_v1.py` 기존 403 기대 케이스 업데이트, `tests/test_asset_catalog.py` 기존 403 기대 케이스 업데이트, `tests/test_v1_daemons.py` 신규 생성. - -## Changed Files - -- `app/api/v1_assets.py`: `require_read` import 추가, `list_catalog_assets` 의존성 교체 (require_admin → require_read) -- `app/api/v1_daemons.py`: 신규 생성 — GET /v1/daemons 라우터 -- `app/schemas/v1.py`: `DaemonResponse`, `DaemonListResponse` 스키마 추가 -- `app/main.py`: `v1_daemons_router` import 및 `include_router` 추가 -- `tests/test_v1.py`: 5개 테스트 업데이트 (403→200 기대치 변경, 일부 내용/이름 변경) -- `tests/test_asset_catalog.py`: 2개 테스트 삭제 후 3개로 교체 (REQ-044 반영 + 무토큰 401 추가) -- `tests/test_v1_daemons.py`: 신규 생성 — REQ-045 AC 전체 + REQ-044 회귀 커버 - -## Execution Log - -1. `app/api/v1_assets.py`: `require_read` import, `list_catalog_assets` Depends 교체 -2. `app/schemas/v1.py`: DaemonResponse, DaemonListResponse 스키마 추가 (파일 말미) -3. `app/api/v1_daemons.py`: 신규 작성 (GET /v1/daemons, user_id=None → 빈 목록 200) -4. `app/main.py`: v1_daemons_router import + include_router -5. `tests/test_v1.py`: 영향받는 5개 테스트 업데이트 -6. `tests/test_asset_catalog.py`: 영향받는 2개 테스트 → 3개로 교체 -7. `tests/test_v1_daemons.py`: 신규 생성 (13개 테스트) -8. pytest 전체 실행 → 결과 verification.md에 기록 - -## Validation - -- pytest 전체 실행 결과: verification.md 참조 - -## Remaining Risks - -- `list_daemons_for_user`는 DB 라이브 테스트 없이 mock으로만 검증됨 (DB 통합 테스트는 비활성화 환경 밖에서 필요) diff --git a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/index.md b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/index.md deleted file mode 100644 index 7bba6a7..0000000 --- a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/index.md +++ /dev/null @@ -1,25 +0,0 @@ -# BAT-019 Index - -- Status: confirmed -- Profile: standard -- Batch: BAT-019 -- Discovery: dcy-009 -- Created: 2026-06-27 -- Confirmed: 2026-06-27 -- PR: #16 (main 머지, HEAD `7f15c6b`) -- QC: PASS (독립 qc-reviewer) - -## Included REQ - -- REQ-044: GET /v1/assets 권한 완화 (require_admin → require_read) -- REQ-045: 사용자 가용 데몬 목록 엔드포인트 신규 (GET /v1/daemons) - -## Scope Summary - -GET /v1/assets 엔드포인트의 인증 의존성을 `require_admin`에서 `require_read`로 완화한다. -신규로 GET /v1/daemons 엔드포인트를 추가해 caller 소유 데몬 목록을 반환한다(require_read). -asset 쓰기 엔드포인트는 require_admin을 유지한다. - -## Folder - -[bat-019](./bat-019_20260627_assets-read-permission-and-daemon-list/) diff --git a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/planning.md b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/planning.md deleted file mode 100644 index f450d71..0000000 --- a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/planning.md +++ /dev/null @@ -1,62 +0,0 @@ -# BAT-019 Planning - -- Status: confirmed -- Batch: BAT-019 -- Profile: standard - -## Included REQ - -- REQ-044: GET /v1/assets 권한 완화 (require_admin → require_read) -- REQ-045: 사용자 가용 데몬 목록 엔드포인트 신규 (GET /v1/daemons) - -## Delivery Plan - -### REQ-044 순서 - -1. `app/api/v1_assets.py`의 `list_catalog_assets` 의존성을 `require_read`로 교체. -2. 나머지 쓰기 엔드포인트(POST/PATCH/DELETE)는 `require_admin` 유지. -3. 기존 test_asset_catalog.py의 `test_get_v1_assets_user_token_returns_403`, - `test_get_v1_assets_service_token_returns_403` 등 403 기대 테스트를 - 200/403 신규 기대치로 업데이트. - -### REQ-045 순서 - -1. `app/schemas/v1.py`에 `DaemonResponse`, `DaemonListResponse` 스키마 추가. -2. `app/api/v1_daemons.py` 신규 생성: GET /v1/daemons 라우터. -3. `app/main.py`에 v1_daemons 라우터 include_router 등록. -4. `tests/test_v1_daemons.py` 신규 생성: AC 전체 커버. - -### 구현 순서 - -REQ-044(단순 1줄 변경) → REQ-045(스키마 → 라우터 → 등록 → 테스트) → 테스트 전체 실행 - -## Dependencies - -- Internal: - - `app/core/auth.py`: `require_read` 의존성 (이미 존재) - - `app/domain/models.py`: `Daemon` 도메인 모델 (이미 존재, line 66) - - `app/services/auth_service.py`: `find_candidate_daemons_for_user` (이미 존재, line 161) - - `app/repositories/portfolio_repository.py`: `list_daemons_for_user` (이미 프로토콜 선언됨, line 103) -- External: - - 없음 - -## Design Gate - -- Design Required Before Implementation: yes -- Reason: REQ-045 신규 엔드포인트이므로 경로 컨벤션, 응답 래퍼 스키마, env/service 토큰(user_id=None) 처리, active 필터 여부를 design에서 확정해야 안전하게 구현할 수 있다. REQ-044는 단순 교체지만 같은 batch이므로 design에 함께 기록한다. - -## Milestones - -1. BAT-019 batch 폴더 및 문서 생성 완료 -2. Planning 확정 -3. Design 확정 (경로·스키마·env토큰 처리) -4. REQ-044 구현 + 테스트 조정 -5. REQ-045 구현 + 신규 테스트 -6. pytest 전체 통과 -7. Verification 완료 - -## Risks - -- REQ-044: 기존 테스트 중 `require_admin` 기대 403 케이스가 다수 있어 신규 200 기대로 업데이트 필요. 누락 시 기존 테스트 깨짐. -- REQ-045: `user_id=None`(env/service 토큰) 처리를 design에서 명확히 결정해야 구현이 일관됨. -- REQ-045: `AuthService.find_candidate_daemons_for_user`가 `list_daemons_for_user`를 호출하는데, InMemoryRepository 미구현 시 서비스 레이어를 직접 mock해야 함. diff --git a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/verification.md b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/verification.md deleted file mode 100644 index 8d98b8c..0000000 --- a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/verification.md +++ /dev/null @@ -1,99 +0,0 @@ -# BAT-019 Verification - -- Status: confirmed -- Batch: BAT-019 -- Confirmed: 2026-06-27 -- PR: #16 (main 머지, HEAD `7f15c6b`) -- QC: PASS (독립 qc-reviewer) - -## Acceptance Mapping - -### REQ-044: GET /v1/assets 권한 완화 - -- AC: read 토큰 GET /v1/assets → 200 - - Evidence: `test_v1_daemons.py::test_assets_read_token_200_regression` PASSED - - Evidence: `test_v1.py::test_v1_assets_returns_asset_list` PASSED (200, total=0) - - Evidence: `test_v1.py::test_v1_assets_env_read_token_returns_200` PASSED - - Evidence: `test_v1.py::test_v1_assets_user_token` PASSED (200, total=0) - - Evidence: `test_asset_catalog.py::test_get_v1_assets_user_token_returns_200` PASSED - - Evidence: `test_asset_catalog.py::test_get_v1_assets_service_token_returns_200` PASSED - -- AC: admin GET 200(회귀) - - Evidence: `test_v1_daemons.py::test_assets_admin_get_200_regression` PASSED - - Evidence: `test_v1.py::test_v1_assets_admin_token_default` PASSED - - Evidence: `test_asset_catalog.py::test_get_v1_assets_admin_returns_catalog_response` PASSED - -- AC: status·market 필터 유지 - - Evidence: `test_v1.py::test_v1_assets_market_filter` PASSED (read 토큰 + market=KRX → 1개 반환) - - Evidence: `test_asset_catalog.py::test_get_v1_assets_market_filter` PASSED - - Evidence: `test_asset_catalog.py::test_get_v1_assets_status_filter_all_includes_inactive` PASSED - - Evidence: `test_asset_catalog.py::test_get_v1_assets_status_filter_inactive_only` PASSED - - Evidence: `test_asset_catalog.py::test_get_v1_assets_status_filter_default_active_only` PASSED - -- AC: read 토큰 쓰기 403 - - Evidence: `test_v1_daemons.py::test_assets_write_requires_admin_regression` PASSED (POST /v1/assets read → 403) - - Evidence: `test_asset_catalog.py::test_asset_http_non_admin_403` PASSED (POST /v1/assets read → 403) - -- AC: 무토큰 401 - - Evidence: `test_v1_daemons.py::test_assets_no_token_returns_401_regression` PASSED - - Evidence: `test_v1.py::test_v1_assets_requires_service_token` PASSED - - Evidence: `test_asset_catalog.py::test_get_v1_assets_no_token_returns_401` PASSED - -### REQ-045: GET /v1/daemons 신규 엔드포인트 - -- AC: 사용자A(데몬2) → 자기 2개만 - - Evidence: `test_v1_daemons.py::test_daemons_returns_caller_owned_daemons` PASSED - -- AC: 필드 4개(id, daemon_slug, display_name, is_active) 포함 - - Evidence: `test_v1_daemons.py::test_daemons_response_has_required_fields` PASSED - -- AC: 사용자B 데몬 비노출 - - Evidence: `test_v1_daemons.py::test_daemons_does_not_expose_other_user_daemons` PASSED (list_daemons_for_user("user-1") 호출 검증) - -- AC: 데몬 없으면 빈 목록 200 - - Evidence: `test_v1_daemons.py::test_daemons_empty_list_returns_200` PASSED - -- AC: 무토큰 401 - - Evidence: `test_v1_daemons.py::test_daemons_no_token_returns_401` PASSED - -- AC: admin도 caller 스코프 - - Evidence: `test_v1_daemons.py::test_daemons_admin_token_with_user_id_returns_caller_daemons` PASSED (admin user_id="admin-1" → admin-1 소유만) - - Evidence: `test_v1_daemons.py::test_daemons_admin_token_with_user_id_none_returns_empty` PASSED (env admin 토큰 user_id=None → 빈 목록) - -### Design 결정 AC 검증 - -- OQ-2 경로: GET /v1/daemons 사용 확인 (라우터 prefix="/v1", path="/daemons") -- OQ-3 스키마: DaemonListResponse{daemons: [...], total: int} 사용 확인 -- env토큰 처리: user_id=None → 빈 목록 200 (`test_daemons_admin_token_with_user_id_none_returns_empty` PASSED) -- active 필터: 소유 전체 반환, is_active로 구분 (`test_daemons_includes_inactive_daemons` PASSED) - -## Evidence - -- Test: - - 전체 실행: `pytest tests/ -v` - - 결과: **472 passed, 0 failed, 11 warnings** (2026-06-27) - - 신규 테스트: `tests/test_v1_daemons.py` 13개 전부 PASSED - - 업데이트된 테스트: `test_v1.py` 5개, `test_asset_catalog.py` 2→3개, `test_daemon_assignment.py` 2개, `test_tags_master.py` 3개 - - 기존 v1_assets 회귀: 0 (모두 PASSED) - - 기존 auth 회귀: 0 (모두 PASSED) - -``` -pytest 실행 원문 마지막 줄: -====================================================== 472 passed, 11 warnings in 24.83s ====================================================== -``` - -- Manual: - - 없음 (DB 연결 없는 환경에서 mock 기반 단위 테스트로 모든 AC 커버) - -- Logs: - - 없음 - -## Result - -- Ready for Release: true -- Blocking Issues: 없음 -- Verification Outcome: PASS - - 모든 AC 충족, 472 passed / 0 failed (2026-06-27) - - QC 독립 검증 PASS - - PR #16 main 머지 완료 (HEAD `7f15c6b`) - - DB 통합 테스트는 로컬 인프라 필요(비활성 환경) — mock 기반 커버로 대체 (비차단) diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/design.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/design.md deleted file mode 100644 index d0829b1..0000000 --- a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/design.md +++ /dev/null @@ -1,89 +0,0 @@ -# BAT-020 Design - -## Design 위임 2건 확정 - -### D-1: REQ-048 백필 구현방식 — CLI subcommand vs alembic data migration revision - -**결정: CLI subcommand (`pfhub credential backfill-daemon-id`)** - -근거: -1. **운영자 제공 매핑 필요**: 어떤 user가 어떤 daemon_id에 매핑되는가는 인프라/운영 도메인 지식이 필요하며 자동화 불가 (REQ-048 Notes). alembic data migration revision은 매핑을 하드코딩하거나 env 변수에서 읽어야 해 유연성이 낮다. -2. **--dry-run 필수 (REQ-048 AC-3)**: CLI subcommand는 typer option으로 dry-run을 자연스럽게 표현하고, 운영자가 실행 전 확인 후 적용 가능. alembic revision에 dry-run을 구현하면 비표준 패턴이 된다. -3. **기존 CLI 패턴과 일관성**: 현재 `pfhub credential set/list/remove`가 이미 CLI로 구현돼 있으며, `--user` → user_id 변환 패턴을 그대로 활용 가능 (`get_user_by_username`). -4. **반복 실행 가능**: 1회성 운영 작업이지만 개발 환경에서 반복 실행 가능하도록 CLI가 더 적합. alembic은 이미 적용된 revision을 재실행하지 않아 개발 테스트에 불편. - -구현: `pfhub credential backfill-daemon-id --user --daemon-id [--dry-run] [--force]` - ---- - -### D-2: REQ-050 --daemon-id 입력 타입 + 과도기 정책 - -**결정 (1): `--daemon-id`는 daemon_slug를 받는다 (UUID도 허용, 내부에서 해석)** - -근거: -- 기존 `daemons` 테이블에 `daemon_slug` (TEXT, UNIQUE per owner)와 `id` (UUID TEXT) 두 식별자가 있다. -- 기존 CLI 패턴: `--user`가 username(사람이 읽기 쉬운 식별자)을 받아 내부에서 `get_user_by_username()`으로 user_id 변환. 이 패턴과 동일하게 `--daemon-id`도 daemon_slug를 우선 받는다. -- `get_daemon_by_slug(owner_user_id, daemon_slug)` 메서드가 이미 존재함 (postgres.py:2204). `--user`로 먼저 owner user를 조회하고, 그 user_id + daemon_slug로 daemon을 조회한다. -- UUID 형식이면 `get_daemon_by_id()`로도 조회 시도 가능하나, 이 batch에서는 **daemon_slug 우선 + user 소유 검증**으로 단순화한다. UUID 직접 지정은 미지원(오운 user에서 slug로 찾지 못하면 에러). -- 입력 파라미터 이름은 `--daemon-id`로 유지(REQ-050 명세)하되, 값은 daemon_slug로 해석. - -**결정 (2): REQ-047 과도기(daemon_id nullable 구간)의 --daemon-id 필수/선택 정책** - -- `pfhub credential set` — REQ-047 Step1 이후(nullable 구간): `--daemon-id` 미제공 시에도 upsert 허용 (기존 user-scoped 동작 유지). **단, 경고 메시지 출력**: "daemon_id 없이 등록된 인증정보는 REQ-047 Step2 PK 전환 후 backfill이 필요합니다." - - 이유: 단계적 마이그레이션 과도기에 운영자가 기존 방식으로도 인증정보를 등록할 수 있어야 함. Step2 PK 전환 이후에는 daemon_id NOT NULL 제약이 DB 레벨에서 강제된다. - - REQ-050 AC-6 ("--daemon-id 미제공 시 exit 1")은 REQ-047 Step2 PK 전환 완료 이후 상태 기준. 이 batch에서는 미제공 시 경고만 출력하고 허용. (AC-6은 Step2 이후 환경에서 DB 제약으로 자동 강제됨) -- `pfhub credential remove` — `--daemon-id` 제공 시 (daemon_id, broker) 기반 삭제, 미제공 시 기존 (user_id, broker) 기반 삭제 (하위 호환). -- `pfhub credential list` — `--daemon-id` 제공 시 해당 daemon_id 필터, 미제공 시 user 전체 (기존 동작). - ---- - -## 주요 설계 결정 - -### DS-1: UserInfoResponse.brokers → broker 단일화 (REQ-049) - -- `brokers: list[BrokerItem]` 필드 제거, `broker: BrokerItem | None = None` 추가 -- 기존 소비자(trex-core hub_read.whoami)가 `daemon_id`만 읽고 `brokers` 미참조 → 파괴적 변경이나 실질 영향 없음 (dcy-010 OQ-2) -- 이 batch의 기존 테스트 중 `body["brokers"]`를 참조하는 테스트는 `body["broker"]`로 갱신 필요 - -### DS-2: daemon_id 없음 → 403 (REQ-049, REQ-051) - -- `auth.daemon_id is None`이면 HTTP 403 Forbidden 반환 -- 기존 `_require_service_token()` 함수는 token_type 검사만 함 → daemon_id 검사는 엔드포인트 핸들러 초입에서 추가 - -### DS-3: graceful degrade 계층 (REQ-049, REQ-051) - -- `list_broker_credentials` DB 오류 → except → `broker=None` 반환 (기존 패턴 유지, 확장) -- 복호화 실패 → `credential_payload=None`인 BrokerItem 반환 (기존 패턴 유지) -- 어느 단계에서도 `app_key`, `app_secret` 값은 로그에 출력하지 않음 - -### DS-4: _row_to_broker_credential 하위호환 (REQ-046 AC-4) - -- 기존 DB(daemon_id 컬럼 없음): 6컬럼 행 (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at) -- REQ-047 Step1 이후: 7컬럼 행 (위 + daemon_id) -- `_row_to_broker_credential(row)` — `len(row) > 6`이면 `row[6]`을 daemon_id로 읽고, 없으면 None. 단, SELECT 쿼리에서 daemon_id를 명시적으로 포함하는 것이 더 안전. 따라서 쿼리에 daemon_id 컬럼을 추가하되, 컬럼 미존재 시 에러가 나지 않도록 Step1 revision 이후에만 활성화. - - 실용적 접근: 현재 `_SELECT_*` 상수 방식이 아니라 inline SQL이므로, `list_broker_credentials`에서 daemon_id를 선택적으로 포함하는 것이 복잡. 대신 `_row_to_broker_credential`에서 인덱스 기반 조회 시 `len(row) > 6`으로 분기. - - 단, `list_broker_credentials` 쿼리에 daemon_id 컬럼이 없으면 row에 포함되지 않아 항상 6컬럼 → daemon_id 항상 None. 이는 Step1 이전에는 올바른 동작이나 Step1 이후엔 정보 손실. - - **최종 결정**: `list_broker_credentials` 및 `upsert_broker_credential` 쿼리에 `daemon_id` 컬럼을 포함하고, `_row_to_broker_credential`이 7컬럼 행을 처리. 단 ensure_schema의 broker_credentials CREATE TABLE은 수정하지 않음 (Step2 이후 ensure_schema 갱신은 별도 주석). - -### DS-5: upsert_broker_credential ON CONFLICT 처리 (REQ-046 Notes) - -- REQ-047 Step1 이후 daemon_id 컬럼 존재하나 PK는 여전히 (user_id, broker) → ON CONFLICT 대상 불변 -- REQ-047 Step2 이후 PK가 (daemon_id, broker)로 변경 → ON CONFLICT 절이 달라짐 -- 이 batch에서는 Step2 이후 ON CONFLICT 절은 TODO 주석으로 표시하고, 현재는 기존 (user_id, broker) ON CONFLICT 유지 + daemon_id INSERT 컬럼 추가 - -### DS-6: backfill CLI `get_daemon` 조회 방식 (REQ-048) - -- `--daemon-id `: daemon_slug로 먼저 시도 (`get_daemon_by_slug(owner_user_id, value)`), 실패 시 `get_daemon_by_id(value)` 재시도 - - `--user `으로 user_id를 먼저 조회한 후 get_daemon_by_slug 호출 - - 단, backfill은 user-scoped 인증정보를 특정 daemon에 할당하는 작업이므로 `--user`와 `--daemon-id`를 함께 받음 - -## 기존 테스트 영향 분석 - -| 테스트 | 영향 | 조치 | -| --- | --- | --- | -| `test_v1.py::test_v1_user_info_returns_user` | `body["brokers"] == []` 참조 | `body["broker"]` is None 으로 변경 | -| `test_v1.py::test_v1_user_info_returns_decrypted_broker_credentials` | `body["brokers"]` 리스트 참조 | `body["broker"]` 단일 필드 검증으로 변경 | -| `test_v1.py::test_v1_user_info_degrades_when_broker_credentials_lookup_fails` | `body["brokers"] == []` 참조 | `body["broker"]` is None 으로 변경 | -| `test_cli.py::FakeRepo.upsert_broker_credential` | daemon_id 파라미터 없음 | 시그니처에 daemon_id 추가 | -| `test_cli.py::FakeRepo.list_broker_credentials` | daemon_id 필터 없음 | daemon_id 필터 추가 | -| `test_cli.py::FakeRepo.delete_broker_credential` | daemon_id 파라미터 없음 | 시그니처에 daemon_id 추가 | diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/implementation.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/implementation.md deleted file mode 100644 index b16fcb3..0000000 --- a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/implementation.md +++ /dev/null @@ -1,30 +0,0 @@ -# BAT-020 Implementation - -## 변경 파일 요약 - -| 파일 | 변경 | REQ | -| --- | --- | --- | -| `app/domain/models.py` | BrokerCredential.daemon_id: str | None = None 추가 | REQ-046 | -| `app/repositories/postgres.py` | _row_to_broker_credential 7컬럼 지원, upsert/list/delete daemon_id 파라미터, update_broker_credential_daemon_id 메서드 신규 | REQ-046, REQ-048 | -| `app/schemas/v1.py` | UserInfoResponse: brokers list 제거, broker: BrokerItem | None = None 추가 | REQ-049 | -| `app/api/v1_user_info.py` | daemon_id=None → 403, daemon_id 기반 단일 broker 조회, 로그 개선(app_secret 미노출) | REQ-049, REQ-051 | -| `app/cli/commands/credential.py` | set/list/remove --daemon-id 옵션, backfill-daemon-id subcommand | REQ-048, REQ-050 | -| `migrations/versions/20260628_0026_*.py` | broker_credentials daemon_id TEXT NULL 컬럼 추가 (Step1) | REQ-047 | -| `migrations/versions/20260628_0027_*.py` | broker_credentials PK 전환 (Step2, 사장님 결재 후 적용) | REQ-047 | -| `tests/test_bat020_broker_credential_domain.py` | REQ-046 AC-1~6 단위 테스트 신규 | REQ-046 | -| `tests/test_bat020_user_info_daemon_scoped.py` | REQ-049/051 AC 전체 테스트 신규 | REQ-049, REQ-051 | -| `tests/test_bat020_credential_cli.py` | REQ-048/050 AC 전체 테스트 신규 | REQ-048, REQ-050 | -| `tests/test_v1.py` | brokers→broker 반영, daemon_id=None → 403 반영, 기존 테스트 갱신 | REQ-049 | -| `tests/test_cli.py` | FakeRepo 시그니처 갱신, credential 플로우 테스트 --daemon-id 포함 | REQ-050 | -| `tests/test_daemon_assignment.py` | daemon_id=None → 403 정책 갱신 | REQ-049 | - -## Design 위임 확정 결과 - -### D-1: REQ-048 구현방식 → CLI subcommand 선택 -- `pfhub credential backfill-daemon-id --user --daemon-id [--dry-run] [--force] [--broker ]` -- 근거: 운영자 제공 매핑 필요, --dry-run 자연스러운 구현, 기존 CLI 패턴과 일관성 - -### D-2: REQ-050 --daemon-id 입력 타입 + 과도기 정책 -- daemon_slug 우선 (`get_daemon_by_slug(owner_user_id, slug)`) + UUID 폴백 (`get_daemon_by_id`) -- 과도기(Step1 완료, Step2 미완료): `--daemon-id` 미제공 시 경고 출력 + 허용 -- Step2 PK 전환 후: DB NOT NULL 제약으로 자동 강제 diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/index.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/index.md deleted file mode 100644 index bab8899..0000000 --- a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/index.md +++ /dev/null @@ -1,45 +0,0 @@ -# BAT-020 — daemon-scoped 브로커 인증정보 - -- Batch ID: BAT-020 -- Profile: standard -- Status: release-candidate -- Discovery: dcy-010 -- Included REQ: REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 -- Folder: docs/batches/bat-020_20260628_daemon-scoped-broker-credential/ -- Created: 2026-06-28 - -## Summary -dcy-010(daemon-scoped 브로커 인증정보 소유·전달)을 portfolio-hub에 구현한다. -BrokerCredential 도메인 모델에 daemon_id 필드 추가, broker_credentials 테이블 PK 전환 마이그레이션(Step1/Step2), 백필 CLI, GET /v1/user_info daemon-scoped 단일 broker 전달, CLI --daemon-id 옵션, 보안 격리 검증. - -## Included REQ - -| REQ | 제목 | Type | 의존 | -| --- | --- | --- | --- | -| REQ-046 | BrokerCredential daemon_id 필드 추가 | Data | 없음 | -| REQ-047 | broker_credentials PK 전환 마이그레이션 | Migration | REQ-046 | -| REQ-048 | 기존 user-scoped 인증정보 daemon_id 백필 | Migration | REQ-047 Step1 | -| REQ-049 | GET /v1/user_info daemon-scoped 단일 broker 전달 | Interface | REQ-046, REQ-047 | -| REQ-050 | credential CLI --daemon-id 옵션 추가 | Interface | REQ-046, REQ-047 | -| REQ-051 | 브로커 인증정보 보안 격리 | Non-Functional | REQ-049 | - -## DoR (진입 조건) -- [x] 6개 REQ 전건 Approved 상태 확인 -- [x] dcy-010 confirmed Discovery 확인 -- [x] 구현 대상 파일 존재 확인(app/domain/models.py, app/repositories/postgres.py, app/api/v1_user_info.py, app/schemas/v1.py, app/cli/commands/credential.py, migrations/versions/) -- [x] design 위임 2건 식별 완료(REQ-048 구현방식, REQ-050 --daemon-id 입력 타입·과도기 정책) - -## DoD (완료 조건) -- [x] REQ-046~051 전 AC 테스트 통과 -- [x] alembic revision Step1/Step2 작성 완료(dev DB 검증) -- [x] 기존 테스트 회귀 없음 -- [x] draft-batch-verification 문서에 evidence 기록 - -## 비가역 결재 게이트 -REQ-047 Step2(PK 전환) 실DB 적용은 사장님 결재 대상. 이 batch에서는 revision 코드 작성 + dev/test DB 검증까지만 수행. - -## Notes -- 머지 완료: PR #17, mergeCommit d4acfd4 (2026-06-28) -- QC: PASS-WITH-NOTES (opus, blocker 0) -- confirm-req-implemented: REQ-046/048/049/050/051 → Implemented. REQ-047은 Approved 유지. -- REQ-047 잔여: 실 운영 DB Step1/Step2 적용은 사장님 결재 게이트 대기. 적용 시 ensure_schema()·upsert ON CONFLICT를 (daemon_id, broker)로 갱신 필요. diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/planning.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/planning.md deleted file mode 100644 index 13402b8..0000000 --- a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/planning.md +++ /dev/null @@ -1,73 +0,0 @@ -# BAT-020 Planning - -## 구현 의존 순서 - -1. **REQ-046** — BrokerCredential 도메인 모델 + repository 시그니처 변경 (daemon_id 필드) -2. **REQ-047 Step1** — alembic revision: broker_credentials에 daemon_id TEXT NULL 컬럼 추가 -3. **REQ-048** — backfill-daemon-id CLI subcommand 추가 -4. **REQ-047 Step2** — alembic revision: PK (user_id,broker) → (daemon_id,broker) 전환 (dev/test DB만) -5. **REQ-049** — GET /v1/user_info daemon-scoped 단일 broker 전달 (스키마 + 엔드포인트) -6. **REQ-050** — credential CLI --daemon-id 옵션 추가 (set/list/remove) -7. **REQ-051** — 보안 격리 테스트: service-token 전용, web 토큰 차단, 로그 비노출 검증 - -## Task Breakdown - -### T-1: REQ-046 모델·repository 변경 -- `app/domain/models.py` — `BrokerCredential.daemon_id: str | None = None` 추가 -- `app/repositories/postgres.py` — `_row_to_broker_credential` daemon_id 처리 (7컬럼/6컬럼 하위호환) -- `app/repositories/postgres.py` — `upsert_broker_credential(daemon_id=None)` 파라미터 추가 -- `app/repositories/postgres.py` — `list_broker_credentials(daemon_id=None)` 파라미터 + 필터 추가 -- `app/repositories/postgres.py` — `delete_broker_credential(daemon_id=None)` 파라미터 추가 -- Tests: REQ-046 AC-1~6 단위 테스트 신규 - -### T-2: REQ-047 Step1 alembic revision -- `migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py` -- 내용: `broker_credentials`에 `daemon_id TEXT NULL` 컬럼 추가 + downgrade(컬럼 제거, PK 복원) -- Tests: AC-1 (컬럼 존재), AC-2 (기존 데이터 NULL 보존), AC-6 (downgrade) - -### T-3: REQ-048 backfill CLI -- `app/cli/commands/credential.py` — `backfill-daemon-id` subcommand 추가 -- Tests: AC-1~6 단위 테스트 (mock repo 기반) - -### T-4: REQ-047 Step2 alembic revision -- `migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py` -- 내용: PK 전환 (user_id,broker) → (daemon_id,broker), daemon_id NOT NULL 제약, downgrade 안전장치 -- `app/repositories/postgres.py` — `ensure_schema()` broker_credentials 정의 최종 스키마로 갱신 (Step2 이후 활성화 주석) -- Tests: AC-3~5 (dev DB에서 Step1→백필→Step2 순서 검증), AC-7 (downgrade 안전장치) - -### T-5: REQ-049 user_info daemon-scoped 확장 -- `app/schemas/v1.py` — `UserInfoResponse.broker: BrokerItem | None = None`, `brokers: list` 제거 -- `app/api/v1_user_info.py` — daemon_id 없으면 403, daemon_id 기반 list_broker_credentials 호출, 단일 broker 반환 -- Tests: REQ-049 AC-1~9 (daemon_id 있음/없음, broker 있음/없음, 2데몬 격리, 복호화 실패, 회귀) - -### T-6: REQ-050 credential CLI --daemon-id -- `app/cli/commands/credential.py` — set/list/remove 에 `--daemon-id` 옵션 추가 -- `tests/test_cli.py` FakeRepo — upsert/list/delete에 daemon_id 파라미터 반영 -- Tests: REQ-050 AC-1~7 - -### T-7: REQ-051 보안 격리 테스트 -- web 토큰 403 (AC-1), 비인증 401 (AC-2), daemon_id=None 403 (AC-3), 타데몬 null (AC-4) -- 타 엔드포인트 credential 미노출 (AC-5) -- DB 오류 graceful degrade (AC-6), 복호화 실패 degrade (AC-7) -- 로그 비노출 (AC-8, caplog fixture) - -## 파일 변경 대상 요약 - -| 파일 | 변경 내용 | -| --- | --- | -| `app/domain/models.py` | BrokerCredential.daemon_id 추가 | -| `app/repositories/postgres.py` | _row_to_broker_credential, upsert/list/delete 시그니처, ensure_schema broker_credentials | -| `app/schemas/v1.py` | UserInfoResponse brokers→broker 단일화 | -| `app/api/v1_user_info.py` | daemon-scoped 조회, daemon_id 없음 403, 로그 비노출 확인 | -| `app/cli/commands/credential.py` | set/list/remove --daemon-id, backfill-daemon-id subcommand | -| `migrations/versions/20260628_0026_*.py` | Step1 revision | -| `migrations/versions/20260628_0027_*.py` | Step2 revision | -| `tests/test_v1.py` | user_info 테스트 갱신 + 신규(daemon-scoped, 보안) | -| `tests/test_cli.py` | credential CLI 테스트 갱신 + 신규 | -| `tests/test_bat020_broker_credential_domain.py` | REQ-046 단위 테스트 신규 | -| `tests/test_bat020_broker_credential_migration.py` | REQ-047 마이그레이션 테스트 신규 | - -## 위험 -- REQ-049: `brokers` → `broker` 응답 변경으로 기존 `test_v1.py`의 `brokers` 필드 참조 테스트가 실패할 수 있음 → 갱신 필수 -- REQ-047 Step2: 실 DB 적용 금지(게이트). dev/test DB에서만 검증. -- `get_daemon_by_slug`은 `owner_user_id` + `daemon_slug` 조합으로 조회 → REQ-050에서 daemon_slug로 받으면 user 조회 먼저 필요 diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/verification.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/verification.md deleted file mode 100644 index 89e0592..0000000 --- a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/verification.md +++ /dev/null @@ -1,116 +0,0 @@ -# BAT-020 Verification - -## 실행 결과 - -``` -python3 -m pytest tests/ -v -509 passed, 0 failed, 11 warnings in 26.39s -``` - -신규 테스트: -- tests/test_bat020_broker_credential_domain.py: 6 passed -- tests/test_bat020_user_info_daemon_scoped.py: 13 passed -- tests/test_bat020_credential_cli.py: 11 passed - -기존 테스트 회귀: 0건 - -## AC 커버리지 - -### REQ-046: BrokerCredential daemon_id 필드 추가 - -| AC | 테스트 | 결과 | -| --- | --- | --- | -| AC-1: daemon_id=None 기본값 | test_broker_credential_daemon_id_defaults_to_none | PASS | -| AC-2: daemon_id 값 지정 | test_broker_credential_daemon_id_set | PASS | -| AC-3: 7컬럼 행 변환 daemon_id 세팅 | test_row_to_broker_credential_with_daemon_id | PASS | -| AC-4: 6컬럼 행 변환 daemon_id=None (하위호환) | test_row_to_broker_credential_without_daemon_id | PASS | -| AC-5: daemon_id 필터 시 해당 행만 반환 | test_list_broker_credentials_daemon_id_filter | PASS | -| AC-6: daemon_id 미지정 시 전체 반환 | test_list_broker_credentials_no_daemon_id_filter_returns_all | PASS | - -### REQ-047: broker_credentials PK 전환 마이그레이션 - -| AC | 검증 방법 | 결과 | -| --- | --- | --- | -| AC-1: Step1 후 daemon_id TEXT NULL 컬럼 존재 | alembic revision 코드 확인 + ADD COLUMN 명시 | 코드 검증 완료 | -| AC-2: Step1 후 기존 데이터 daemon_id=NULL 보존 | 컬럼 추가 시 기존 행 NULL 유지 (DB 동작) | 코드 검증 완료 | -| AC-3: Step2 후 PK (daemon_id, broker) 전환 | revision 코드 + 사전 검증 로직 확인 | 코드 검증 완료 | -| AC-4: user_id 컬럼 유지 | Step2 revision에서 user_id 삭제 없음 | 코드 검증 완료 | -| AC-5: ensure_schema Step2 이후 정합 | ensure_schema broker_credentials TODO 주석 | (Step2 이후 활성화 예정) | -| AC-6: Step1 downgrade 동작 | downgrade 함수에서 컬럼/인덱스 제거 명시 | 코드 검증 완료 | -| AC-7: Step2 downgrade 안전장치 | 중복 (user_id,broker) 시 RuntimeError 발생 | 코드 검증 완료 | - -NOTE: REQ-047 마이그레이션은 dev/test DB 검증 대상이나 실제 DB 인프라 미연결 상태. 코드 수준 검증. - -### REQ-048: daemon_id 백필 CLI - -| AC | 테스트 | 결과 | -| --- | --- | --- | -| AC-1: daemon_id IS NULL 행 UPDATE | test_backfill_daemon_id_updates_null_rows | PASS | -| AC-2: 백필 후 NULL 0건 | test_backfill_daemon_id_updates_null_rows | PASS | -| AC-3: --dry-run DB 변경 없음 | test_backfill_daemon_id_dry_run_no_change | PASS | -| AC-4: encrypted_payload 동등 보존 | test_backfill_preserves_encrypted_payload | PASS | -| AC-5: NULL 없으면 "이미 완료됨" | test_backfill_daemon_id_already_complete | PASS | -| AC-6: 존재하지 않는 user → exit 1 | test_backfill_daemon_id_nonexistent_user_exits_1 | PASS | -| AC-7: Step2 적용 가능 상태 검증 | (Step2 revision 사전 검증 코드에서 NULL=0 확인) | 코드 검증 완료 | - -### REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달 - -| AC | 테스트 | 결과 | -| --- | --- | --- | -| AC-1: daemon_id 있음 + broker 있음 → 단일 BrokerItem | test_user_info_daemon_scoped_broker_returned | PASS | -| AC-2: daemon_id 있음 + broker 없음 → broker=null | test_user_info_daemon_no_broker_returns_null | PASS | -| AC-3: daemon_id=None → 403 | test_user_info_no_daemon_id_returns_403 | PASS | -| AC-4: daemon A → daemon A account_ref | test_user_info_daemon_isolation_two_daemons | PASS | -| AC-5: daemon B → daemon B account_ref (격리) | test_user_info_daemon_isolation_two_daemons | PASS | -| AC-6: broker 단일 키, brokers 키 없음 | test_user_info_response_has_broker_not_brokers | PASS | -| AC-7: 복호화 실패 → broker.credential_payload=null | test_user_info_decryption_failure_returns_null_payload | PASS | -| AC-8: service 아닌 token → 403 | test_user_info_web_token_returns_403 | PASS | -| AC-9: 기존 필드 회귀 없음 | test_user_info_regression_existing_fields_unchanged | PASS | - -### REQ-050: credential CLI --daemon-id 옵션 - -| AC | 테스트 | 결과 | -| --- | --- | --- | -| AC-1: set --daemon-id → daemon_id 포함 upsert | test_credential_set_with_daemon_id | PASS | -| AC-2: list --daemon-id → 해당 daemon만 출력 | test_credential_list_daemon_id_filter | PASS | -| AC-3: list daemon-id 미지정 → 전체 | test_credential_list_no_daemon_id_returns_all | PASS | -| AC-4: remove --daemon-id → (daemon_id,broker) 삭제 | test_credential_remove_with_daemon_id | PASS | -| AC-5: 존재하지 않는 daemon → exit 1 | test_credential_set_nonexistent_daemon_exits_1 | PASS | -| AC-6: --daemon-id 미제공 시 경고 출력 + 허용 (과도기 정책) | test_cli_credential_set_list_and_remove_flow 갱신 | PASS | -| AC-7: 출력에 daemon_id 포함 | test_credential_set_output_includes_daemon_id | PASS | - -NOTE: AC-6은 design 결정에 따라 Step2 PK 전환 이전 과도기에는 경고만 출력하고 허용. REQ-047 Step2 후 DB 레벨 NOT NULL 제약으로 자동 강제. - -### REQ-051: 보안 격리 - -| AC | 테스트 | 결과 | -| --- | --- | --- | -| AC-1: web 토큰 → 403 | test_user_info_web_token_returns_403 | PASS | -| AC-2: 비인증 → 401 | test_user_info_unauthenticated_returns_401 | PASS | -| AC-3: daemon_id=None → 403 | test_user_info_no_daemon_id_returns_403 | PASS | -| AC-4: 다른 daemon 인증정보 → null | test_user_info_different_daemon_credential_not_returned | PASS | -| AC-5: 타 엔드포인트 credential 미노출 | test_other_endpoints_do_not_expose_credential_payload | PASS | -| AC-6: DB 오류 → broker=null (500 없음) | test_user_info_db_error_returns_null_broker | PASS | -| AC-7: 복호화 실패 → credential_payload=null | test_user_info_decryption_failure_returns_null_payload | PASS | -| AC-8: 로그에 app_secret 미노출 | test_user_info_decryption_failure_log_does_not_expose_secret | PASS | - -## 기존 테스트 회귀 분석 - -갱신한 기존 테스트: -- `tests/test_v1.py`: brokers→broker 응답 변경 반영, daemon_id=None → 403 반영 -- `tests/test_cli.py`: FakeRepo 시그니처 갱신(daemon_id 추가), credential 플로우 테스트 --daemon-id 포함 -- `tests/test_daemon_assignment.py`: daemon_id=None → 200 기대를 403으로 갱신 (REQ-049 정책 변경) - -## 비가역 결재 게이트 - -REQ-047 Step2(PK 전환) revision 코드가 작성되었으나 실 운영 DB 적용은 사장님 결재 게이트 대상. -이 batch에서는 revision 코드만 작성. 적용 순서: -1. `alembic upgrade 20260628_0026` (Step1 - nullable daemon_id 추가) -2. `pfhub credential backfill-daemon-id --user --daemon-id ` (REQ-048) -3. `alembic upgrade 20260628_0027` (Step2 - PK 전환, 사장님 결재 후) - -## 미해결/위험 사항 - -1. **ensure_schema() broker_credentials 정의 미갱신**: Step2 PK 전환 이후 `ensure_schema()` 내 `broker_credentials` CREATE TABLE은 여전히 구 스키마(PK=(user_id,broker)). Step2 결재·적용 후 별도 갱신 필요. 현재 코드에 TODO 주석 없음 — 팀장에게 Step2 적용 시 함께 갱신 요청 필요. -2. **upsert ON CONFLICT 절**: REQ-047 Step2 이후 PK가 (daemon_id,broker)로 변경되면 `upsert_broker_credential`의 `ON CONFLICT (user_id, broker)` 절이 맞지 않음. Step2 결재·적용 시 해당 쿼리도 함께 갱신 필요. -3. **REQ-047 Step2 dev DB 통합 검증**: 실제 PostgreSQL에서 Step1→backfill→Step2 순서 검증을 완료하지 못했음(DB 인프라 미연결). 코드 수준 검증만 완료. diff --git a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/design.md b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/design.md deleted file mode 100644 index c4103ef..0000000 --- a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/design.md +++ /dev/null @@ -1,135 +0,0 @@ -# BAT-021 Design - -## 변경 설계 - -### T-1/T-2/T-3: `app/repositories/postgres.py` 수정 - -#### ensure_schema() broker_credentials (T-1) - -현재 `ensure_schema()`의 `broker_credentials` CREATE TABLE은: -```sql -CREATE TABLE IF NOT EXISTS broker_credentials ( - user_id TEXT NOT NULL, - broker TEXT NOT NULL, - account_ref TEXT NOT NULL, - encrypted_payload TEXT NOT NULL, - credential_version INTEGER NOT NULL DEFAULT 1, - last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id, broker) -) -``` - -Step2 이후 최종 스키마: -- `daemon_id TEXT NOT NULL` 추가 -- `PK = (daemon_id, broker)` -- `user_id` 컬럼 유지 (NOT NULL — 신규 INSERT는 항상 user_id 포함) - -갱신 후: -```sql -CREATE TABLE IF NOT EXISTS broker_credentials ( - daemon_id TEXT NOT NULL, - broker TEXT NOT NULL, - user_id TEXT NOT NULL, - account_ref TEXT NOT NULL, - encrypted_payload TEXT NOT NULL, - credential_version INTEGER NOT NULL DEFAULT 1, - last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (daemon_id, broker) -) -``` - -`ensure_schema()`는 `IF NOT EXISTS`를 사용하므로 테이블이 이미 존재하면 CREATE를 스킵한다. -Step2 마이그레이션이 적용된 DB에서는 이 정의와 실제 스키마가 정합된다. -Step1만 적용된 DB(Step2 미적용)에서 `ensure_schema()`를 호출하면 테이블은 이미 존재하므로 스킵되며 영향 없다. - -Step2 이후 추가될 인덱스: -```sql -CREATE INDEX IF NOT EXISTS idx_broker_credentials_daemon_broker -ON broker_credentials (daemon_id, broker) -``` -이 인덱스는 PK 자체이므로 별도 인덱스는 불필요. Step1에서 만든 `idx_broker_credentials_daemon_id` 인덱스는 그대로 유지. - -#### upsert_broker_credential (T-2) - -현재: -- Primary INSERT: `ON CONFLICT (user_id, broker)` — Step2 이후 PK가 바뀌어 동작 안 함 -- Fallback: `ON CONFLICT (user_id, broker)` without daemon_id — Step1 미적용 환경용 - -Step2 이후 세계에서: -- daemon_id는 항상 NOT NULL (Step2 PK 제약) -- ON CONFLICT는 `(daemon_id, broker)` 로 변경 -- Fallback 불필요 → 제거 - -단, BAT-020의 fallback try/except는 `psycopg` 트랜잭션 abort 이슈가 있었다: -트랜잭션이 abort 상태에서 fallback 쿼리를 실행하면 `InFailedSqlTransaction` 오류가 발생. -이 batch에서 fallback을 제거하면서 이 이슈도 동시에 해소된다. - -갱신 후 `upsert_broker_credential`: -```python -cur.execute( - """ - INSERT INTO broker_credentials (daemon_id, user_id, broker, account_ref, encrypted_payload) - VALUES (%s, %s, %s, %s, %s) - ON CONFLICT (daemon_id, broker) DO UPDATE - SET account_ref = EXCLUDED.account_ref, - encrypted_payload = EXCLUDED.encrypted_payload, - user_id = EXCLUDED.user_id, - credential_version = broker_credentials.credential_version + 1, - last_rotated_at = NOW() - RETURNING daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at - """, - (daemon_id, user_id, broker, account_ref, encrypted_payload), -) -``` - -RETURNING 절에서 `daemon_id` 컬럼 순서 변경: `_row_to_broker_credential`의 현재 row 매핑 확인 필요. - -현재 `_row_to_broker_credential`: -```python -return BrokerCredential( - user_id=row[0], # user_id - broker=row[1], # broker - account_ref=row[2], - encrypted_payload=row[3], - credential_version=int(row[4]), - last_rotated_at=row[5], - daemon_id=row[6], # optional -) -``` - -RETURNING 컬럼 순서를 유지해야 한다. 새 RETURNING: -`daemon_id(0), user_id(1), broker(2), account_ref(3), encrypted_payload(4), credential_version(5), last_rotated_at(6)` -→ `_row_to_broker_credential` 매핑도 함께 수정 필요. - -`list_broker_credentials`도 같은 방향으로 정렬: -`SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at` - -`_row_to_broker_credential` 갱신: -```python -return BrokerCredential( - daemon_id=row[0], - user_id=row[1], - broker=row[2], - account_ref=row[3], - encrypted_payload=row[4], - credential_version=int(row[5]), - last_rotated_at=row[6], -) -``` - -#### list_broker_credentials fallback 제거 (T-3) - -Step2 이후 daemon_id 컬럼이 항상 존재하므로 try/except fallback 제거. - -### 영향 범위 - -- `app/repositories/postgres.py` — ensure_schema, upsert_broker_credential, list_broker_credentials, _row_to_broker_credential -- `tests/` — bat021 신규 테스트, 기존 FakeRepo 의존 테스트 영향 없음 (FakeRepo는 실제 DB 쿼리 미사용) - -### 하위 호환성 - -Step2 PK 전환 이후에만 이 코드가 올바르게 동작한다. -Step2 미적용 DB에서는 `ON CONFLICT (daemon_id, broker)` 제약이 없어 오류 발생 가능. -런북에서 Step2 적용 이전에 새 코드를 배포하지 말 것을 명시한다. - -배포 순서: Step1 → backfill → Step2 → 새 코드 배포. diff --git a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/implementation.md b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/implementation.md deleted file mode 100644 index a618a31..0000000 --- a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/implementation.md +++ /dev/null @@ -1,60 +0,0 @@ -# BAT-021 Implementation - -## 변경 파일 - -### `app/repositories/postgres.py` - -**T-1: ensure_schema() broker_credentials CREATE TABLE 갱신** - -구 정의 (Step1 이전): -```sql -PRIMARY KEY (user_id, broker) --- daemon_id 없음 -``` - -신 정의 (Step2 이후): -```sql -daemon_id TEXT NOT NULL, -broker TEXT NOT NULL, -user_id TEXT NOT NULL, -account_ref TEXT NOT NULL, -encrypted_payload TEXT NOT NULL, -credential_version INTEGER NOT NULL DEFAULT 1, -last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -PRIMARY KEY (daemon_id, broker) -``` - -**T-2: `_row_to_broker_credential` Step2 컬럼 순서 적용** - -구 매핑: `row[0]=user_id, row[1]=broker, ..., row[6]=daemon_id` - -신 매핑: `row[0]=daemon_id, row[1]=user_id, row[2]=broker, row[3]=account_ref, row[4]=encrypted_payload, row[5]=credential_version, row[6]=last_rotated_at` - -**T-3: `upsert_broker_credential` ON CONFLICT 수정 + fallback 제거** - -- `ON CONFLICT (user_id, broker)` → `ON CONFLICT (daemon_id, broker)` -- `RETURNING` 컬럼 순서: `daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at` -- `try/except` fallback 완전 제거 (트랜잭션 abort 이슈 동시 해소) - -**T-4: `list_broker_credentials` fallback 제거 + SELECT 컬럼 순서 수정** - -- `try/except` fallback 완전 제거 -- `SELECT daemon_id, user_id, broker, ...` 순서로 변경 - -### `tests/test_bat020_broker_credential_domain.py` - -- `test_row_to_broker_credential_with_daemon_id`: 행 순서를 Step2 컬럼 순서로 수정 (AC-3 갱신) -- `test_row_to_broker_credential_without_daemon_id`: Step2 5컬럼 기본값 테스트로 갱신 (AC-4 갱신) -- `_make_rows_for_user`: Step2 컬럼 순서로 갱신 - -### 신규 파일 - -- `tests/test_bat021_step2_ensure_schema_and_upsert.py`: 12개 신규 테스트 -- `docs/runbooks/bat-021-req047-step2-migration-runbook.md` -- `docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/` (index, planning, design, implementation, verification) - -## 테스트 count (구현 후) - -- 기존: 509 passed -- 신규: 12 추가 (test_bat021_step2_ensure_schema_and_upsert.py) -- 합계: 521 passed diff --git a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/index.md b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/index.md deleted file mode 100644 index 213cade..0000000 --- a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/index.md +++ /dev/null @@ -1,33 +0,0 @@ -# BAT-021 Index - -- Profile: standard -- Status: release-candidate -- Included REQ: REQ-047 -- Discovery: dcy-010 -- Created: 2026-06-28 - -## Scope Summary - -REQ-047 Step2 마무리 batch. - -BAT-020(PR #17, d4acfd4)에서 Step1/Step2 마이그레이션 revision 코드가 작성됐으나 -실 PostgreSQL 통합검증이 미완이고, Step2 적용 후 동반 코드 수정 2건이 TODO로 잔존했다. - -이 batch는: -1. `ensure_schema()` `broker_credentials` CREATE TABLE을 최종 daemon-scoped 스키마로 갱신 -2. `upsert_broker_credential` `ON CONFLICT (user_id, broker)` → `ON CONFLICT (daemon_id, broker)` 수정 + fallback 트랜잭션 abort 이슈 정리 -3. dev/test PostgreSQL에서 Step1 → backfill → Step2 → downgrade 통합검증 (AC-1~7) -4. 운영 적용 런북 작성 -5. 신규/갱신 단위 테스트 추가 - -## Notes - -- PR #18 머지(mergeCommit 7164f9e), 실 dev PG 통합검증 AC-1~7 PASS, QC(opus) PASS-WITH-NOTES(blocker 0). REQ-047 confirm-req-implemented 완료. -- **Step2(PK 전환) 실 운영 DB 적용은 별도 release 결재 게이트 잔여.** 런북: `docs/runbooks/bat-021-req047-step2-migration-runbook.md`, 배포순서 Step1→backfill→Step2→코드배포 엄수, alembic stamp 사전확인. - -## Documents - -- [planning.md](./planning.md) -- [design.md](./design.md) -- [implementation.md](./implementation.md) -- [verification.md](./verification.md) diff --git a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/planning.md b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/planning.md deleted file mode 100644 index bab5fcf..0000000 --- a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/planning.md +++ /dev/null @@ -1,34 +0,0 @@ -# BAT-021 Planning - -## DoR 확인 - -- [x] REQ-047: `Approved` 상태 확인 -- [x] BAT-020 코드 머지 (d4acfd4) — Step1/Step2 revision 존재 -- [x] dev PostgreSQL (Docker) 가동 확인 -- [x] 동반 수정 TODO 2건 식별 완료 (verification.md 미해결 사항 1, 2) - -## 작업 분해 - -| # | 작업 | 파일 | 비고 | -| --- | --- | --- | --- | -| T-1 | `ensure_schema()` broker_credentials 갱신 | `app/repositories/postgres.py` | daemon_id NOT NULL, PK=(daemon_id,broker) | -| T-2 | `upsert_broker_credential` ON CONFLICT 수정 | `app/repositories/postgres.py` | (daemon_id,broker) + fallback 제거 | -| T-3 | list_broker_credentials fallback 제거 | `app/repositories/postgres.py` | Step2 이후 컬럼 항상 존재 | -| T-4 | Step1 alembic upgrade 실 DB 검증 | dev PostgreSQL | AC-1, AC-2 | -| T-5 | backfill CLI 실행 (테스트 데이터) | pfhub CLI | AC(REQ-048) | -| T-6 | Step2 alembic upgrade 실 DB 검증 | dev PostgreSQL | AC-3, AC-4, AC-5 | -| T-7 | downgrade 실 DB 검증 (Step2 → Step1 → base) | dev PostgreSQL | AC-6, AC-7 | -| T-8 | 단위 테스트 추가/갱신 | `tests/test_bat021_*.py` | ensure_schema 정합, ON CONFLICT 행동 | -| T-9 | 운영 런북 작성 | `docs/runbooks/bat-021-runbook.md` | 백업→Step1→backfill→Step2→verify→rollback | -| T-10 | batch 문서 완성 | docs/batches/bat-021 | implementation.md, verification.md | - -## Design Gate - -Design 필요: 코드 수정 범위가 명확(T-1/T-2/T-3 — 두 함수 내 SQL 수정)하나 -fallback 패턴 제거 및 ensure_schema 스키마 정합성 확인이 필요하므로 design.md를 작성한다. - -## 위험 - -- R-1: Step2 upgrade 중 NULL 행 있으면 revision 코드가 RuntimeError 발생 → 사전에 backfill 완료 확인 필수 -- R-2: `ensure_schema()` 변경 후 Step2 미적용 DB에서 실행 시 NOT NULL/PK 불일치 → ensure_schema는 Step2 적용 후 동작 환경에서만 호출 (런북에 명시) -- R-3: fallback 제거 시 Step1 미적용 DB에서 upsert 호출 시 오류 → 이 batch는 Step2 이후 세계를 목표로 하므로 Step1 이전 환경 지원 불필요 (런북에 명시) diff --git a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/verification.md b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/verification.md deleted file mode 100644 index 549fff9..0000000 --- a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/verification.md +++ /dev/null @@ -1,164 +0,0 @@ -# BAT-021 Verification - -## pytest 실행 결과 - -``` -python3 -m pytest tests/ --tb=short -521 passed, 11 warnings in 26.67s -``` - -기존 테스트: 509 passed (BAT-020 기준) -신규 테스트: 12 added (test_bat021_step2_ensure_schema_and_upsert.py) -기존 테스트 회귀: 0건 - ---- - -## REQ-047 AC 검증 (실 PostgreSQL) - -### 검증 환경 - -- Docker: `portfolio-hub-timescaledb` (timescale/timescaledb:latest-pg16) -- DB: `portfolio_hub` (127.0.0.1:5432) -- 시작 alembic 상태: 20260601_0024 (ensure_schema()로 스키마 구성, alembic 미동기 상태) -- stamp 조치: `alembic stamp 20260605_0025` (DB 실제 스키마 = 0025 적용 상태 반영) - -### AC-1: Step1 후 daemon_id TEXT NULL 컬럼 존재, PK (user_id,broker) 유지 - -``` -alembic upgrade 20260628_0026 -``` - -결과: -``` -daemon_id | text | nullable=YES -PK: broker_credentials_pkey PRIMARY KEY btree (user_id, broker) -``` - -**PASS** - -### AC-2: Step1 후 기존 데이터 daemon_id=NULL 보존 - -```sql -INSERT INTO broker_credentials ... VALUES ('test-user-1', 'kis', ...) -SELECT daemon_id FROM broker_credentials WHERE user_id='test-user-1'; --- 결과: NULL -``` - -**PASS** - -### AC-3: Step2 후 PK (daemon_id, broker) 전환, daemon_id NOT NULL - -``` -# backfill: UPDATE broker_credentials SET daemon_id='test-daemon-1' WHERE daemon_id IS NULL -alembic upgrade 20260628_0027 -``` - -결과: -``` -daemon_id | text | nullable=NO -PK: broker_credentials_pkey PRIMARY KEY btree (daemon_id, broker) -``` - -**PASS** - -### AC-4: Step2 후 user_id 컬럼 유지 - -```sql -SELECT column_name FROM information_schema.columns -WHERE table_name='broker_credentials' AND column_name='user_id'; --- 결과: user_id 존재 -``` - -**PASS** - -### AC-5: Step2 후 ensure_schema() 정합 - -```python -repo.ensure_schema() # 오류 없음 -# DB 조회 결과: -# daemon_id: NOT NULL, PK=(daemon_id, broker), user_id 존재 -``` - -**PASS** - -### AC-6: Step1 downgrade — daemon_id 컬럼 제거, PK (user_id,broker) 복원 - -``` -alembic downgrade 20260605_0025 -``` - -결과: -``` -broker_credentials: daemon_id 컬럼 없음 -PK: broker_credentials_pkey PRIMARY KEY btree (user_id, broker) -``` - -**PASS** - -### AC-7: Step2 downgrade 안전장치 — 중복 (user_id,broker) 존재 시 RuntimeError - -``` -# 2개 daemon이 같은 user/broker 사용하도록 데이터 구성 후 downgrade 시도 -alembic downgrade 20260628_0026 -``` - -결과: -``` -RuntimeError: downgrade 불가: 1개 (user_id, broker) 조합이 중복됩니다. -같은 user의 여러 daemon이 동일 broker를 사용하고 있어 -(user_id, broker) PK로 복원 시 데이터 손실이 발생합니다. -수동으로 데이터를 정리한 후 downgrade하세요. -``` - -**PASS** - ---- - -## 동반 코드 검증 - -### upsert_broker_credential (ON CONFLICT + row mapper) - -```python -cred = repo.upsert_broker_credential( - 'bat021-test-user', 'kis', - account_ref='BAT021-ACCOUNT', - encrypted_payload='bat021-encrypted-payload', - daemon_id='bat021-daemon-1' -) -# 결과: daemon_id='bat021-daemon-1', user_id='bat021-test-user', broker='kis', account_ref='BAT021-ACCOUNT' -cred2 = repo.upsert_broker_credential(..., account_ref='BAT021-ACCOUNT-UPDATED') -# 결과: version=2, account_ref='BAT021-ACCOUNT-UPDATED' (idempotent update OK) -``` - -**PASS** - -### list_broker_credentials (Step2 컬럼 순서) - -```python -creds = repo.list_broker_credentials('bat021-test-user', daemon_id='bat021-daemon-1') -# 결과: 1건, daemon_id='bat021-daemon-1', broker='kis' -``` - -**PASS** - ---- - -## Step2 NULL 사전 검증 (REQ-048 미수행 시 거부) - -``` -# daemon_id IS NULL 행 1건 존재 상태에서 Step2 upgrade 시도 -RuntimeError: broker_credentials에 daemon_id IS NULL인 행이 1건 있습니다. -REQ-048 backfill-daemon-id CLI로 모든 행을 백필한 후 다시 실행하세요. -``` - -**PASS** - ---- - -## 미해결/위험 사항 - -없음 — 이 batch의 TODO 2건 모두 해소됨: -1. ensure_schema() broker_credentials 갱신: **완료** -2. upsert ON CONFLICT (user_id,broker) → (daemon_id,broker) + fallback 제거: **완료** - -실 운영 DB 적용은 런북에 따라 사장님 결재 후 운영자가 수행한다. diff --git a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/design.md b/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/design.md deleted file mode 100644 index 81779bb..0000000 --- a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/design.md +++ /dev/null @@ -1,93 +0,0 @@ -# BAT-022 Design - -- Status: confirmed -- Batch: BAT-022 - -## OQ-1 해소: 인증정보 관리 엔드포인트 경로 (REQ-052) - -**확정: `/v1/daemons/{daemon_id}/credential`** - -근거: -- v1 컨벤션: `/v1/daemons/{daemon_id}`가 daemon 리소스 계층 루트. credential은 daemon의 하위 리소스이므로 중첩 경로가 REST 의미에 맞는다. -- 소유 스코프 자연성: daemon_id가 경로에 있어 소유 검증(`daemon.owner_user_id == auth.user_id`) 경로 파라미터에서 바로 가능. -- dcy-009 UI 흐름: 사용자가 자기 데몬을 선택한 뒤 인증정보를 관리하는 흐름과 일치. -- 기존 `/v1/daemons` GET이 daemon 계층을 쓰므로 일관성 유지. - -메서드: -- `GET /v1/daemons/{daemon_id}/credential` — 메타데이터 조회 (has_credential, version, last_rotated_at) -- `PUT /v1/daemons/{daemon_id}/credential` — 등록/회전 (upsert, 응답 메타데이터만) -- `DELETE /v1/daemons/{daemon_id}/credential` — 삭제 (204) - -브로커 지정: PUT 요청 body에 `broker` 필드(기본값 `"kis"`). 현재 단일 broker 가정이므로 경로에 broker를 두지 않는다. 다중 broker 지원 필요 시 `/v1/daemons/{daemon_id}/credentials/{broker}` 로 확장 경로로 분리 가능(하위 호환). - -## OQ-2 해소: admin 데몬 CRUD 경로 (REQ-053) - -**확정: `/v1/admin/daemons`** - -근거: -- dcy-006 패턴: `/v1/admin/...` prefix를 admin 전용 관리 API의 네임스페이스로 일관되게 사용한다. dcy-006에서 ledger 회계 통합 경로는 `/v1/portfolios/...`를 사용했으나, admin 전용 mutate는 다른 서비스(users.py 등)도 `/users`에 admin 게이트를 두는 방식. 여기서 `/v1/daemons` 기존 경로에 POST/PATCH를 추가하면 GET(caller-scoped, REQ-045)과 POST(admin-only, REQ-053)가 같은 경로에 혼재되어 권한 경계가 불명확해진다. -- `/v1/admin/daemons`는 admin 전용 write 작업임을 경로 자체로 명시. -- trex-web dcy-009 소비자가 admin 경로를 별도 컨텍스트에서 관리하기 쉽다. - -메서드: -- `POST /v1/admin/daemons` → 201 DaemonAdminResponse (생성) -- `PATCH /v1/admin/daemons/{daemon_id}` → 200 DaemonAdminResponse (display_name 변경, owner 재할당) -- `POST /v1/admin/daemons/{daemon_id}/deactivate` → 200 DaemonAdminResponse -- `POST /v1/admin/daemons/{daemon_id}/activate` → 200 DaemonAdminResponse -- `DELETE /v1/admin/daemons/{daemon_id}` → 405 Method Not Allowed (명시적 미제공) - -## Architecture Summary - -### 신규 파일 - -| 파일 | 역할 | -| --- | --- | -| `app/api/v1_admin_daemons.py` | REQ-053 admin daemon CRUD 라우터 | -| `app/api/v1_daemon_credential.py` | REQ-052 소유 스코프 인증정보 관리 라우터 | - -### 변경 파일 - -| 파일 | 변경 내용 | -| --- | --- | -| `app/schemas/v1.py` | REQ-052 응답/요청 스키마 + REQ-053 응답/요청 스키마 추가 | -| `app/repositories/postgres.py` | `get_broker_credential_by_daemon`, `delete_credentials_by_daemon_id`, `update_daemon_owner` 추가 | -| `app/repositories/portfolio_repository.py` | Protocol에 신규 메서드 추가 | -| `app/main.py` | 신규 라우터 include | - -### 신규 테스트 파일 - -| 파일 | 커버 | -| --- | --- | -| `tests/test_bat022_req052_credential_api.py` | REQ-052 AC-1~10 | -| `tests/test_bat022_req053_admin_daemon_crud.py` | REQ-053 AC-1~12 | -| `tests/test_bat022_req054_security.py` | REQ-054 AC-1~7 | - -## Key Decisions - -1. **소유 스코프 게이트 위치**: 라우터 내에서 daemon 조회 후 `daemon.owner_user_id != auth.user_id` 즉시 403. 서비스 레이어 분리 없이 라우터에서 직접 처리(기존 v1_portfolios.py 패턴 동일). - -2. **service-token 403 (REQ-052 AC-10)**: `auth.token_source == "db" and auth.token_type == "service"` 이면 403. env-token(token_source="env")은 token_type이 None이므로 이 조건에 해당하지 않는다. env admin token으로 credential 관리는 허용하지 않는다(user_id가 없으므로 소유 스코프 검사에서 403 발생). - -3. **admin도 타인 credential write 403 (REQ-052 AC-9)**: admin role이라도 `auth.user_id != daemon.owner_user_id`이면 403. 인증정보 소유 스코프는 admin role을 우선시하지 않는다. admin은 REQ-053(`/v1/admin/daemons`)으로 데몬 provisioning만 담당. - -4. **owner 재할당 + credential 무효화 (REQ-053 AC-4)**: `update_daemon_owner()`에서 먼저 daemon의 owner를 업데이트하고 이후 `DELETE FROM broker_credentials WHERE daemon_id = %s` 호출. autocommit 모드이므로 두 연산이 별도. 인증정보 삭제 실패는 로그 경고 후 무시(데몬 업데이트는 이미 완료). - -5. **DELETE 405 (REQ-053 AC-8)**: `@router.delete("/v1/admin/daemons/{daemon_id}")` 핸들러를 명시 등록해 HTTP 405를 반환. FastAPI 기본은 미등록 DELETE → 405 (Method Not Allowed)이나 명시 등록이 더 안전. - -6. **slug 중복 → 409 (REQ-053 AC-2)**: postgres.py create_daemon에서 UniqueViolation → ValueError 변환. 라우터에서 ValueError 메시지 패턴 매칭으로 409 반환. - -7. **DELETE credential 204 (REQ-052 AC-5)**: 인증정보가 없어도 204 반환(멱등). 있으면 삭제 후 204. - -8. **브로커 경로 단순화**: PUT body에 `broker: str = "kis"`. GET/DELETE는 기본 broker(현재 단일 broker 가정). - -## Changed Areas - -- app/api/v1_admin_daemons.py — 신규 -- app/api/v1_daemon_credential.py — 신규 -- app/schemas/v1.py — CredentialSetRequest, CredentialMetaResponse, DaemonCreateRequest, DaemonUpdateRequest, DaemonAdminResponse 추가 -- app/repositories/postgres.py — get_broker_credential_by_daemon, delete_credentials_by_daemon_id, update_daemon_owner 추가 -- app/repositories/portfolio_repository.py — Protocol 동기화 -- app/main.py — 라우터 include -- tests/test_bat022_req052_credential_api.py — 신규 -- tests/test_bat022_req053_admin_daemon_crud.py — 신규 -- tests/test_bat022_req054_security.py — 신규 diff --git a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/implementation.md b/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/implementation.md deleted file mode 100644 index 4dd7781..0000000 --- a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/implementation.md +++ /dev/null @@ -1,59 +0,0 @@ -# BAT-022 Implementation - -- Status: completed -- Commit: 6a0e14d - -## 변경 파일 목록 - -### 신규 파일 - -| 파일 | 역할 | -| --- | --- | -| `app/api/v1_admin_daemons.py` | REQ-053 admin daemon CRUD 라우터 (`/v1/admin/daemons`) | -| `app/api/v1_daemon_credential.py` | REQ-052 소유 스코프 credential 관리 라우터 (`/v1/daemons/{id}/credential`) | -| `tests/test_bat022_req052_credential_api.py` | REQ-052 AC-1~10 테스트 (17 케이스) | -| `tests/test_bat022_req053_admin_daemon_crud.py` | REQ-053 AC-1~12 테스트 (15 케이스) | -| `tests/test_bat022_req054_security.py` | REQ-054 AC-1~7 + REQ-051 회귀 (12 케이스) | - -### 변경 파일 - -| 파일 | 변경 내용 | -| --- | --- | -| `app/schemas/v1.py` | `CredentialSetRequest`, `CredentialMetaResponse`, `DaemonCreateRequest`, `DaemonUpdateRequest`, `DaemonAdminResponse` 추가 | -| `app/repositories/postgres.py` | `get_broker_credential_by_daemon`, `delete_credentials_by_daemon_id`, `update_daemon_owner` 추가 | -| `app/repositories/portfolio_repository.py` | Protocol에 신규 3개 메서드 추가 | -| `app/main.py` | `v1_admin_daemons_router`, `v1_daemon_credential_router` include | - -## REQ별 구현 요약 - -### REQ-052 (소유 스코프 인증정보 관리 API) - -- `GET /v1/daemons/{daemon_id}/credential` — 소유 스코프 검증 후 BrokerCredential 메타데이터 반환 (has_credential, version, last_rotated_at). secret 없음. -- `PUT /v1/daemons/{daemon_id}/credential` — `app_key`/`app_secret` Fernet 암호화 후 `upsert_broker_credential(daemon_id=...)` 호출. 응답 메타데이터만. -- `DELETE /v1/daemons/{daemon_id}/credential` — `delete_broker_credential(daemon_id=...)` 호출, 204 반환(멱등). -- 소유 스코프 게이트: `daemon.owner_user_id != auth.user_id` → 403. admin role도 동일 검사(AC-9). -- service-token 거부: `token_source == "db" and token_type == "service"` → 403(AC-10). -- 기존 `upsert_broker_credential`, `delete_broker_credential`(dcy-010 자산) 재사용. CLI와 동일 repo 메서드. - -### REQ-053 (admin 데몬 CRUD API) - -- `POST /v1/admin/daemons` → 기존 `create_daemon()` 재사용, 201. -- `PATCH /v1/admin/daemons/{id}` → owner 변경 시 `update_daemon_owner()`(새 메서드), display_name만 변경 시 기존 `update_daemon()`. -- `POST .../deactivate` → `update_daemon(is_active=False)` + `revoke_tokens_by_daemon_id()`(REQ-022 재사용). -- `POST .../activate` → `update_daemon(is_active=True)`. -- `DELETE .../` → 명시적 405 핸들러 등록. -- `require_admin` dependency 사용 (기존 `app/core/auth.py`). - -### REQ-054 (보안 격리) - -- 응답 스키마: `CredentialMetaResponse`/`DaemonAdminResponse` 모두 secret 필드 부재. -- 로그: `PUT credential` 처리 시 broker/daemon_id만 기록 (app_key/app_secret 값 미포함). -- Fernet at-rest: `encrypt_payload()` 호출 후 `upsert_broker_credential()` — 평문 미저장(AC-5). -- REQ-051 decrypt 경로 불변: `/v1/user_info` 코드 무변경, 회귀 4개 테스트 PASS(AC-7). - -## 설계 결정 트레이스 (design.md 참조) - -- OQ-1 해소: `/v1/daemons/{daemon_id}/credential` — daemon-scoped, REST 정합. -- OQ-2 해소: `/v1/admin/daemons` — admin 전용 prefix, 기존 GET /v1/daemons(caller-scoped)와 분리. -- service-token 403: `token_source == "db" and token_type == "service"` 조건. env-token은 token_type=None이므로 해당 없음. -- admin + 소유 스코프: admin role도 ownership 검사 통과 → 403(REQ-052 AC-9). diff --git a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/index.md b/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/index.md deleted file mode 100644 index 917eb18..0000000 --- a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/index.md +++ /dev/null @@ -1,56 +0,0 @@ -# BAT-022 — daemon CRUD admin API + self-service 인증정보 관리 API - -- Batch ID: BAT-022 -- Profile: standard -- Status: release-candidate -- Discovery: dcy-011 -- Included REQ: REQ-052, REQ-053, REQ-054 -- Folder: docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/ -- Created: 2026-06-28 - -## Summary - -dcy-011(daemon admin CRUD + 사용자 self-service 브로커 인증정보 관리 API)을 portfolio-hub에 구현한다. - -- REQ-052: user-token + 소유 스코프 인증정보 set/rotate/delete + 메타데이터 GET (`/v1/daemons/{daemon_id}/credential`) -- REQ-053: admin 데몬 생성·owner 할당·변경·(비)활성화 (`/v1/admin/daemons`) -- REQ-054: 모든 관리 API 응답/로그에 app_key/app_secret 평문 미포함(보안 격리) + REQ-051 회귀 PASS - -dcy-010 자산(BrokerCredential 모델, Fernet 암호화, upsert/delete 메서드) 재사용. CLI와 동일 repo 메서드 공유. - -## Included REQ - -| REQ | 제목 | Type | 의존 | -| --- | --- | --- | --- | -| REQ-052 | 사용자 소유 스코프 인증정보 관리 HTTP API | Interface | dcy-010 자산(BrokerCredential, Fernet) | -| REQ-053 | admin 데몬 CRUD HTTP API | Interface | Daemon 모델, REQ-022(service token revoke) | -| REQ-054 | 인증정보 관리 보안 격리 | Non-Functional | REQ-052, REQ-053, REQ-051 | - -## DoR (진입 조건) - -- [x] REQ-052/053/054 전건 Approved 상태 확인 -- [x] dcy-011 confirmed Discovery 확인(OQ-1/OQ-2 Deferred → design 단계 해소) -- [x] 구현 대상 파일 존재 확인: app/repositories/postgres.py, app/domain/models.py, app/core/auth.py, app/core/encryption.py -- [x] dcy-010 자산 존재 확인: BrokerCredential PK (daemon_id, broker), upsert_broker_credential, list_broker_credentials, delete_broker_credential - -## DoD (완료 조건) - -- [x] REQ-052 AC-1~10 테스트 통과 (17 케이스) -- [x] REQ-053 AC-1~12 테스트 통과 (15 케이스) -- [x] REQ-054 AC-1~7 테스트 통과 (12 케이스, caplog 비노출, 응답 secret 부재, REQ-051 회귀) -- [x] 기존 521 테스트 회귀 없음 (565 passed, 0 failures) -- [x] draft-batch-verification 문서에 AC별 evidence 기록 - -## 비가역 결재 게이트 - -- DB 마이그레이션: 신규 DDL 없음. 기존 ensure_schema() + postgres.py 메서드 확장으로 처리 (비가역 없음). -- HTTP API 계약 신설: trex-web dcy-009가 소비 예정. 이 batch는 계약 신설이므로 기존 소비자에 영향 없음(호환 깨짐 없음). - -## Notes - -- PR #19 머지 완료: mergeCommit 1b78c18 (2026-06-28) -- QC: PASS-WITH-NOTES (opus, blocker 0) -- confirm-req-implemented: REQ-052/053/054 → Implemented (2026-06-28) -- Major 보안 #3(owner 재할당 credential leak) 원자화 수정(83e6dde) — 단일 트랜잭션+선삭제 방식, leak 방지 테스트 3종 포함. 실 DB 트랜잭션 롤백 통합검증은 배포 단계 항목으로 이월. -- broker "kis" 고정(멀티 broker 확장은 후속 Discovery 대상). -- 실 운영 배포 시 `/v1/daemons/{daemon_id}/credential` 및 `/v1/admin/daemons` 경로 trex-web dcy-009 연결 확인 필요. diff --git a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/planning.md b/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/planning.md deleted file mode 100644 index 599692c..0000000 --- a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/planning.md +++ /dev/null @@ -1,85 +0,0 @@ -# BAT-022 Planning - -## 구현 의존 순서 - -1. **REQ-052/053 스키마** — `app/schemas/v1.py`에 `CredentialSetRequest`, `CredentialMetaResponse`, `DaemonCreateRequest`, `DaemonUpdateRequest`, `DaemonAdminResponse` 추가 -2. **REQ-053 repository 확장** — `postgres.py`에 `update_daemon_owner()` (owner 변경 + 인증정보 자동 삭제) 신규. `update_daemon()` 재사용 가능한 부분은 그대로. -3. **REQ-052 repository 확장** — `postgres.py`에 `get_broker_credential_meta()` (단일 credential 조회, user_id 소유 검증 포함) 추가. 기존 `upsert_broker_credential`, `delete_broker_credential` 재사용. -4. **REQ-053 라우터** — `app/api/v1_admin_daemons.py` 신규. `POST /v1/admin/daemons`, `PATCH /v1/admin/daemons/{daemon_id}`, `POST /v1/admin/daemons/{daemon_id}/activate`, `POST /v1/admin/daemons/{daemon_id}/deactivate`. `require_admin` 사용. -5. **REQ-052 라우터** — `app/api/v1_daemon_credential.py` 신규. `GET /v1/daemons/{daemon_id}/credential`, `PUT /v1/daemons/{daemon_id}/credential`, `DELETE /v1/daemons/{daemon_id}/credential`. `require_read` + 소유 스코프 검사. -6. **REQ-052/053/054 테스트** — `tests/test_bat022_*.py` 신규 (AC별 pytest 케이스) -7. **REQ-054 보안 격리 테스트** — caplog, 응답 JSON 필드 열거, REQ-051 회귀 - -## Task Breakdown - -### T-1: 스키마 추가 (app/schemas/v1.py) - -**REQ-052 스키마:** -- `CredentialSetRequest` — `app_key: str`, `app_secret: str`, `account_ref: str`, `broker: str = "kis"` -- `CredentialMetaResponse` — `daemon_id: str`, `broker: str`, `account_ref: str`, `has_credential: bool`, `credential_version: int`, `last_rotated_at: datetime | None` (secret 필드 없음) - -**REQ-053 스키마:** -- `DaemonCreateRequest` — `daemon_slug: str`, `display_name: str | None`, `owner_user_id: str` -- `DaemonUpdateRequest` — `display_name: str | None`, `owner_user_id: str | None` -- `DaemonAdminResponse` — `id: str`, `daemon_slug: str`, `display_name: str | None`, `owner_user_id: str`, `is_active: bool` (credential 필드 없음) - -### T-2: repository 메서드 추가 (app/repositories/postgres.py + protocol) - -- `get_broker_credential_by_daemon` — `(daemon_id, broker)` 로 단일 BrokerCredential 조회(없으면 None). 소유 검증은 서비스/라우터 레이어에서. -- `delete_credentials_by_daemon_id` — daemon_id에 속한 모든 BrokerCredential 삭제(owner 재할당 시 무효화용). REQ-053 AC-4 지원. -- `update_daemon_owner` — owner_user_id 변경 + 해당 daemon의 모든 broker_credentials 삭제 (단일 메서드 atomic). owner 변경 없이 display_name만 변경하면 기존 `update_daemon()` 재사용. -- Protocol(`portfolio_repository.py`) 동기화. - -### T-3: admin daemon 라우터 (app/api/v1_admin_daemons.py) - -경로: `/v1/admin/daemons` - -- `POST /v1/admin/daemons` → `DaemonCreateRequest` → 201 + `DaemonAdminResponse` - - 중복 slug → 409 Conflict - - 비admin/service-token → 403, 비인증 → 401 -- `PATCH /v1/admin/daemons/{daemon_id}` → `DaemonUpdateRequest` → 200 + `DaemonAdminResponse` - - owner 변경 시 `update_daemon_owner()` 호출(인증정보 자동 무효화) - - owner 변경 없으면 `update_daemon(display_name=...)` 호출 - - 미존재 → 404 -- `POST /v1/admin/daemons/{daemon_id}/deactivate` → 200 + `DaemonAdminResponse` - - `update_daemon(is_active=False)` + `revoke_tokens_by_daemon_id()` (REQ-022 재사용) - - 미존재 → 404 -- `POST /v1/admin/daemons/{daemon_id}/activate` → 200 + `DaemonAdminResponse` - - `update_daemon(is_active=True)` 호출 - - 미존재 → 404 -- `DELETE /v1/admin/daemons/{daemon_id}` → 405 Method Not Allowed (하드 삭제 미제공) - -### T-4: credential 라우터 (app/api/v1_daemon_credential.py) - -경로: `/v1/daemons/{daemon_id}/credential` - -공통 소유 스코프 게이트: -- 비인증(토큰 없음) → 401 (require_read가 처리) -- service-token(`token_type == "service"`) → 403 -- auth.user_id가 daemon.owner_user_id와 불일치 → 403 -- 미존재 daemon_id → 404 - -엔드포인트: -- `GET /v1/daemons/{daemon_id}/credential` → 200 + `CredentialMetaResponse` - - 인증정보 없으면 `has_credential: false` + 나머지 null -- `PUT /v1/daemons/{daemon_id}/credential` → 200 + `CredentialMetaResponse` - - `app_key`/`app_secret` Fernet 암호화 → `upsert_broker_credential()` 호출 - - 응답에 app_key/app_secret 미포함 -- `DELETE /v1/daemons/{daemon_id}/credential` → 204 No Content (인증정보 없어도 204) - -### T-5: main.py 라우터 등록 - -- `v1_admin_daemons_router`, `v1_daemon_credential_router` include - -### T-6: 테스트 신규 (tests/test_bat022_*.py) - -- `test_bat022_req052_credential_api.py` — REQ-052 AC-1~10 (소유 스코프 음성 포함) -- `test_bat022_req053_admin_daemon_crud.py` — REQ-053 AC-1~12 (owner 재할당 + 인증정보 무효화, DELETE 405 포함) -- `test_bat022_req054_security.py` — REQ-054 AC-1~7 (caplog, secret 필드 부재, REQ-051 회귀) - -## Risks / Constraints - -- service-token 403 (REQ-052 AC-10): `token_type == "service"` 체크는 `auth.token_source == "db"` 전제. env-token은 `token_type=None`이므로 별도 분기 필요. -- admin role 검증 (REQ-053): `require_admin`은 기존 permission hierarchy(admin > trade-write > read)를 사용. env admin 토큰도 처리. -- owner 변경 + 인증정보 무효화(REQ-053 AC-4): `update_daemon_owner()`는 단일 DB 트랜잭션이 아님(psycopg3 autocommit). partial failure(daemon 업데이트 성공 + 인증정보 삭제 실패)는 드문 케이스이나 로그 경고 추가. -- DELETE 405 (REQ-053 AC-8): FastAPI에서 미등록 경로의 DELETE는 404를 반환. 명시적 405 반환하려면 라우터에 `DELETE` 핸들러를 405 고정으로 등록해야 함. diff --git a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/verification.md b/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/verification.md deleted file mode 100644 index 0749007..0000000 --- a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/verification.md +++ /dev/null @@ -1,92 +0,0 @@ -# BAT-022 Verification - -- Status: draft -- Batch: BAT-022 -- Verification Date: 2026-06-28 - -## 테스트 실행 결과 - -### 신규 테스트 (44 케이스) - -``` -tests/test_bat022_req052_credential_api.py — 17 passed -tests/test_bat022_req053_admin_daemon_crud.py — 15 passed -tests/test_bat022_req054_security.py — 12 passed -``` - -### 전체 테스트 스위트 - -``` -565 passed, 11 warnings in 32.53s -``` - -기존 521 테스트 회귀: **0건** (521 → 565, +44 신규만 추가) - -## REQ-052 AC별 검증 Evidence - -| AC | 테스트 | 결과 | -| --- | --- | --- | -| AC-1: PUT → 200 + 메타데이터, secret 없음 | `test_put_credential_returns_meta_without_secret` | PASS | -| AC-2: 회전 시 credential_version 증가 | `test_put_credential_rotation_increments_version` | PASS | -| AC-3: CLI·API·service-token 정합 (upsert daemon_id 전달) | `test_put_credential_upsert_called_with_correct_daemon_id` | PASS | -| AC-4: GET → 200 + 메타데이터, secret 없음 | `test_get_credential_meta_returns_metadata_without_secret` | PASS | -| AC-5: DELETE → 204 + 이후 has_credential=False | `test_delete_credential_returns_204`, `test_get_after_delete_returns_has_credential_false` | PASS | -| AC-6 (음성): 타 사용자 토큰 → 403 | `test_put/get/delete_credential_other_user_returns_403` (3개) | PASS | -| AC-7: 비인증 → 401 | `test_credential_no_token_returns_401` | PASS | -| AC-8: 미존재 daemon → 404 | `test_credential_nonexistent_daemon_returns_404`, `test_put_nonexistent_daemon_returns_404` | PASS | -| AC-9 (음성): admin으로 타인 daemon write → 403 | `test_admin_cannot_write/read_other_user_credential` (2개) | PASS | -| AC-10 (음성): service-token → 403 | `test_service_token_credential_get/put/delete_returns_403` (3개) | PASS | - -## REQ-053 AC별 검증 Evidence - -| AC | 테스트 | 결과 | -| --- | --- | --- | -| AC-1: POST 생성 → 201 + DaemonAdminResponse | `test_admin_create_daemon_returns_201` | PASS | -| AC-2: 중복 slug → 409 | `test_admin_create_daemon_duplicate_slug_returns_409` | PASS | -| AC-3: display_name 변경 → BrokerCredential 유지 | `test_admin_patch_display_name_credential_preserved` | PASS | -| AC-4: owner 재할당 → BrokerCredential 자동 삭제 | `test_admin_patch_owner_invalidates_credentials` | PASS | -| AC-5: 재할당 후 이전 owner 인증정보 무효화 | `test_admin_patch_owner_then_old_owner_loses_credential` | PASS | -| AC-6: 비활성화 → is_active=False + service token revoke | `test_admin_deactivate_daemon_revokes_tokens` | PASS | -| AC-7: 재활성화 → is_active=True | `test_admin_activate_daemon_returns_active` | PASS | -| AC-8 (음성): DELETE → 405 | `test_admin_delete_daemon_returns_405` | PASS | -| AC-9 (음성): user-token(비admin) → 403 | `test_non_admin_user_token_create/patch/deactivate` (3개) | PASS | -| AC-10 (음성): service-token → 403 | `test_service_token_admin_crud_returns_403` | PASS | -| AC-11: 비인증 → 401 | `test_admin_crud_no_token_returns_401` | PASS | -| AC-12: 미존재 daemon → 404 | `test_admin_patch/deactivate_nonexistent_daemon_returns_404` (2개) | PASS | - -## REQ-054 AC별 검증 Evidence - -| AC | 테스트 | 결과 | -| --- | --- | --- | -| AC-1: PUT 응답에 app_key/app_secret 없음 | `test_put_credential_response_has_no_secret_fields` | PASS | -| AC-2: GET 응답에 app_key/app_secret 없음 | `test_get_credential_meta_response_has_no_secret_fields` | PASS | -| AC-3: admin CRUD 응답에 credential 필드 없음 | `test_admin_create/patch_response_has_no_credential_fields` (2개) | PASS | -| AC-4: caplog — PUT 처리 중 app_key/app_secret 값 미포함 | `test_put_credential_log_does_not_expose_secret` | PASS | -| AC-5: Fernet 암호화 저장 확인 (gAAAA... 형태) | `test_put_credential_stores_encrypted_payload` | PASS | -| AC-6: PUT 후 service-token user_info → 동일 account_ref | `test_put_then_service_token_user_info_returns_correct_account_ref` | PASS | -| AC-7: REQ-051 회귀 (4개 테스트) | `test_req051_regression_*` (4개) | PASS | - -## 음성 테스트 체크리스트 - -| 음성 시나리오 | 케이스 수 | 결과 | -| --- | --- | --- | -| 소유 스코프 403 (AC-6: 타 사용자 토큰) | 3 | PASS | -| admin 소유 스코프 403 (AC-9) | 2 | PASS | -| service-token 403 (AC-10, REQ-052) | 3 | PASS | -| service-token 403 (AC-10, REQ-053) | 1 | PASS | -| DELETE 405 (AC-8, REQ-053) | 1 | PASS | -| secret 필드 부재 (AC-1/2/3, REQ-054) | 4 | PASS | -| caplog secret 미노출 (AC-4, REQ-054) | 1 | PASS | - -## 회귀 테스트 - -- REQ-051 AC-1~3, AC-5: 신규 API 추가 후 GET /v1/user_info 보안 격리 불변 확인 (4개 테스트 PASS) -- 기존 521 테스트 전건 회귀 없음 - -## 잔여 위험 / 미해결 - -1. **AC-3 통합 수준**: REQ-052 AC-3(CLI·API·service-token 3경로 정합)은 이 batch에서 unit-level로 검증(upsert daemon_id 전달 확인). 실 DB 통합 검증은 dev DB 환경 필요(비가역 아님, 선택적 추후 확인 가능). - -2. **DELETE credential 기본 broker "kis"**: GET/DELETE 에서 broker 파라미터 없이 "kis" 고정. 다중 broker 지원이 필요해지면 경로 파라미터 추가 필요 — 현재 단일 broker 가정 설계 일치. - -3. **update_daemon_owner autocommit 부분 실패**: postgres.py `update_daemon_owner()`는 daemon UPDATE + credential DELETE를 autocommit 2회 연산으로 처리. daemon 업데이트 성공 후 credential 삭제 실패 케이스는 테스트 범위 밖(네트워크 장애 등). 실 운영에서 발생 시 수동 정리 필요. diff --git a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/design.md b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/design.md deleted file mode 100644 index 0de7355..0000000 --- a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/design.md +++ /dev/null @@ -1,154 +0,0 @@ -# BAT-023 Design - -## 변경 범위 요약 - -additive 확장. 기존 `PortfolioItem` 16필드·토큰분기·필터는 불변. 4개 신규 필드만 추가. - -## SQL 쿼리 변경 - -### 변경 전 (_SELECT_PORTFOLIO) - -```sql -SELECT - p.portfolio_id, -- [0] - p.broker, -- [1] - p.status, -- [2] - p.name, -- [3] - a.symbol, -- [4] - p.cash_balance, -- [5] - p.asset_quantity, -- [6] - p.asset_price, -- [7] - p.profit_buffer, -- [8] - p.active_strategy_id, -- [9] - p.avabile_strategies, -- [10] - p.owner_user_id, -- [11] - p.created_at, -- [12] - p.updated_at, -- [13] - p.last_trade_at, -- [14] - p.managed_by_daemon_id, -- [15] - p.asset_id, -- [16] - COALESCE(p.tag_ids, '{}'::uuid[]), -- [17] - a.display_name -- [18] -FROM portfolios p -JOIN assets a ON p.asset_id = a.asset_id -``` - -### 변경 후 - -```sql -SELECT - p.portfolio_id, -- [0] - p.broker, -- [1] - p.status, -- [2] - p.name, -- [3] - a.symbol, -- [4] - p.cash_balance, -- [5] - p.asset_quantity, -- [6] - p.asset_price, -- [7] - p.profit_buffer, -- [8] - p.active_strategy_id, -- [9] - p.avabile_strategies, -- [10] - p.owner_user_id, -- [11] - p.created_at, -- [12] - p.updated_at, -- [13] - p.last_trade_at, -- [14] - p.managed_by_daemon_id, -- [15] - p.asset_id, -- [16] - COALESCE(p.tag_ids, '{}'::uuid[]), -- [17] - a.display_name, -- [18] - ss.name, -- [19] NEW: strategy_sets.name (NULL if unbound/dangling) - ss.config, -- [20] NEW: strategy_sets.config TEXT (NULL if unbound/dangling) - a.markets -- [21] NEW: assets.markets TEXT[] -FROM portfolios p -JOIN assets a ON p.asset_id = a.asset_id -LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id -``` - -LEFT JOIN이므로: -- `active_strategy_id IS NULL` → ss.name=NULL, ss.config=NULL -- dangling FK (active_strategy_id 있으나 strategy_sets row 없음) → ss.name=NULL, ss.config=NULL - -## _row_to_portfolio 파싱 로직 (신규 컬럼) - -```python -import json -import logging -logger = logging.getLogger(__name__) # 기존 없으면 추가 - -# strategy_name -strategy_name = row[19] if len(row) > 19 else None - -# strategy_params -_config_raw = row[20] if len(row) > 20 else None -if not _config_raw: - strategy_params: dict = {} -else: - try: - strategy_params = json.loads(_config_raw) - if not isinstance(strategy_params, dict): - strategy_params = {} - except (json.JSONDecodeError, ValueError): - logger.warning( - "strategy_sets.config 파싱 실패 — portfolio_id=%s, active_strategy_id=%s, config=%r", - row[0], row[9], _config_raw[:100] if _config_raw else None, - ) - strategy_params = {} - -# markets -_markets_raw = row[21] if len(row) > 21 else None -markets = list(_markets_raw) if _markets_raw else [] -``` - -## Portfolio dataclass 변경 - -```python -@dataclass -class Portfolio: - ... - active_strategy_id: str | None = None - strategy_name: str | None = None # NEW - strategy_params: dict = field(default_factory=dict) # NEW - markets: list[str] = field(default_factory=list) # NEW - avabile_strategies: list[str] | None = None - ... -``` - -`Any` 타입이 필요한 경우 `dict` 또는 `dict[str, Any]`로 선언. `typing.Any` import 추가. - -## PortfolioItem 스키마 변경 - -```python -class PortfolioItem(BaseModel): - ... - # 기존 16필드 유지 - active_strategy_id: str | None = None # NEW (REQ-055) - strategy_name: str | None = None # NEW (REQ-055) - strategy_params: dict[str, Any] = Field(default_factory=dict) # NEW (REQ-055) - markets: list[str] = Field(default_factory=list) # NEW (REQ-055) -``` - -## _portfolio_to_item 변경 - -```python -def _portfolio_to_item(portfolio: Portfolio) -> PortfolioItem: - return PortfolioItem( - ... - # 기존 필드 유지 - active_strategy_id=portfolio.active_strategy_id, # NEW - strategy_name=portfolio.strategy_name, # NEW - strategy_params=portfolio.strategy_params, # NEW - markets=portfolio.markets, # NEW - ) -``` - -## 영향받지 않는 경로 - -- `create_portfolio`, `update_portfolio`, `soft_delete_portfolio` — 쓰기 경로는 `_SELECT_PORTFOLIO` 후 `_row_to_portfolio`를 통해 읽어오므로 자동 혜택. 변경 불필요. -- `get_or_create` — 동일하게 `_SELECT_PORTFOLIO` 사용. -- 기존 테스트들의 `_make_portfolio` — `strategy_name`/`strategy_params`/`markets`에 기본값이 있으므로 인수 변경 불필요. - -## 하위호환성 - -- 신규 필드 모두 `Optional` 또는 `default_factory`로 선언 → 기존 소비자가 필드를 무시해도 안전. -- 기존 16필드 이름·타입 불변. -- N+1 없음: 단일 SELECT + LEFT JOIN으로 해결. diff --git a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/index.md b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/index.md deleted file mode 100644 index 9c7d50d..0000000 --- a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/index.md +++ /dev/null @@ -1,50 +0,0 @@ -# BAT-023 — GET /v1/portfolios 응답에 전략·시장 메타데이터 노출 (additive) - -- Batch ID: BAT-023 -- Profile: batch-lite -- Status: in-progress -- Discovery: trex-tvr-v1 dcy-001 (confirmed) -- Included REQ: REQ-055 -- Folder: docs/batches/bat-023_20260704_portfolios-strategy-market-meta/ -- Created: 2026-07-04 - -## Summary - -REQ-055 구현: `GET /v1/portfolios`(및 단건) 응답 `PortfolioItem`에 전략·시장 메타데이터 4 필드를 additive 노출한다. - -- `active_strategy_id` (str|null) — portfolios.active_strategy_id 원본 -- `strategy_name` (str|null) — strategy_sets.name JOIN 결과 -- `strategy_params` (dict) — strategy_sets.config JSON 파싱 결과; 미바인딩·파싱불가 → `{}` -- `markets` (list[str]) — assets.markets JOIN 결과; 미연결·빈 → `[]` - -기존 16필드·토큰분기·필터는 불변(하위호환 additive). N+1 금지: LEFT JOIN으로 일괄 처리. -trex-core `hub_read.py:168-169`의 `active_strategy_id`·`strategy_params` 파싱 준비와의 계약 필드명 정확히 일치. - -## Included REQ - -| REQ | 제목 | Type | 의존 | -| --- | --- | --- | --- | -| REQ-055 | GET /v1/portfolios 응답에 전략·시장 메타데이터 노출 (additive) | Interface | strategy_sets 테이블, assets.markets 컬럼 (기존) | - -## DoR (진입 조건) - -- [x] REQ-055 Approved 상태 확인 -- [x] strategy_sets 테이블 및 portfolios.active_strategy_id 컬럼 기존 존재 확인 -- [x] assets.markets 컬럼 기존 존재 확인 -- [x] DB 마이그레이션 불필요 확인 (쿼리 JOIN 확장 + 스키마 변경만) - -## DoD (완료 조건) - -- [ ] REQ-055 AC-1~10 테스트 전수 통과 -- [ ] 기존 테스트 회귀 없음 (365+ passed, 0 failures) -- [ ] draft-batch-verification 문서에 AC별 PASS/FAIL evidence 기록 - -## 비가역 결재 게이트 - -- DB 마이그레이션: 없음. strategy_sets·assets.markets 컬럼 기존 존재. alembic 마이그레이션 발생하지 않음. -- HTTP API 계약: additive 확장(기존 필드 불변). 하위호환 — 기존 소비자에 영향 없음. - -## Notes - -- 필드명 4종은 trex-core REQ-116 `active_strategy_id`·`strategy_params`와 상호 잠금 — 정확히 이 이름으로 고정. -- OQ-1(strategy_sets.config TEXT→JSONB 전환)은 본 REQ 범위 밖(서버 측 파싱으로 처리). diff --git a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/planning.md b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/planning.md deleted file mode 100644 index 82906b4..0000000 --- a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/planning.md +++ /dev/null @@ -1,87 +0,0 @@ -# BAT-023 Planning - -## 구현 의존 순서 - -1. **domain/models.py** — `Portfolio` dataclass에 `strategy_name`, `strategy_params`, `markets` 필드 추가 -2. **schemas/v1.py** — `PortfolioItem`에 4개 additive 필드 추가 -3. **repositories/postgres.py** — `_SELECT_PORTFOLIO` 쿼리에 `LEFT JOIN strategy_sets` + `ss.name`, `ss.config`, `a.markets` 컬럼 추가; `_row_to_portfolio` 파싱 보강(config JSON 파싱 + 오류 격리); 단건/리스트 경로 모두 반영 -4. **api/v1_portfolios.py** — `_portfolio_to_item` 매핑에 4개 신규 필드 포함 -5. **tests/test_v1_portfolios_list.py** — AC-1~10 검증 케이스 추가 - -## Task Breakdown - -### T-1: domain/models.py — Portfolio 필드 추가 - -`Portfolio` dataclass에 아래 필드 추가 (기존 `active_strategy_id: str | None = None` 이후): - -```python -strategy_name: str | None = None -strategy_params: dict[str, Any] = field(default_factory=dict) # Any import 필요 -markets: list[str] = field(default_factory=list) -``` - -주의: `Any`는 `typing.Any`. domain/models.py에 현재 미import — 추가 필요. - -### T-2: schemas/v1.py — PortfolioItem 4개 필드 추가 - -`PortfolioItem` 클래스에 기존 `updated_at` 이후: - -```python -active_strategy_id: str | None = None -strategy_name: str | None = None -strategy_params: dict[str, Any] = Field(default_factory=dict) -markets: list[str] = Field(default_factory=list) -``` - -`active_strategy_id`는 이미 Portfolio domain model에 있으나 PortfolioItem 스키마엔 없음 — 이번에 함께 노출. - -### T-3: repositories/postgres.py — 쿼리 + 파싱 보강 - -`_SELECT_PORTFOLIO` 쿼리를: -- `JOIN assets a` → 유지 -- `LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id` 추가 -- SELECT 컬럼: 기존 19개 + `ss.name` (index 19), `ss.config` (index 20), `a.markets` (index 21) 추가 - -`_row_to_portfolio`: -- `strategy_name = row[19] if len(row) > 19 else None` -- `strategy_params`: `row[20]` config 파싱: - - None 또는 빈 문자열 → `{}` - - 유효 JSON → `json.loads(row[20])` - - 파싱 오류 → `{}` + `logger.warning(...)` (500 금지) -- `markets = list(row[21]) if len(row) > 21 and row[21] else []` - -N+1 해소: 이미 SQL JOIN이므로 별도 IN-fetch 불필요. - -### T-4: api/v1_portfolios.py — _portfolio_to_item 매핑 - -`_portfolio_to_item` 함수에 4개 필드 추가: - -```python -active_strategy_id=portfolio.active_strategy_id, -strategy_name=portfolio.strategy_name, -strategy_params=portfolio.strategy_params, -markets=portfolio.markets, -``` - -### T-5: tests/test_v1_portfolios_list.py — AC-1~10 - -기존 `_make_portfolio` / `_FakeListRepo` 헬퍼 확장: -- `_make_portfolio`에 `active_strategy_id`, `strategy_name`, `strategy_params`, `markets` 파라미터 추가 -- AC-1: 바인딩 포트폴리오 → active_strategy_id/strategy_name/strategy_params 정상 반환 -- AC-2: 미바인딩 → active_strategy_id=null, strategy_name=null, strategy_params={} -- AC-3: dangling FK(active_strategy_id 있으나 row 없음) → active_strategy_id 원래 값, strategy_name=null, strategy_params={} -- AC-4: config 파싱불가 → strategy_params={} + WARNING 로그 (caplog) -- AC-5: markets=['KRX','NXT'] → 정상 반환 -- AC-6: markets=[] → [] -- AC-7: 기존 필드 회귀 (portfolio_id, broker, status, symbol, display_name 등) -- AC-8: 토큰분기 불변 (service/session 경로 모두) -- AC-9: strategy_params 원본 JSON 동일 키·값 검증 -- AC-10: 혼합 리스트 (일부 바인딩, 일부 미바인딩) 개별 정확성 - -## Risks / Constraints - -- `_row_to_portfolio` index 오프셋: 기존 row[0~18] = 19개 컬럼. LEFT JOIN 후 row[19]=ss.name, row[20]=ss.config, row[21]=a.markets. index 정확히 맞춰야 기존 필드 회귀 없음. -- LEFT JOIN이므로 active_strategy_id=NULL인 포트폴리오는 ss.name=NULL, ss.config=NULL로 반환 — 정상 처리. -- assets.markets: ARRAY 타입. `row[21]`이 Python list로 반환되므로 `list()` 변환 + None 가드 필요. -- AC-4 WARNING 로그 테스트: `pytest caplog` 사용. 로거 이름은 `app.repositories.postgres`. -- 기존 `_make_portfolio`가 `strategy_name`/`strategy_params`/`markets` 파라미터를 받지 않으므로 `Portfolio` dataclass 기본값으로 처리 — 기존 테스트 영향 없음 (default_factory 보장). diff --git a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/verification.md b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/verification.md deleted file mode 100644 index 60b07b8..0000000 --- a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/verification.md +++ /dev/null @@ -1,70 +0,0 @@ -# BAT-023 Verification - -- Batch ID: BAT-023 -- Status: draft-batch-verification -- Date: 2026-07-04 -- Verifier: portfolio-hub-dev (delivery agent) - -## 실행 환경 - -- Python: 3.12.3 -- pytest: 8.4.2 -- 실행 디렉토리: worktree (`agent-a42c94dc44a9844af`) -- DB: 미연결 (TestClient + mock repo, DB 없이 전수 통과) - -## 전체 pytest 결과 - -``` -583 passed, 11 warnings in 35.08s -``` - -기존 565개(BAT-022 기준) → 583개 (+18, 신규 AC-1~10 케이스 포함). -실패: 0건. 회귀: 없음. - -## AC별 PASS/FAIL - -| AC | 테스트 함수 | 결과 | 비고 | -|---|---|---|---| -| AC-1 | `test_req055_ac1_bound_portfolio_returns_strategy_fields` | PASS | active_strategy_id/strategy_name/strategy_params 정상 반환 | -| AC-2 | `test_req055_ac2_unbound_portfolio_returns_null_strategy` | PASS | active_strategy_id=null → strategy_name=null, strategy_params={} | -| AC-3 | `test_req055_ac3_dangling_fk_returns_null_strategy_but_keeps_strategy_id` | PASS | active_strategy_id 원래 값 유지, JOIN 결과 null 처리 | -| AC-4 | `test_req055_ac4_invalid_config_json_returns_empty_params_and_logs_warning` | PASS | strategy_params={} + WARNING 로그 1건 확인 (caplog) | -| AC-5 | `test_req055_ac5_markets_with_values` | PASS | markets=['KRX','NXT'] 정상 반환 | -| AC-6 | `test_req055_ac6_empty_markets` | PASS | markets=[] 반환 | -| AC-7 | `test_req055_ac7_existing_fields_regression` | PASS | 기존 16필드 회귀 없음 | -| AC-8 | `test_req055_ac8_token_branch_invariant_service_token` | PASS | service token 분기 동작 불변 + 신규 필드 포함 | -| AC-9 | `test_req055_ac9_strategy_params_exact_key_values` | PASS | target_weight/rebalance_band_pct/min_trade_krw 키·값 동일 | -| AC-10 | `test_req055_ac10_mixed_list_individual_accuracy` | PASS | 혼합 리스트 개별 정확성 (바인딩/미바인딩 각각) | - -모든 AC: **10/10 PASS** - -## 변경 파일 목록 - -| 파일 | 변경 내용 | -|---|---| -| `app/domain/models.py` | `Portfolio` dataclass에 `strategy_name`, `strategy_params`, `markets` 3개 필드 추가. `typing.Any` import 추가. | -| `app/schemas/v1.py` | `PortfolioItem`에 `active_strategy_id`, `strategy_name`, `strategy_params`, `markets` 4개 additive 필드 추가. | -| `app/repositories/postgres.py` | `_SELECT_PORTFOLIO` 쿼리에 `LEFT JOIN strategy_sets` + `ss.name`, `ss.config`, `a.markets` 컬럼 추가. `_row_to_portfolio`에 row[19~21] 파싱 로직 추가 (JSON 파싱 + 오류 격리). `json`, `logging` import 추가. | -| `app/api/v1_portfolios.py` | `_portfolio_to_item` 함수에 4개 신규 필드 매핑 추가. | -| `tests/test_v1_portfolios_list.py` | `_make_portfolio` 헬퍼 확장 + REQ-055 AC-1~10 테스트 케이스 10개 추가. | -| `docs/batches/bat-023_.../index.md` | 배치 인덱스 | -| `docs/batches/bat-023_.../planning.md` | 구현 계획 | -| `docs/batches/bat-023_.../design.md` | 설계 문서 | -| `docs/batches/bat-023_.../verification.md` | 본 문서 | - -## 하위호환성 확인 - -- 기존 `PortfolioItem` 16필드 이름·타입 불변 — AC-7에서 직접 검증. -- 신규 4개 필드 모두 `Optional`/`default_factory` → 기존 소비자가 무시해도 안전. -- `_SELECT_PORTFOLIO` 쿼리 변경: `LEFT JOIN` 추가이므로 기존 row 매핑(index 0~18)은 불변. index 19~21 신규. -- DB 마이그레이션: 없음. strategy_sets·assets.markets 컬럼 기존 존재. - -## N+1 검증 - -`_SELECT_PORTFOLIO`에 `LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id` 추가. 단일 SQL로 strategy_name·strategy_params·markets를 일괄 조회. list/단건 경로 모두 동일 쿼리 사용. - -## 주의점 / 미해결 - -- AC-3(dangling FK)은 domain layer 레벨 시뮬레이션으로 검증. 실 DB에서 LEFT JOIN 동작은 배포 후 통합 검증 권장. -- AC-8: session token 분기도 포함해야 하나, service token 경로로 REQ-028 토큰 불변을 대표 검증. 기존 `test_get_user_token_returns_own_portfolios` 회귀 확인. -- 필드명 4종(`active_strategy_id`, `strategy_name`, `strategy_params`, `markets`)은 trex-core REQ-116과 잠금 — 이름 변경 시 소비자 코드 변경 발생. diff --git a/docs/batches/bat-024_20260714_manual-ledger-api/design.md b/docs/batches/bat-024_20260714_manual-ledger-api/design.md deleted file mode 100644 index 2fd7ba7..0000000 --- a/docs/batches/bat-024_20260714_manual-ledger-api/design.md +++ /dev/null @@ -1,69 +0,0 @@ -# BAT-024 Design - -## 결정해야 할 구현 설계점 - -### D-1: reversal target / confirmation metadata 저장 위치 - -REQ-056은 `target_ledger_entry_id` 필수와 confirmation evidence 저장을 요구한다. 구현 전 아래 중 하나를 선택한다. - -- Option A: 기존 metadata/note/context 필드가 있다면 구조화된 JSON metadata로 저장 -- Option B: `portfolio_ledger`에 컬럼 추가(`target_ledger_entry_id`, `source`, `confirmation_metadata`) — migration 필요 -- Option C: note에만 저장 — 감사성이 약하므로 비권장 - -권장: 기존 구조화 metadata가 없고 중복 reversal을 DB 수준에서 검증해야 한다면 Option B를 설계하고 migration을 batch에 포함하되, 운영 DB 적용은 release gate에서 별도 승인한다. - -### D-2: manual endpoint shape - -```http -POST /v1/portfolios/{portfolio_id}/ledger/manual -``` - -단일 action-dispatch body: - -```json -{ - "action": "BUY", - "amount": null, - "quantity": 10, - "price": 80000, - "fee": 100, - "tax": 0, - "target_ledger_entry_id": null, - "note": "manual buy", - "confirm_daemon_managed_manual_write": false, - "confirmation_evidence": null -} -``` - -### D-3: response shape - -```json -{ - "ledger_entry_id": "...", - "portfolio_id": "...", - "action": "BUY", - "source": "manual", - "cash_balance_after": 199900, - "asset_quantity_after": 10, - "profit_buffer_after": 0, - "total_value_after": 999900, - "recorded_at": "..." -} -``` - -### D-4: error codes - -- 403: non-owner or service-token manual endpoint access -- 409 `PORTFOLIO_DISABLED` -- 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION` -- 409 `INSUFFICIENT_CASH` -- 409 `INSUFFICIENT_QUANTITY` -- 409 `INSUFFICIENT_BUFFER` -- 409 `LEDGER_ENTRY_ALREADY_REVERSED` -- 422: invalid action/field/value - -### D-5: verification strategy - -- API TestClient tests for AC-1~17. -- repository/concurrency test for row lock/lost update when feasible. -- regression tests for existing `/ledger/trades`, list/read, CLI/domain flows. diff --git a/docs/batches/bat-024_20260714_manual-ledger-api/implementation.md b/docs/batches/bat-024_20260714_manual-ledger-api/implementation.md deleted file mode 100644 index 6b2c909..0000000 --- a/docs/batches/bat-024_20260714_manual-ledger-api/implementation.md +++ /dev/null @@ -1,6 +0,0 @@ -# BAT-024 Implementation - -- Status: not-started -- Owner: portfolio-hub-dev / delivery-runner - -Implementation evidence will be filled by the delivery owner. diff --git a/docs/batches/bat-024_20260714_manual-ledger-api/index.md b/docs/batches/bat-024_20260714_manual-ledger-api/index.md deleted file mode 100644 index 9911c94..0000000 --- a/docs/batches/bat-024_20260714_manual-ledger-api/index.md +++ /dev/null @@ -1,56 +0,0 @@ -# BAT-024 — owner-scoped 수동 장부 입력 HTTP API - -- Batch ID: BAT-024 -- Profile: standard -- Status: release-candidate -- Discovery: dcy-012_20260714_manual-ledger-write-api-owner-scoped -- Included REQ: REQ-056 -- Folder: docs/batches/bat-024_20260714_manual-ledger-api/ -- Created: 2026-07-14 - -## Summary - -REQ-056 구현: trex-web이 user/session token으로 자기 소유 portfolio에 수동 장부 entry를 append/read할 수 있는 portfolio-hub v1 API를 추가한다. - -기존 `POST /v1/portfolios/{portfolio_id}/ledger/trades`는 trex-core/daemon/service-token broker 체결 보고 전용으로 유지한다. 신규 manual API는 owner-scoped user/session token 전용으로 분리한다. - -## Included REQ - -| REQ | 제목 | Type | 의존 | -| --- | --- | --- | --- | -| REQ-056 | owner-scoped 수동 장부 입력 HTTP API | Interface | dcy-012 confirmed, existing portfolio_ledger/snapshot model | - -## DoR - -- [x] dcy-012 confirmed by Justin Ko -- [x] REQ-056 Approved -- [x] 기존 `/ledger/trades` 보존 정책 확정 -- [x] action/effect, PROFIT/WITHDRAW 수익률 의미, confirmation, validation 정책 확정 - -## DoD - -- [x] REQ-056 AC-1~19 테스트 전수 통과 -- [x] 기존 `/ledger/trades`, `GET /v1/portfolios`, CLI/domain flow 회귀 없음 -- [x] baseline docs(`interface-contract`, `runtime-flows`, `data-model`) 갱신 -- [x] draft-batch-verification 문서에 AC별 PASS/FAIL evidence 기록 -- [x] independent QC PASS 후 lead `confirm-batch-verification` - -## 비가역 결재 게이트 - -- DB migration 가능성: reversal target reference/confirmation metadata 저장 방식에 따라 migration 필요 가능. migration이 필요하면 design에서 명시하고, 운영 DB 적용은 release gate로 분리한다. -- HTTP API 계약: 신규 additive endpoint. 기존 `/ledger/trades` semantics는 변경하지 않는다. -- Security: owner-scoped write API 및 service-token fail-closed가 핵심 gate다. - -## Notes - -- `QUANTITY_ADJUSTMENT`는 1차 제외. -- `PROFIT`은 신규 manual API 필수 action이며 순입금액 계산에는 포함하지 않는다. - - -## Verification / Gate Result - -- Developer verification: PASS — `594 passed, 12 warnings in 37.24s`. -- Independent QC: PASS — delegation `deleg_f2337a39`, HEAD `b3447f6`, `594 passed, 12 warnings in 37.29s`, focused remediation `4 passed`. -- Lead gate: `confirm-batch-verification` accepted 2026-07-14 KST. -- REQ gate: `confirm-req-implemented` accepted for REQ-056. -- Release note: Production rollout still requires release gate; Alembic migration/runbook for additive `portfolio_ledger` columns remains a release risk to decide before production DB rollout. diff --git a/docs/batches/bat-024_20260714_manual-ledger-api/planning.md b/docs/batches/bat-024_20260714_manual-ledger-api/planning.md deleted file mode 100644 index b628ce6..0000000 --- a/docs/batches/bat-024_20260714_manual-ledger-api/planning.md +++ /dev/null @@ -1,67 +0,0 @@ -# BAT-024 Planning - -## 구현 의존 순서 - -1. **현재 구조 재확인** - - `CLAUDE.md`, `README.md`, `pyproject.toml` - - `app/api/v1_portfolios.py`, `app/schemas/v1.py` - - `app/domain/services.py`, `app/repositories/postgres.py`, `app/repositories/portfolio_repository.py` - - auth dependency와 기존 `/ledger/trades` tests -2. **스키마/API 계약** - - `ManualLedgerRequest`, `ManualLedgerResponse`, history/summary response field 정의 - - action enum: `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `BUY`, `SELL`, `WITHDRAW`, `PROFIT`, `REVERSAL` -3. **domain/repository 회계 helper** - - 기존 invest/transfer/buy/sell flow 재사용 또는 공통 helper 추출 - - 부족 cash/quantity/buffer validation - - PROFIT vs WITHDRAW classification - - REVERSAL target lookup/opposite effect/dedup -4. **auth/route 구현** - - `POST /v1/portfolios/{portfolio_id}/ledger/manual` - - owner-scoped user/session token only - - service-token/daemon-token fail-closed - - disabled portfolio 409 - - daemon-managed confirmation 409/allowed flow -5. **history/summary read 구현** - - history response에 `total_value_after` - - `cumulative_profit_withdrawn` 별도 지표 -6. **baseline docs 업데이트** - - `docs/interface-contract.md` - - `docs/runtime-flows.md` - - `docs/data-model.md` -7. **tests** - - API AC-1~17 - - repository/concurrency AC-18 - - regression AC-19 - -## Task Breakdown - -### T-1: Contract/schema -- manual request/response schema와 error body shape 작성. -- extra numeric fields는 schema 또는 validation에서 422로 실패. - -### T-2: Accounting helper/repository -- ledger append + portfolio snapshot update를 단일 transaction/row lock으로 보장. -- accepted action만 허용하고 `QUANTITY_ADJUSTMENT`는 422. -- `target_ledger_entry_id` 저장 위치는 design에서 확정. migration 필요 시 별도 명시. - -### T-3: Route/auth -- owner token만 허용. -- service-token/daemon-token은 manual endpoint 403. -- non-owner 403, disabled 409. -- daemon-managed confirmation 없으면 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION`. - -### T-4: History/summary -- history items에 after-snapshot 4종 포함. -- summary/return response에 `cumulative_profit_withdrawn` 포함. - -### T-5: Docs/tests/verification -- baseline docs 갱신. -- `uv run pytest` 또는 repo 표준 test command로 targeted + regression 수행. -- `verification.md`에 AC별 evidence 기록. - -## Risks / Constraints - -- Auth regression: existing daemon `/ledger/trades`와 user manual endpoint 권한을 혼동하면 보안 결함. -- Accounting regression: PROFIT/WITHDRAW effect는 유사하지만 performance classification이 다름. -- Migration risk: reversal/confirmation metadata를 기존 구조로 담을 수 없으면 migration이 필요하다. -- Concurrency risk: row lock 없이 simultaneous manual writes는 lost update 가능. diff --git a/docs/batches/bat-024_20260714_manual-ledger-api/verification.md b/docs/batches/bat-024_20260714_manual-ledger-api/verification.md deleted file mode 100644 index 7c280cd..0000000 --- a/docs/batches/bat-024_20260714_manual-ledger-api/verification.md +++ /dev/null @@ -1,90 +0,0 @@ -# BAT-024 Verification - -## Status - -- Stage: draft-batch-verification -- Result: PASS (developer self-verification) -- Date: 2026-07-14 KST - -## Scope - -- REQ-056: owner-scoped 수동 장부 입력 HTTP API -- Implemented endpoints: - - `POST /v1/portfolios/{portfolio_id}/ledger/manual` - - `GET /v1/portfolios/{portfolio_id}/ledger/manual` - - `GET /v1/portfolios/{portfolio_id}/returns/summary` -- Preserved endpoint: - - `POST /v1/portfolios/{portfolio_id}/ledger/trades` - -## Evidence - -### Static/syntax - -```text -python3 -m compileall app -exit_code=0 -``` - -### Full regression - -```text -PYTHONPATH=. uv run python -m pytest -q --override-ini addopts='' -594 passed, 12 warnings in 37.24s -exit_code=0 -``` - -Warnings are pre-existing Starlette/FastAPI deprecation warnings around TestClient/httpx and HTTP_422 constants; no failures. - -### Targeted regression - -```text -PYTHONPATH=. uv run python -m pytest -q --override-ini addopts='' tests/test_v1_portfolios_manual_ledger.py tests/test_v1_portfolios_crud.py tests/test_v1_portfolios_trades.py -45 passed, 8 warnings in 5.63s -exit_code=0 -``` - -## AC mapping - -| AC area | Evidence | -| --- | --- | -| Existing `/ledger/trades` remains service-token broker write | `tests/test_v1_portfolios_trades.py` included in targeted regression; existing tests pass | -| Manual endpoint owner-scoped user/session only | `test_manual_write_rejects_service_token`, `test_owner_mismatch_returns_403` | -| Manual BUY effect + after-snapshot | `test_manual_buy_updates_snapshot_and_returns_total_value_after` | -| Insufficient cash/quantity/buffer conflict policy | `test_insufficient_cash_rejected_as_409` plus repository conflict mapping | -| daemon-managed confirmation | `test_daemon_managed_requires_confirmation_then_records_evidence` | -| PROFIT vs WITHDRAW return semantics | `test_profit_is_exposed_separately_from_net_contributed_capital` | -| Manual history includes `total_value_after` | `test_manual_history_returns_total_value_after` | -| Regression: CRUD/list/trades unaffected | Full regression 590 passed; targeted CRUD/trades pass | - - - -## QC FAIL remediation evidence - -The first independent QC run against the pre-amend implementation found three blockers; all were fixed before this verification snapshot: - -- Admin session tokens no longer bypass owner scope for manual write/history/return summary endpoints. -- Amount/reversal actions reject explicit `fee`/`tax` fields with HTTP 422. -- REVERSAL target-not-found now maps to HTTP 404 `LEDGER_ENTRY_NOT_FOUND`. - -Added regression coverage in `tests/test_v1_portfolios_manual_ledger.py`: - -- `test_admin_session_does_not_bypass_manual_owner_scope` -- `test_admin_session_does_not_bypass_manual_read_owner_scope` -- `test_amount_actions_reject_fee_and_tax_fields` -- `test_reversal_rejects_fee_tax_and_missing_target` - -## Independent QC result - -- QC delegation: `deleg_f2337a39` -- Target HEAD: `b3447f6` -- Verdict: PASS -- QC compile evidence: `python3 -m compileall app` → `exit_code=0` -- QC full regression: `PYTHONPATH=. uv run python -m pytest -q --override-ini addopts=''` → `594 passed, 12 warnings in 37.29s` -- QC focused remediation tests: admin owner-scope, manual read owner-scope, fee/tax validation, reversal missing target → `4 passed, 1 warning in 0.92s` -- Lead decision: `confirm-batch-verification` PASS accepted. - -## Notes / follow-up risks - -- The implementation adds additive bootstrap columns via `ensure_schema()` for `portfolio_ledger`: `source`, `target_ledger_entry_id`, `confirmation_evidence`, `actor_user_id`. -- No Alembic migration file was added in this batch. Persistent environment rollout should either rely on `ensure_schema()` bootstrap path or add a release/migration task before production DB rollout. -- `total_value_after` for new manual entries uses cash + asset valuation + profit buffer, matching the dcy-012 interpretation of full portfolio evaluation. diff --git a/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/design.md b/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/design.md deleted file mode 100644 index 659587c..0000000 --- a/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/design.md +++ /dev/null @@ -1,40 +0,0 @@ -# BAT-025 Design - -## D-1. Revision graph and parent - -구현 시작 직전 `uv run alembic heads` 및 `uv run alembic history`로 유효 head를 재확인한다. 신규 revision은 그 실제 head 하나를 `down_revision`으로 사용하며, 문서/코드에 20260628_0027을 선결 가정으로 고정하지 않는다. - -## D-2. Migration idempotency - -신규 revision은 inspector/information_schema 기반으로 table/column 존재를 확인한다. - -- `portfolio_ledger`가 정상적으로 존재하는 일반 경로에서는 네 컬럼을 추가한다. -- runtime `ensure_schema()`가 이미 컬럼을 생성한 경로에서는 컬럼 재생성 없이 nullable/default를 검증·보정한다. -- `source`는 upgrade 전에 NULL을 `'broker'`로 backfill하고 이후 `NOT NULL DEFAULT 'broker'`를 보장한다. -- 기존 행은 삭제·재작성하지 않으며 upgrade 전후 row count와 business field를 비교한다. -- down migration은 운영 forward-only 정책과 충돌하지 않도록 구현 범위를 명시하고, 운영 runbook은 column deletion을 rollback으로 제시하지 않는다. - -## D-3. Runtime boundary - -`app/repositories/postgres.py:ensure_schema()`는 bootstrap safety 경로로 유지한다. Alembic revision은 persistent schema history의 source of truth로 둔다. migration 성공만으로 version table을 임의 stamp하지 않으며, schema와 Alembic current가 함께 일치하는지 검증한다. - -## D-4. Test matrix - -| 시작 상태 | 검증 | -| --- | --- | -| fresh Alembic schema | upgrade, 네 컬럼 타입/nullable/default, current/head | -| 기존 ledger rows | row count/business data 보존, source broker backfill | -| runtime-precreated columns | 중복 DDL 부재, constraints/default 정합, current/head | -| broker write | 기존 `/ledger/trades` 회귀 없음 | -| manual API | source/manual metadata 및 REQ-056 핵심 regression | -| runbook | preflight 조건, 실패 중단, forward-only recovery 문서 정합 | - -## D-5. Operational gate - -이번 batch는 local/non-production verification만 수행한다. 운영 DB backup/upgrade, service restart, 신규 API activation, deploy/push/merge는 M5 Release의 별도 결재 없이는 실행하지 않는다. runbook은 실행 가능한 명령 템플릿과 중단 조건을 제공하되 실제 운영 결과를 가장하지 않는다. - -## Design decisions / open findings - -- 실제 revision ID는 구현 preflight 결과로 결정한다. -- 실제 DB driver availability에 따라 migration integration test harness는 repository 표준 fixture 또는 isolated PostgreSQL 경로를 사용하되, 사용하지 못한 환경은 QC evidence에 명시한다. -- stamp 필요성은 schema inspection과 version table evidence 이후에만 판단하며 자동 조치하지 않는다. diff --git a/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/implementation.md b/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/implementation.md deleted file mode 100644 index cd6d782..0000000 --- a/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/implementation.md +++ /dev/null @@ -1,55 +0,0 @@ -# BAT-025 Implementation Log - -- Date: 2026-07-15 -- Assignee: dev-impl -- Scope: REQ-057 `portfolio_ledger` 수동 장부 메타데이터의 명시 Alembic migration -- Implementation state: remediation complete; independent QC pending - -## Baseline and remediation provenance - -- Initial implementation baseline: `f6195ff8090d8761d467e15f44255642ca350e80`. -- The previously recorded SHA `850e970` does **not** identify the reviewed implementation and is superseded by the baseline above. -- The remediation is committed locally as `779d6f7711d700d6f56be22023cb826cc6f2fe28` on the isolated worktree branch. No push, PR, main merge, production migration, deployment, or service activation was performed. - -## Implemented changes - -1. Explicit revision `20260715_0028` remains the REQ-057 head. - - Parent: `20260628_0027`. - - Adds `source`, `target_ledger_entry_id`, `confirmation_evidence`, and `actor_user_id` additively. - - Handles runtime-precreated columns by backfilling nullable `source`, then converging its default and `NOT NULL` constraint. - - Downgrade remains a forward-only no-op; no column deletion was added. - -2. Remediation of actual PostgreSQL migration-chain/runtime behavior. - - `migrations/versions/20260605_0025_ledger_consolidation.py`: makes the identity `ledger_entry_id` explicitly `nullable=False`. PostgreSQL identity columns are implicitly non-null; the prior explicit trailing `NULL` was incompatible with fresh real-PostgreSQL migration. - - `app/db/migrations.py`: programmatic Alembic configuration now carries the caller-provided `Settings`. For a legacy/runtime-precreated schema, bootstrap stamps `20260628_0027` and then executes `upgrade head`, so revision `0028` performs its required source backfill/default/constraint convergence instead of being skipped by a direct head stamp. - - `migrations/env.py`: consumes the explicit settings when supplied and preserves existing application loggers when Alembic runs in-process. - -3. Real isolated PostgreSQL integration coverage. - - `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py` creates a UUID-named temporary database only through an explicitly supplied loopback `postgres` maintenance DSN. - - The test rejects non-loopback hosts and any maintenance database other than `postgres`; each test terminates connections and drops only its own UUID-named database. - - Coverage includes pre-0028 upgrade/data preservation, runtime-precreated nullable `source` convergence and Alembic current-head state, plus representative broker/manual ledger writes after upgrade. - -4. Existing operation runbook is retained. - - `docs/runbooks/bat-025-req057-portfolio-ledger-manual-metadata-runbook.md` - - It remains a runbook only; no production command in it was executed. - -## Executed evidence - -All commands below used the local Docker PostgreSQL service only. The DSN was supplied from an environment variable and is intentionally omitted here. - -- `BAT025_LOCAL_POSTGRES_DSN=[REDACTED local loopback maintenance DSN] PYTHONPATH=. uv run --extra dev --with httpx2 python -m pytest -q --override-ini addopts='' -p no:cacheprovider tests/test_bat025_portfolio_ledger_manual_metadata_migration.py` - - Result: **3 passed in 6.10s**. - - Verifies real 0027→0028 data preservation/source backfill, runtime-precreated compatibility/current revision, and broker/manual write regression in throwaway DBs. -- `PYTHONPATH=. uv run --extra dev --with httpx2 python -m pytest -q --override-ini addopts='' -p no:cacheprovider` - - Result: **597 passed, 11 warnings in 41.41s**. - - Warnings are pre-existing Starlette `HTTP_422_UNPROCESSABLE_ENTITY` deprecations in endpoint tests; no test failed. -- `uv run --extra dev ruff check app/db/migrations.py migrations/env.py migrations/versions/20260605_0025_ledger_consolidation.py tests/test_bat025_portfolio_ledger_manual_metadata_migration.py` - - Result: **All checks passed**. -- `python3 -m compileall -q app migrations` and `git diff --check` - - Result: exit 0. - -## Limits / non-claims - -- This evidence is for isolated local PostgreSQL only; it is not an operational DB upgrade. -- Lock contention, backup restore, production access control, deployment, service activation, and M5 release remain unexecuted. -- This log is developer evidence only. It does not mark BAT-025 verified or approved; independent QC remains required. diff --git a/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/index.md b/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/index.md deleted file mode 100644 index ed6f369..0000000 --- a/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/index.md +++ /dev/null @@ -1,51 +0,0 @@ -# BAT-025 — portfolio_ledger 명시 Alembic migration - -- Batch ID: BAT-025 -- Profile: standard -- Status: release-candidate (PR #21 independent QC PASS; lead confirmation recorded) -- Discovery: dcy-012_20260714_manual-ledger-write-api-owner-scoped (REQ-057 follow-on) -- Included REQ: REQ-057 -- Folder: docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/ -- Created: 2026-07-15 - -## Summary - -REQ-056/BAT-024가 사용한 `portfolio_ledger` additive metadata 네 컬럼을 runtime `ensure_schema()` 자동 DDL에만 의존하지 않고 명시 Alembic revision으로 추적·검증한다. 기존 broker/manual 데이터 보존, runtime-precreated schema 호환성, 운영 migration runbook 및 독립 QC를 delivery 범위로 둔다. - -## Scope - -- `source TEXT NOT NULL DEFAULT 'broker'` -- `target_ledger_entry_id BIGINT NULL` -- `confirmation_evidence TEXT NULL` -- `actor_user_id TEXT NULL` -- fresh/기존/runtime-precreated schema migration compatibility -- broker ledger regression 및 manual ledger regression -- backup/preflight/current/schema/lock/smoke/forward-only recovery runbook - -## DoR - -- [x] REQ-057 Approved (Justin Ko) -- [x] REQ-056 Implemented 및 BAT-024 release-candidate 유지 확인 -- [x] implementation 시점 `alembic heads`/graph 재확인 요구사항 확인 -- [x] 운영 DB upgrade·service activation은 별도 M5 Release gate로 분리 - -## DoD - -- [x] confirmed planning/design 문서 local main commit (`600375b`) -- [x] dev-impl의 migration/code/test/runbook commit (`f6195ff`, remediation `779d6f7`) -- [x] PR #21 committed-diff independent QC PASS (`deleg_ae0c6f27`; patch-id mapping, loopback PostgreSQL, Ruff, hygiene) -- [x] lead verification confirmation re-recorded after PR #21 QC PASS (2026-07-15) -- [x] M5 Release 미착수 — 별도 release approval 전 운영 migration/deploy/service activation 금지 - -## Gate / Constraints - -- 운영 DB, deploy, service activation, push/merge, credential/account disclosure 금지. -- down migration/column deletion은 운영 rollback 경로로 사용하지 않는다. 복구는 호환 코드 rollback + additive schema 유지의 forward-only 정책이다. -- migration 구현 전 실제 단일 head와 graph를 재확인하고, 결과를 implementation/verification evidence에 기록한다. - -## Documents - -- Planning: [planning.md](./planning.md) -- Design: [design.md](./design.md) -- Implementation: [implementation.md](./implementation.md) (dev-impl 산출) -- Verification: [verification.md](./verification.md) (dev-qc/lead 산출) diff --git a/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/planning.md b/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/planning.md deleted file mode 100644 index d517c67..0000000 --- a/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/planning.md +++ /dev/null @@ -1,49 +0,0 @@ -# BAT-025 Planning - -## Objective - -REQ-057의 명시 Alembic migration과 운영 runbook을 구현·검증한다. BAT-024의 API 계약과 Implemented 상태는 변경하지 않는다. - -## Delivery sequence - -1. **Preflight** — `uv run alembic heads`, `uv run alembic history`, revision graph 및 `ensure_schema()` DDL 소유 경계를 확인하고 실제 head를 evidence로 고정한다. -2. **Migration** — 실제 head를 parent로 하는 신규 revision을 작성한다. fresh/기존/runtime-precreated schema에서 중복 DDL 없이 네 컬럼, source backfill, NOT NULL/default를 보장한다. -3. **Runtime compatibility** — `ensure_schema()` 선행 생성 경로와 Alembic version tracking의 상호작용을 테스트한다. 임의 stamp는 schema/version evidence와 승인된 절차 없이 사용하지 않는다. -4. **Regression tests** — 기존 broker ledger write, manual ledger API, row/data preservation, schema metadata/current 정합을 검증한다. -5. **Runbook** — backup, current, schema/row count, active query/lock, upgrade, post-check, smoke, 실패 대응 및 forward-only recovery를 문서화하고 dry-run 검토한다. -6. **Independent QC** — dev-qc가 committed diff를 직접 검토하고 표준 테스트 및 migration-focused 테스트를 재실행한다. - -## Work packets - -### WP-1: Batch artifacts (lead) - -- 이 문서와 `design.md`, `index.md`를 confirmed 상태로 작성한다. -- `docs/batches/index.md`에 BAT-025를 `confirmed`로 등록한다. -- local main에 docs-only commit을 남긴다. - -### WP-2: Implementation (dev-impl) - -- 신규 Alembic revision, 필요한 runtime compatibility 수정, tests, `docs/runbooks/` runbook을 작성한다. -- implementation log와 실행 명령/결과를 기록하고 implementation commit을 반환한다. -- 운영 DB나 서비스 activation은 실행하지 않는다. - -### WP-3: QC (dev-qc) - -- dev-impl committed diff와 문서/REQ 정합성을 독립 검토한다. -- `uv run pytest` 및 가능한 migration-focused checks를 재실행한다. -- blocker가 있으면 PASS가 아닌 명시적 finding으로 반환한다. - -## Acceptance evidence targets - -- AC-1: head/graph preflight 출력 -- AC-2~5: fresh/legacy/runtime-precreated schema upgrade와 source backfill/중복 오류 부재 -- AC-6: `alembic current`, information_schema, row count 결과 -- AC-7~9: runbook preflight/실패 대응/forward-only recovery 검토 evidence -- AC-10: 운영 실행을 하지 않았다는 scope evidence 및 release gate 기록 -- AC-11: implementation + tests + QC committed evidence - -## Risks / blockers - -- local repository의 schema graph와 runtime-created schema가 서로 다른 시작 상태를 가질 수 있다. -- `ensure_schema()`와 Alembic 양쪽 소유 경계 변경은 기존 broker flow 회귀 위험이 있다. -- 실제 PostgreSQL 운영 DB evidence는 이번 batch 밖이며 M5 Release 승인 없이는 확보하지 않는다. diff --git a/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/verification.md b/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/verification.md deleted file mode 100644 index a5e8b50..0000000 --- a/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/verification.md +++ /dev/null @@ -1,65 +0,0 @@ -# BAT-025 Developer Self-Verification - -- Status: **release-candidate — PR #21 independent QC PASS; lead confirmation recorded** -- Batch: BAT-025 -- Requirement: REQ-057 -- Date: 2026-07-15 -- Scope boundary: local isolated PostgreSQL evidence only. No production DB, deployment, service activation, push, merge, or release action was performed. - -## Acceptance mapping - -| REQ-057 acceptance area | Evidence | Result | -| --- | --- | --- | -| Explicit `0028` revision is the chain head | `alembic_version` queried in a throwaway PostgreSQL DB after upgrade | Pass — current revision `20260715_0028` | -| Existing broker/manual business data survives upgrade | Real `20260628_0027 → head` upgrade after a representative pre-existing ledger row is inserted; row fields and metadata are asserted after upgrade | Pass | -| `source` is backfilled/defaulted/not-null | Real upgrade asserts preserved row `source='broker'`; schema inspection checks text type, non-null constraint, and default | Pass | -| Runtime-precreated metadata schema converges | Real test creates all four columns with nullable `source`, invokes bootstrap, and asserts source backfill/default/not-null plus `current=head` | Pass | -| Representative broker/manual writes still work | Post-upgrade synthetic repository writes assert broker source and manual metadata fields | Pass | -| Earlier fresh PostgreSQL chain can reach 0028 | `0025` identity-column PostgreSQL incompatibility corrected and exercised by fresh throwaway DB migration path | Pass | -| API/manual-ledger regression | `tests/test_v1_portfolios_manual_ledger.py`, `tests/test_v1_portfolios_trades.py`, `tests/test_v1_portfolios_ledger.py` | Pass — 33 tests | -| Whole-repository regression | pytest with project dev extra plus temporary TestClient dependency overlay | Pass — 597 tests, 11 warnings | -| Lint/compile/diff hygiene | Ruff, compileall, `git diff --check` | Pass | - -## Executed commands and results - -- `BAT025_LOCAL_POSTGRES_DSN=[REDACTED local loopback maintenance DSN] PYTHONPATH=. uv run --extra dev --with httpx2 python -m pytest -q --override-ini addopts='' -p no:cacheprovider tests/test_bat025_portfolio_ledger_manual_metadata_migration.py` - - **3 passed in 6.10s**. - - Each test creates and removes a UUID-named database on local loopback PostgreSQL; it refuses non-loopback hosts and non-`postgres` maintenance databases. -- `PYTHONPATH=. uv run --extra dev --with httpx2 python -m pytest -q --override-ini addopts='' -p no:cacheprovider` - - **597 passed, 11 warnings in 41.41s**. - - Warnings are Starlette deprecations for `HTTP_422_UNPROCESSABLE_ENTITY`; they are not BAT-025 failures. -- `uv run --extra dev ruff check app/db/migrations.py migrations/env.py migrations/versions/20260605_0025_ledger_consolidation.py tests/test_bat025_portfolio_ledger_manual_metadata_migration.py` - - **All checks passed**. -- `python3 -m compileall -q app migrations` and `git diff --check` - - exit 0. - -## Evidence boundaries and blockers - -- The local container is an isolated test target, not a staging or production database. -- No production backup/restore, lock-duration measurement under production load, migration run against an operational DB, deploy, or service activation was attempted. -- The test command uses a transient `httpx2` overlay because this environment's resolved Starlette TestClient requires it while the project declares `httpx`; no dependency source change was made for this batch. -- The initial independent QC reproduced the technical evidence but returned FAIL because the then-committed document described the remediation as uncommitted and its test run produced an untracked `uv.lock`. Follow-up commit `71a0312e1efb54772b94fd7de0d8add5943f05af` corrected provenance/hygiene only; independent re-QC inspected that committed diff and passed it. - -## PR #21 provenance reconciliation - -The delivery was implemented and initially verified on the isolated worktree chain. PR #21 was then assembled by cherry-picking that chain onto the confirmed local-main baseline, which created different commit IDs. The mappings are: - -| Original worktree commit | PR #21 commit | Purpose | -|---|---|---| -| `f6195ff8090d8761d467e15f44255642ca350e80` | `1909caa22ce3f7d271dfb619b9095f998d0566cc` | BAT-025 implementation | -| `779d6f7711d700d6f56be22023cb826cc6f2fe28` | `d48279f7abdbabe1eb59a8d85703cf21f28d39d0` | PostgreSQL verification hardening | -| `71a0312e1efb54772b94fd7de0d8add5943f05af` | `2f82251e57cfefacec3dd39c49bfe40d605fe145` | provenance/hygiene correction | -| `da731b554a2cb7823100f32c85e8cab578e0ea36` | `5f8097947c1933f6b5eadeab94e5528680effc10` | lead confirmation state at PR creation | - -- `git diff --exit-code da731b55..5f809794` confirmed that the original verified worktree state and PR #21 initial head have identical file content. -- Lead reproduction after the PR QC environment correction used the tracked local-test loopback defaults without exposing the DSN. The migration suite passed **3 passed in 6.16s**. -- Cleanup verification targets only the fixture contract `portfolio_hub_bat025_<32 lowercase hex UUID>`; its catalog count was **0**. The separately existing `portfolio_hub_bat025_test` database does not match that generated-name contract and was not modified. - -## Gate result - -- Developer self-verification: **complete** on the original worktree remediation chain, with the PR #21 commit mapping above. -- Initial independent QC: **FAIL** — documentation provenance and untracked `uv.lock` hygiene only; technical ACs reproduced. -- Original-worktree independent re-QC: **PASS** (`deleg_6b805aa0`, 2026-07-15), including loopback PostgreSQL and direct Ruff evidence. -- PR #21 committed-diff QC: **PASS** (`deleg_ae0c6f27`, 2026-07-15). It verified the cherry-pick patch-id mapping, PR head `bd51330`, migration semantics, loopback PostgreSQL suite (**3 passed**), fixture-generated UUID catalog cleanup (**0**), direct Ruff checks, clean tested worktree, and PR diff hygiene. -- `confirm-batch-verification`: **PASS accepted by TREX lead (2026-07-15)**. This approval is limited to merging the verified PR; it does not authorize release, deployment, operational database migration, or service restart. -- M5 Release: **not started**. Release preparation requires separate scope/approval after merge; production migration requires a separate operational decision and release approval. diff --git a/docs/batches/index.md b/docs/batches/index.md deleted file mode 100644 index 8c2a080..0000000 --- a/docs/batches/index.md +++ /dev/null @@ -1,35 +0,0 @@ -# Batch Index - -## 목적 -- 이 문서는 batch delivery 단위의 전역 인덱스다. -- 각 batch는 `docs/batches//` 아래에서 profile(`standard` | `batch-lite`)에 맞는 산출물을 관리한다. - -## Batch Register - -| BAT ID | Profile | Status | Included REQ | Discovery | Folder | -| --- | --- | --- | --- | --- | --- | -| BAT-001 | standard | released | REQ-009, REQ-011, REQ-014 | dcy-001 | [bat-001](./bat-001_20260519_asset-catalog-db-foundation/) | -| BAT-002 | standard | released | REQ-010, REQ-012, REQ-013, REQ-015 | dcy-001 | [bat-002](./bat-002_20260519_asset-catalog-api-completion/) | -| BAT-003 | standard | released | REQ-001, REQ-002, REQ-003, REQ-006 | dcy-002 | [bat-003](./bat-003_20260519_tags-db-foundation/) | -| BAT-004 | standard | released | REQ-004, REQ-005 | dcy-002 | [bat-004](./bat-004_20260520_tags-http-api/) | -| BAT-005 | standard | released | REQ-007, REQ-008 | dcy-002 | [bat-005](./bat-005_20260520_pfhub-tag-cli-and-docs/) | -| BAT-006 | standard | released | REQ-016, REQ-017, REQ-018 | dcy-004 | [bat-006](./bat-006_20260520_token-surface-code-fixes/) | -| BAT-007 | standard | released | REQ-022, REQ-023 | dcy-004 | [bat-007](./bat-007_20260520_daemon-lifecycle-token-enforcement/) | -| BAT-008 | standard | released | REQ-019, REQ-020, REQ-021, REQ-024 | dcy-004 | [bat-008](./bat-008_20260521_token-surface-docs/) | -| BAT-009 | standard | released | REQ-025, REQ-026, REQ-027, REQ-028 | dcy-003 | [bat-009](./bat-009_20260521_v1-portfolios-crud-core/) | -| BAT-010 | standard | released | REQ-030, REQ-031, REQ-032 | dcy-003 | [bat-010](./bat-010_20260521_v1-portfolios-sub-resources/) | -| BAT-011 | standard | released | REQ-029 | dcy-003 | [bat-011](./bat-011_20260521_v1-portfolios-ledger-path-migration/) | -| BAT-012 | standard | released | REQ-002, REQ-010 | dcy-001, dcy-002, dcy-003 | [bat-012](./bat-012_20260521_assets-tagging-and-admin-get-redefinition/) | -| BAT-013 | batch-lite | released | REQ-005 | dcy-002 | [bat-013](./bat-013_20260521_asset-tag-apply-remove-fix/) | -| BAT-014 | batch-lite | released | REQ-025, REQ-026, REQ-027, REQ-028, REQ-032 | dcy-003 | [bat-014](./bat-014_20260524_portfolioitem-symbol-response-contract/) | -| BAT-015 | standard | released | REQ-033, REQ-034, REQ-035 | dcy-005 | [bat-015](./bat-015_20260601_rel-001-feedback-cleanup/) | -| BAT-016 | standard | released | REQ-036, REQ-037, REQ-038 | dcy-006 | [bat-016](./bat-016_20260605_ledger-accounting-consolidation/) | -| BAT-017 | standard | released | REQ-039, REQ-040, REQ-041, REQ-042 | dcy-007 | [bat-017](./bat-017_20260618_trade-history-and-pnl-api/) | -| BAT-018 | batch-lite | release-candidate | REQ-043 | dcy-008 | [bat-018](./bat-018_20260625_holdings-cost-basis-endpoint/) | -| BAT-019 | standard | confirmed | REQ-044, REQ-045 | dcy-009 | [bat-019](./bat-019_20260627_assets-read-permission-and-daemon-list/) | -| BAT-020 | standard | release-candidate | REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 | dcy-010 | [bat-020](./bat-020_20260628_daemon-scoped-broker-credential/) | -| BAT-021 | standard | release-candidate | REQ-047 | dcy-010 | [bat-021](./bat-021_20260628_req047-step2-integration-and-runbook/) | -| BAT-022 | standard | release-candidate | REQ-052, REQ-053, REQ-054 | dcy-011 | [bat-022](./bat-022_20260628_daemon-crud-self-service-credential-api/) | -| BAT-023 | batch-lite | in-progress | REQ-055 | trex-tvr-v1 dcy-001 | [bat-023](./bat-023_20260704_portfolios-strategy-market-meta/) | -| BAT-024 | standard | release-candidate | REQ-056 | dcy-012 | [bat-024](./bat-024_20260714_manual-ledger-api/) | -| BAT-025 | standard | release-candidate | REQ-057 | dcy-012 follow-on | [bat-025](./bat-025_20260715_portfolio-ledger-alembic-migration/) | diff --git a/docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md b/docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md deleted file mode 100644 index b38607e..0000000 --- a/docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md +++ /dev/null @@ -1,252 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-001_20260519_asset-catalog-and-user-portfolio-model -- 이슈명: 자산 관리 구조 재설계 — admin 관리 asset catalog와 user portfolio 분리 모델 도입 -- 작성 시각(KST): 2026-05-19T00:00+09:00 -- 마지막 갱신 시각(KST): 2026-05-20T12:00+09:00 (후속 Discovery 참조 추가 — dcy-003 옵션 B 결정으로 REQ-013 Deprecated 처리, 본 Discovery FR-5의 `GET /v1/assets` 통합 endpoint 결정은 dcy-003으로 회수됨) -- 대체됨: 없음 -- 후속 Discovery 참조: docs/discovery/dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md (confirmed 2026-05-20 — path 명명 분리: `GET /v1/assets`는 admin catalog 전용으로 회수, `GET /v1/portfolios` 포지션 조회 신설. 본 Discovery FR-5의 `GET /v1/assets` 통합 endpoint 결정은 REQ-013 Deprecated로 처리 완료) -- 생성된 REQ 참조: REQ-009, REQ-010, REQ-011, REQ-012, ~~REQ-013~~ (Deprecated 2026-05-20 — dcy-003 옵션 B 결정), REQ-014, REQ-015 - -# 1. 계획 상태 요약 -현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. - -- 연계 문서 현황: - - 관련 점수 파일: 미생성 - - 관련 TODO/메모: 없음 -- 해석: - - 현재 `portfolios` 테이블은 asset 정의(symbol, broker)와 user position(cash_balance, quantity)을 하나의 엔티티로 혼합하여 관리한다. 이로 인해 admin이 거래 가능한 종목을 중앙 관리하는 것이 불가능하고, user가 임의 symbol/broker 조합으로 portfolio를 생성할 수 있어 거버넌스 공백이 생긴다. - - 이 Discovery는 portfolio-hub의 핵심 도메인 모델을 재설계하는 첫 번째 단계로, admin이 관리하는 asset catalog와 user가 구성하는 portfolio(포지션)를 명확히 분리하는 새로운 아키텍처를 정의한다. - -# 2. 요구사항 판정 결과 -이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. - -- 판정: 신규 -- 근거 - - 기존 SDLC 문서 전체(dcy-001~008, REQ-001~029)가 현재 단일 `portfolios` 모델 위에 정의되어 있었으며, 2026-05-19 아키텍처 전환 결정에 따라 폐기되었다. 새 모델은 기존 어떤 Discovery와도 중복되지 않는다. - - asset catalog + user portfolio 분리 모델은 기존 SDLC에서 다룬 적 없는 신규 도메인 구조다. - -# 3. 문제점의 요약 -문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. - -- 현재 상태 - - `portfolios` 테이블이 asset 정의(symbol, market/markets)와 user 포지션(broker, cash_balance, asset_quantity, strategy, tags, comments)을 하나의 엔티티로 관리한다. "portfolio를 만든다" = "거래 가능한 종목을 등록한다"와 동일한 행위다. - - admin이 거래 가능한 종목 목록을 중앙 통제할 방법이 없다. user 또는 admin 누구나 임의 symbol/broker 조합으로 portfolio를 생성할 수 있다. - - v1 API의 `asset_id`는 실제로는 `portfolio_id`(user 포지션 ID)로, "자산"과 "포지션"의 개념이 혼용된다. - - daemon은 portfolio(포지션) 단위로 배정되어 있어 동일 종목을 여러 user가 운용할 때 daemon 관리 구조가 복잡해진다. -- 기대 상태 - - `assets` 테이블(admin 관리 catalog)에 거래 가능한 종목(symbol, market)이 등록되며, admin만 이 목록을 생성·수정·비활성화(deactivate)할 수 있다. broker는 catalog 속성이 아니다. - - user는 `assets` catalog에서 종목을 선택하고 거래에 사용할 broker를 지정하여 자신의 `portfolio`(포지션)를 구성한다. 포지션에는 broker, cash_balance, asset_quantity, strategy, tags, comments, 거래 이력이 귀속된다. - - v1 API의 `asset_id`는 catalog의 실제 asset을 참조하며 "자산"과 "포지션"의 개념이 명확히 분리된다. - - daemon은 portfolio(포지션) 레벨로 배정된다. 하나의 daemon이 특정 user의 특정 portfolio들을 담당하며, 동일 종목이라도 user별로 별도 daemon 배정이 가능하다(`portfolios.managed_by_daemon_id` 유지). -- 이해관계자 - - admin: 거래 가능한 종목을 중앙에서 등록·관리하는 운영자 - - user: catalog에서 종목을 선택해 자신의 포지션을 구성하는 투자자 - - daemon(trex-basic 등): 배정된 자산의 거래 체결 내역을 기록하는 서비스 에이전트 - - repository-maintainers: 새 아키텍처 기반으로 API/CLI/DB를 설계·구현하는 개발팀 -- 제약사항 - - 기존 `portfolios` 테이블 데이터(운영 중인 포지션)를 새 `assets` + `portfolios` 구조로 유실 없이 migration해야 한다. - - Python/FastAPI/Typer/Postgres 기술 스택은 유지한다. - - Alembic 기반 migration 절차는 유지한다. - - dcy-002(태그 소유권 및 적용 대상 정책, confirmed)는 이 Discovery의 `assets`/`portfolios` 테이블 확정을 구현 선행 조건으로 한다. `assets.tag_ids UUID[]` 컬럼과 `portfolios.tag_ids UUID[]` 컬럼 추가는 dcy-002 구현 범위이며 이 Discovery에서 다루지 않는다. - -## 주요 사용자 시나리오 -사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. - -- admin이 `AAPL (markets: ['NASDAQ'])`, `005930.KS (markets: ['KRX', 'NXT', 'SOR'])` 등을 asset catalog에 등록한다. `markets`가 빈 배열이면 현재 어떤 시장에서도 거래 불가 상태다. 등록되지 않은 종목은 user가 portfolio에 추가할 수 없다. -- user가 catalog에 등록된 `005930.KS` 종목을 선택하고 사용할 broker(예: kiwoom)를 지정하여 자신의 portfolio(포지션)를 생성하고, cash_balance와 전략을 설정한다. -- daemon이 `GET /v1/assets`를 호출하면 자신에게 `managed_by_daemon_id`로 배정된 portfolio 목록을 반환받고(각 항목의 `asset_id`는 catalog UUID 참조), 그 `asset_id`로 `POST /v1/assets/{asset_id}/ledger/trades`를 호출해 체결 내역을 기록한다. -- admin이 catalog에서 특정 asset을 비활성화(`status='inactive'`)하면 해당 종목에 대한 신규 portfolio 생성은 차단되지만 기존 portfolio는 보존된다. -- user는 동일 asset에 대해 전략 A, B로 각각 portfolio를 구성할 수 있다(OQ-2 결정: 복수 portfolio 허용). - -# 4. 이번에 정의할 변경 -이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. - -- `assets` 테이블(admin 관리 catalog) 신규 도입 — symbol, market, status 등 종목 정의 데이터를 admin이 관리하는 독립 엔티티로 분리한다. broker는 종목 자체의 속성이 아니므로 포함하지 않는다. -- `portfolios` 테이블 재설계 — `asset_id FK → assets.asset_id`를 포함한 user 포지션 구조로 변경. symbol/market은 `assets`로 이전(market은 markets 배열로 집약)하고, broker는 portfolios에 유지하며, 나머지 포지션 데이터(cash_balance, quantity, strategy 등)는 그대로 남긴다. -- 기존 `portfolios` 데이터 migration — 현재 portfolios 행을 `assets` catalog 항목과 `portfolios` 포지션 항목으로 분리 이전한다. - -## 영향 범위 -변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. - -- 코드 영향: - - `app/domain/models.py`: `Asset` 도메인 모델 신규 추가, `Portfolio` 모델에서 symbol/market 필드 제거 및 `asset_id` 참조로 대체. broker는 Portfolio 모델에 유지. - - `app/repositories/postgres.py`: `assets` CRUD 메서드 신규 추가, `portfolios` 쿼리에서 `assets` JOIN 반영. - - `app/repositories/portfolio_repository.py`: Protocol에 asset CRUD 선언 추가 - - `app/api/v1_assets.py`: 토큰 타입(service/user/admin) 분기 로직 추가, `?user_id` 파라미터 추가(admin 전용), 응답 `asset_id`를 catalog UUID로 재정의 - - `app/api/portfolios.py`: portfolio 생성 시 `asset_id` 지정 필수화. GET 목록 조회는 `GET /v1/assets`로 흡수되며 `GET /portfolios`는 점진적 deprecated 예정(mutation 경로는 별도 Discovery에서 v1으로 이전) - - `app/services/portfolio_service.py`: create_portfolio에 `asset_id` 검증 추가. - - `app/cli/commands/asset.py`: 신규 파일 — `pfhub asset create/list/update/deactivate` 커맨드 그룹 구현 (admin 전용) - - `app/cli/main.py`: `app.add_typer(asset_app, name="asset")` 추가 - - `app/cli/commands/portfolio.py`: portfolio create 시 `--asset-id` 또는 `--symbol --broker` 조합으로 catalog 참조 - - `app/schemas/`: `AssetItem`, `CreatePortfolioRequest` 등 스키마 변경 - - `migrations/versions/`: `assets` 테이블 생성 + `portfolios` 재설계(asset_id FK 추가, symbol/market 제거) + 데이터 이전 migration - - ※ 태그 관련 변경(`Tag` 모델, `tags` 테이블, `asset_tags`/`portfolio_tags` 재설계, 태깅 검증 로직)은 모두 dcy-002 구현 범위 -- 문서 영향: - - `docs/data-model.md`: `Asset` 엔티티 추가, `Portfolio` 엔티티 재정의 - - `docs/interface-contract.md`: v1 API asset catalog 기반 재정의 - - `docs/runtime-flows.md`: admin asset 등록 → user portfolio 구성 → daemon 체결 기록 흐름 추가 -- 운영 영향: - - admin이 신규 종목을 portfolio에 추가하기 전 catalog에 먼저 등록해야 하는 운영 절차 추가 - - 기존 운영 중인 portfolios에 대해 migration 전 admin 확인 절차 필요 - -# 5. 요구사항 목록 -실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. - -## 기능 요구사항 -- FR-1: `assets` 테이블 신규 도입 — 필드: `asset_id UUID PK`, `symbol TEXT NOT NULL UNIQUE`, `markets TEXT[] NOT NULL DEFAULT '{}'`, `display_name TEXT`, `status TEXT NOT NULL DEFAULT 'active'`, `created_at`, `updated_at`. admin만 CRUD 가능. `symbol` 단독 UNIQUE constraint 적용(`symbol`이 종목 식별자). `markets` 배열에 거래 가능한 시장 코드를 복수 개 저장하며, 빈 배열은 현재 어떤 시장에서도 거래 불가를 의미한다. `markets` 컬럼에 GIN 인덱스를 적용한다. 시장 코드 허용 값은 application-layer Python Enum으로 정의하며(예: `MarketCode.KRX`, `MarketCode.NXT`, `MarketCode.SOR`, `MarketCode.NASDAQ`, `MarketCode.NYSE`, `MarketCode.UPBIT`, `MarketCode.BITTHUMB`), API 수신 시 대문자 정규화 후 Enum 검증을 수행한다. DB CHECK constraint는 사용하지 않는다. broker는 종목 자체의 속성이 아니므로 포함하지 않는다. -- FR-2: admin asset catalog CRUD 경로 제공 — admin CLI(`pfhub asset create/list/update/deactivate`) 및 HTTP API(`POST/GET/PATCH /v1/assets`, `PATCH /v1/assets/{asset_id}/deactivate`)를 통해 admin이 거래 가능한 종목을 등록·관리한다. asset 삭제는 hard DELETE 없이 `status='inactive'`로만 처리한다(soft delete). 비활성화된 asset을 참조하는 기존 portfolio는 그대로 보존되며 신규 portfolio 생성만 거부한다. -- FR-3: `portfolios` 테이블 재설계 — `asset_id UUID NOT NULL` 컬럼 추가 및 `FOREIGN KEY (asset_id) REFERENCES assets(asset_id) ON DELETE RESTRICT`(DB-level FK constraint) 적용. `symbol`/`market` 컬럼 제거. `broker` 컬럼은 portfolios에 유지한다(user가 해당 포지션을 어느 증권사로 거래하는지는 포지션 레벨 속성). `(owner_user_id, asset_id)` unique constraint는 추가하지 않는다(동일 user가 동일 asset에 복수 portfolio 허용 — OQ-2 결정). `managed_by_daemon_id`는 기존과 동일하게 nullable soft reference로 유지하며, portfolio 생성 시 지정은 선택사항이고 NULL이면 fail-closed(ledger write, asset listing 불가)다. portfolio는 asset catalog에 등록된 종목만 참조할 수 있다. -- FR-4: user portfolio 생성 시 catalog 검증 — `pfhub portfolio create` (CLI) 호출 시 지정한 `asset_id`가 catalog에 `status='active'`로 존재하는지 검증. 미등록 또는 inactive asset이면 거부한다. user portfolio CRUD HTTP API는 Out of Scope이며 별도 Discovery에서 동일한 catalog 검증 정책을 따른다. -- FR-5: v1 API `GET /v1/assets` 재정의 — 토큰 타입에 따라 아래와 같이 동작하는 통합 portfolio 조회 엔드포인트로 재정의한다. 응답 항목의 `asset_id`는 catalog의 `assets.asset_id`(UUID)를 참조한다. - - **service token (daemon)**: auth context에서 `daemon_id`를 자동 추출하여 `managed_by_daemon_id = auth.daemon_id` 필터링. `daemon_id`가 없으면 HTTP 403. - - **user token (로그인 사용자)**: `owner_user_id = auth.user_id` 필터링. 본인 portfolio만 반환. - - **admin token**: 기본은 `owner_user_id = auth.user_id`(본인 portfolio). `?user_id=` 파라미터 지정 시 해당 user의 portfolio를 반환. - - 모든 토큰 타입에서 `?market=` 파라미터를 지원하며, 지정 시 ` = ANY(assets.markets)`인 portfolio만 반환. daemon은 현재 시간 기준 활성 시장 코드를 전달하여 거래 대상 portfolio를 필터링한다. -- FR-6: 기존 `portfolios` 데이터 migration — 현재 portfolios 행의 `symbol`을 기준으로 중복 없이 `assets` catalog 항목을 생성하고, 동일 symbol의 모든 `market` 값을 `ARRAY_AGG(DISTINCT market)`으로 집약하여 `markets` 배열에 저장한다. 각 portfolio 행에 `asset_id FK`를 연결한다. `managed_by_daemon_id`는 기존 값을 그대로 유지한다. - -## 비기능 요구사항 -- NFR-1: 기존 `portfolios` 데이터 migration은 트랜잭션 내에서 원자적으로 실행되며 실패 시 롤백 가능해야 한다. -- NFR-2: 새 구조에서 portfolio 조회 시 `assets JOIN portfolios` 성능이 기존 단일 테이블 조회 대비 허용 범위 내여야 한다 (인덱스 설계 포함). -- NFR-3: admin이 아닌 user 또는 daemon이 asset catalog를 직접 생성·수정·비활성화하려 하면 HTTP 403으로 거부한다. - -## 범위 경계 -이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. - -- In Scope - - `assets` 테이블 도입 및 admin CRUD 경로 (CLI + HTTP API) - - `portfolios` 테이블 재설계 (`asset_id FK` 추가, symbol/market 제거, broker 유지) - - 기존 portfolios 데이터 migration - - v1 `GET /v1/assets`의 catalog 기반 재정의 - - `docs/data-model.md`, `docs/interface-contract.md`, `docs/runtime-flows.md` 갱신 -- Out of Scope - - user portfolio **mutation** HTTP API(POST 생성, PATCH 수정, DELETE 삭제, buy/sell/invest 등) — 별도 Discovery에서 `/v1/` 경로로 이전 예정 - - `/portfolios` GET 목록 조회의 v1 이전 이후 `GET /portfolios` 엔드포인트 제거 — 별도 Discovery에서 처리 - - 태그 소유권 모델 및 태깅 정책 전체 — dcy-002(confirmed) 구현 범위 - - `assets.tag_ids UUID[]` 컬럼 추가 — dcy-002 구현 범위 - - `portfolios.tag_ids UUID[]` 컬럼 추가 및 `portfolio_tags` 테이블 재설계/제거 — dcy-002 구현 범위 - - `tags` 마스터 테이블 생성, 태그 CRUD API(/v1/tags), pfhub tag CLI — dcy-002 구현 범위 - -# 6. 리스크/가정 목록 -불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. - -## 리스크 -- R-1 (High): 기존 `portfolios` 데이터 migration 중 동일 `symbol`을 가진 여러 portfolio 행이 서로 다른 `market` 값을 가질 수 있다. 이를 하나의 asset 행으로 병합하고 `markets` 배열로 집약하는 로직이 필요하며, 집약 규칙이 명확하지 않으면 market 누락이 발생할 수 있다. - - 완화 방안: migration 전 `SELECT symbol, ARRAY_AGG(DISTINCT market) FROM portfolios GROUP BY symbol`로 symbol별 market 집합을 파악하고, `ARRAY_AGG(DISTINCT market)` 결과를 `assets.markets`에 그대로 저장한다. market 값이 NULL인 기존 행은 빈 배열로 처리한다. -- R-2 (High): `portfolios` 테이블에서 symbol/market 컬럼 제거는 이 컬럼들을 직접 참조하는 모든 쿼리, API response, CLI 출력을 수정해야 한다. 누락된 참조가 있으면 런타임 오류가 발생한다. - - 완화 방안: migration 전 `grep -rn "\.symbol\|\.market" app/` 으로 참조 범위를 전수 조사하고 REQ Impacted Area에 명시한다. broker는 portfolios에 유지되므로 별도 조사 불필요. -- R-3 (Medium): `managed_by_daemon_id IS NULL`인 기존 portfolio가 migration 후에도 계속 동작 불가(fail-closed) 상태로 남는다. migration 자체를 막지는 않지만, daemon 미배정 portfolio의 운영 가시성 확보가 필요하다. - - 완화 방안: migration 전 `SELECT COUNT(*) FROM portfolios WHERE managed_by_daemon_id IS NULL`로 미배정 portfolio 현황을 파악하고, 필요한 경우 migration 전 daemon 배정 절차를 선행한다. - -## 가정 -- A-1: 현재 `portfolios` 테이블에서 동일 `symbol`을 가진 행들은 migration 시 하나의 `assets` catalog 항목으로 병합되며, 각 행의 `market` 값은 `ARRAY_AGG(DISTINCT market)`으로 집약되어 `assets.markets`에 저장된다. broker는 각 portfolio 행에 그대로 유지된다. -- A-2: `assets` catalog의 `status` 필드는 최초 `active`/`inactive` 두 가지로 시작하며, 비활성 asset에 대한 신규 portfolio 생성은 거부한다. -- A-3: 기존 `portfolios.name`은 user가 지정하는 포지션 이름으로 유지된다. asset catalog에는 별도 `display_name` 필드를 둔다. -- A-4: portfolio 생성 시 `managed_by_daemon_id` 지정은 선택사항(nullable)이며, 미배정 portfolio는 fail-closed 상태로 운영된다(ledger write·asset listing 불가). daemon 배정은 portfolio 생성과 분리된 admin 작업으로 처리한다. - -## 오픈 질문 -답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. - -- OQ-1: daemon 배정 단위 — daemon을 `assets` catalog 레벨(하나의 daemon이 모든 user의 해당 asset 거래 처리)에 배정할 것인가, `portfolios` 포지션 레벨(user별 별도 daemon 배정)에 배정할 것인가? - - 상태: Answered - - 결정: portfolio(포지션) 레벨 배정. `portfolios.managed_by_daemon_id` 방식 유지. 동일 종목이라도 user별 별도 daemon 배정 가능. - - 반영 위치: Section 3 기대 상태(daemon 배정 단위 확정), FR-5(`GET /v1/assets` portfolio 기준 재정의) -- OQ-2: 동일 asset에 대해 한 user가 여러 portfolio(포지션)를 가질 수 있는가? (예: 같은 종목을 전략 A, B로 각각 운용) - - 상태: Answered - - 결정: 허용. `(owner_user_id, asset_id)` unique constraint 추가하지 않는다. `managed_by_daemon_id`로 동일 asset의 복수 portfolio를 구별한다. - - 반영 위치: FR-3(unique constraint 제외 및 managed_by_daemon_id 정책 명시) -- OQ-3: asset catalog의 `DELETE` 동작 — catalog에서 asset을 삭제할 때 해당 asset을 참조하는 portfolios가 있으면 어떻게 처리하는가? (거부 / cascade 삭제 / soft delete) - - 상태: Answered - - 결정: soft delete(`status='inactive'`). hard DELETE 없음. 기존 portfolio 보존, 신규 portfolio 생성만 거부. - - 반영 위치: FR-2(delete → deactivate로 변경, soft delete 정책 명시) -- OQ-4: `assets.markets` 배열에 저장하는 시장 코드의 허용 값 목록과 정규화 방식을 어떻게 정의하는가? (예: 'KRX', 'NXT', 'SOR', 'NASDAQ', 'NYSE', 'UPBIT' 등 — 대소문자 기준, DB CHECK constraint vs application-layer 정규화 vs 참조 테이블) - - 상태: Answered - - 결정: application-layer Python Enum 정규화. 허용 값: KRX, NXT, SOR, NASDAQ, NYSE, UPBIT, BITTHUMB. API 수신 시 대문자 변환 후 Enum 검증. DB CHECK constraint 없음. 허용 값 변경 시 코드 배포만 필요. - - 반영 위치: FR-1(`markets` 컬럼 설명에 Enum 검증 방식 추가) - -### 오픈 질문 처리 기준 -- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. -- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. -- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. -- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. -- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다. -- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다. -- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다. - -# 7. 초기 성공 기준 -이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. - -- S-1: admin이 CLI 또는 HTTP API로 asset catalog에 종목을 등록하면 해당 `asset_id`가 생성되고, user가 그 `asset_id`로 portfolio를 생성할 수 있다. -- S-2: catalog에 없거나 `status='inactive'`인 `asset_id`로 portfolio 생성을 시도하면 거부된다. -- S-3: 기존 portfolios 데이터가 migration 완료 후 `assets` + `portfolios` 구조로 유실 없이 이전되며, 기존 portfolio 기능(조회, 업데이트, 거래 기록)이 새 구조에서 동일하게 동작한다. -- S-4: v1 `GET /v1/assets`가 daemon에 `managed_by_daemon_id`로 배정된 portfolio 목록을 반환하며, 각 항목의 `asset_id`가 catalog의 UUID를 참조한다. -- S-5: admin이 asset을 비활성화(`status='inactive'`)한 후 해당 `asset_id`로 신규 portfolio 생성을 시도하면 거부되고, 해당 asset을 참조하는 기존 portfolio는 보존된다. -- S-6: user token으로 `GET /v1/assets`를 호출하면 해당 user의 portfolio 목록만 반환된다. admin token으로 `GET /v1/assets`를 호출하면 admin 본인의 portfolio 목록이 반환되고, `?user_id=` 파라미터 지정 시 해당 user의 portfolio 목록이 반환된다. - -## 측정 방식 및 데이터 출처 -각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. - -- S-1 측정 방식: pytest로 admin asset create → user portfolio create(asset_id 지정) 시나리오 검증 / 데이터 출처: 신규 `tests/test_asset_catalog.py` -- S-2 측정 방식: pytest로 미등록 asset_id로 portfolio create 시 오류 반환 확인 / 데이터 출처: 신규 `tests/test_asset_catalog.py` -- S-3 측정 방식: migration 전후 portfolio 행 수 일치 및 기존 portfolio 기능 regression 테스트 통과 확인 / 데이터 출처: migration script 실행 결과 + 기존 테스트 suite -- S-4 측정 방식: daemon service-token으로 `GET /v1/assets` 호출 시 `managed_by_daemon_id` 기반 portfolio 목록과 catalog UUID asset_id 반환 확인 / 데이터 출처: 신규 `tests/test_v1_asset_catalog.py` -- S-5 측정 방식: pytest로 admin asset 비활성화 → 해당 `asset_id`로 신규 portfolio 생성 시 거부 + 기존 portfolio 존재 보존 확인 / 데이터 출처: 신규 `tests/test_asset_catalog.py` -- S-6 측정 방식: pytest로 user token `GET /v1/assets` 호출 시 본인 portfolio만 반환 확인; admin token으로 본인 portfolio 반환 확인 + `?user_id=` 지정 시 해당 user portfolio 반환 확인 / 데이터 출처: 신규 `tests/test_v1_asset_catalog.py` - -# 8. REQ로 넘기기 전 확인 체크 -REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. - -- [x] 범위(In Scope/Out of Scope) 확정 - - 기준: OQ-1(daemon 배정 단위), OQ-2(user당 동일 asset 복수 portfolio), OQ-3(asset 삭제 동작) 모두 Answered 처리되어 scope 경계 최종화됨 -- [x] 성공 지표의 측정 방식/데이터 출처 확정 - - 기준: S-1~6 측정 방식이 검증 가능한 pytest 시나리오로 연결됨 -- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 - - 기준: R-1(migration 중복 처리 규칙), R-2(symbol/market 참조 전수 조사) 완화 방안 명시됨; draft-req 단계에서 REQ Impacted Area에 반영 예정 -- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - - 기준: OQ-1/2/3/4 모두 Answered 처리 완료 -- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 - - 기준: repository-maintainers 중 1명이 Confirmed By에 명시 - -# 9. 파일 처리 결과 -이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. - -- 처리 결과: 생성 -- 생성 경로: docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md -- 참조 문서: - - docs/data-model.md (현재 portfolios 모델 baseline — 이 Discovery로 재정의 예정) - - docs/interface-contract.md (현재 v1 API 계약 — 이 Discovery로 재정의 예정) - - docs/runtime-flows.md (현재 runtime flow baseline — 새 아키텍처 반영 예정) - -# 10. 사용자 결정 필요 항목 요약 -사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. - -## DECIDE -- ~~{{DECIDE_DAEMON_ASSIGNMENT_LEVEL}}~~: **Decided** — portfolio(포지션) 레벨 배정. `portfolios.managed_by_daemon_id` 유지. (OQ-1 Answered 2026-05-19) -- ~~{{DECIDE_MULTI_PORTFOLIO_PER_ASSET}}~~: **Decided** — 허용. `(owner_user_id, asset_id)` unique constraint 없음. `managed_by_daemon_id`로 구별. (OQ-2 Answered 2026-05-19) -- ~~{{DECIDE_ASSET_DELETE_BEHAVIOR}}~~: **Decided** — soft delete(`status='inactive'`). hard DELETE 없음. 기존 portfolio 보존, 신규 생성 거부. (OQ-3 Answered 2026-05-19) -- ~~{{DECIDE_MARKET_CODE_NORMALIZATION}}~~: **Decided** — application-layer Python Enum 정규화. DB CHECK constraint 없음. 허용 값 변경 시 코드 배포만 필요. (OQ-4 Answered 2026-05-19) - -## CONFIRM -- ~~{{CONFIRM_MIGRATION_DEDUP_RULE}}~~: **Confirmed** — A-1(가정)에 동일 내용 명시됨. 동일 symbol → assets 단일 행 병합, market → ARRAY_AGG(DISTINCT market) 집약, broker → 각 portfolio 행 유지. (A-1 참조, 2026-05-19) - -## DATA -- 없음 - -# 11. Discovery Freeze -REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. - -## 섹션 참조 -- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) -- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) -- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) -- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) -- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) - -## Freeze 플래그 -- Handoff Decision: 승인 -- Handoff Rationale: OQ-1~4 모두 Answered, 플레이스홀더 전체 해소, 저장소 사실 관계 일치 확인. REQ 초안 작성 가능. -- Ready for REQ Drafting: true -- Confirmed By: Justin -- Confirmed At (KST): 2026-05-19T00:00+09:00 diff --git a/docs/discovery/dcy-002_20260519_tag-ownership-and-applicability-policy.md b/docs/discovery/dcy-002_20260519_tag-ownership-and-applicability-policy.md deleted file mode 100644 index e860594..0000000 --- a/docs/discovery/dcy-002_20260519_tag-ownership-and-applicability-policy.md +++ /dev/null @@ -1,239 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-002_20260519_tag-ownership-and-applicability-policy -- 이슈명: 태그 소유권 및 적용 대상 정책 — admin/user 구분 태그 모델과 asset·portfolio 적용 정책 도입 -- 작성 시각(KST): 2026-05-19 -- 마지막 갱신 시각(KST): 2026-05-19 (draft-req 완료 — REQ-001~008 생성) -- 대체됨: 없음 -- 후속 Discovery 참조: docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md -- 생성된 REQ 참조: REQ-001, REQ-002, REQ-003, REQ-004, REQ-005, REQ-006, REQ-007, REQ-008 - -# 1. 계획 상태 요약 -현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. - -- 연계 문서 현황: - - 관련 점수 파일: 미생성 - - 관련 TODO/메모: 없음 -- 해석: - - dcy-001에서 태그 정책을 별도 Discovery로 분리하기로 결정했다. 현재 portfolio_tags 테이블은 tag_value 자유 문자열로 태깅하며 소유권 개념이 없다. - - 이번 Discovery에서 판단해야 할 핵심은 tags 마스터 테이블 도입 → admin/user 태그 생성 권한 분리 → asset_tags·portfolio_tags 적용 정책 정의 → 기존 데이터 마이그레이션 전략이다. - -# 2. 요구사항 판정 결과 -이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. - -- 판정: 신규 -- 근거 - - 현재 SDLC 문서에 태그 소유권이나 tags 마스터 테이블 관련 REQ가 존재하지 않는다. - - dcy-001은 태그 정책 전체를 Out of Scope로 명시하며 dcy-002로 이관했다. - -# 3. 문제점의 요약 -문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. - -- 현재 상태 - - portfolio_tags 테이블이 (portfolio_id, tag_value TEXT, created_at)로 구성되어 있으며 태그는 자유 문자열로 저장된다. 정규화된 tags 마스터 테이블이 없어 소유권·권한 제어가 불가능하다. - - 사용자가 임의 문자열을 태그로 등록할 수 있으며, 동일한 개념의 태그가 서로 다른 문자열로 중복 생성될 수 있다. - - asset_tags 테이블이 없다. dcy-001에서 assets 카탈로그를 도입하지만 태그 정책이 정의되지 않으면 assets에 태그를 적용할 수 없다. -- 기대 상태 - - tags 마스터 테이블로 태그를 정규화하고 생성자(created_by_user_id)와 is_admin_tag를 기록해 소유권을 추적한다. - - tags는 hard delete 없이 is_active = FALSE로 비활성화하며, 비활성 태그인지 조회로 확인할 수 있다. - - assets.tag_ids UUID[], portfolios.tag_ids UUID[] 배열 컬럼으로 태그를 저장하고 junction 테이블은 사용하지 않는다. - - admin이 생성한 태그(is_admin_tag = TRUE)만 assets에 적용 가능하고, portfolio에는 is_admin_tag = TRUE이거나 portfolio 소유자가 생성한 태그를 적용 가능하다. - - 기존 portfolio_tags.tag_value 데이터를 새 스키마로 마이그레이션한다. -- 이해관계자 - - 문서 작성자(AI agent) - - 리뷰어/승인자 (시스템 소유자) - - admin 사용자 (태그 생성·asset 태깅·portfolio 태깅) - - 일반 사용자 (태그 생성·자신의 portfolio 태깅) -- 제약사항 - - dcy-001의 assets 테이블이 먼저 확정되어야 assets.tag_ids 컬럼을 추가할 수 있다. - - 기존 portfolio_tags.tag_value 마이그레이션 시 created_by 정보가 없어 기존 태그의 소유자를 소급 지정해야 한다. - -## 주요 사용자 시나리오 - -- admin이 "코스피200" 태그를 생성하고 assets 카탈로그의 삼성전자 항목에 태깅한다. -- 일반 사용자가 "장기보유" 태그를 생성하고 자신의 삼성전자 portfolio에 태깅한다. admin이 생성한 "코스피200"도 동일 portfolio에 추가 태깅한다. -- 일반 사용자가 admin 전용 태그를 asset에 태깅하려 하면 HTTP 403 Forbidden을 반환한다. - -# 4. 이번에 정의할 변경 -이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. - -- tags 마스터 테이블 도입: (tag_id UUID PK, name TEXT NOT NULL UNIQUE, created_by_user_id FK → users.id, is_admin_tag BOOLEAN, is_active BOOLEAN, created_at TIMESTAMPTZ) — soft delete 방식으로 비활성화 -- assets.tag_ids UUID[] 컬럼 추가 — junction 테이블 없이 배열로 태그 참조 저장 -- portfolios.tag_ids UUID[] 컬럼 추가 및 기존 portfolio_tags 테이블 제거 -- 태그 CRUD API: POST /v1/tags, GET /v1/tags, PATCH /v1/tags/{tag_id}/deactivate (비활성화) -- 기존 portfolio_tags.tag_value 데이터 마이그레이션 (tags 테이블 생성 후 portfolios.tag_ids 배열로 이전) - -## 영향 범위 -변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. - -- 코드 영향: - - migrations/versions/ — tags 생성, assets.tag_ids 컬럼 추가, portfolios.tag_ids 컬럼 추가, portfolio_tags 테이블 제거 마이그레이션 - - app/schemas/v1.py — tags 관련 요청/응답 스키마 추가, AssetItem·PortfolioItem에 tag_ids 필드 추가 - - app/routers/ — /v1/tags 엔드포인트 추가 - - app/repositories/ — tags CRUD, assets/portfolios tag_ids 배열 조작 메서드 추가 - - app/cli/ — 기존 `pfhub portfolio tag add/list/remove` 명령어(portfolio_tags 자유 문자열 기반)를 교체하고, 새 `pfhub tag` 최상위 서브커맨드를 추가 (tags 마스터 테이블 기반) -- 문서 영향: - - docs/data-model.md — tags 테이블 추가, assets/portfolios 스키마에 tag_ids 컬럼 반영, portfolio_tags 테이블 제거 - - docs/interface-contract.md — /v1/tags API 명세 추가 -- 운영 영향: - - 마이그레이션 실행 필요 (portfolio_tags.tag_value → tags 생성 후 portfolios.tag_ids 배열 이전, portfolio_tags 테이블 드롭) - - 기존 portfolio_tags 기반 쿼리·API가 있다면 인터페이스 변경 영향 - -# 5. 요구사항 목록 -실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. - -## 기능 요구사항 -- FR-1: tags 마스터 테이블을 생성한다. 스키마: (tag_id UUID PK DEFAULT gen_random_uuid(), name TEXT NOT NULL, created_by_user_id TEXT NOT NULL REFERENCES users(id), is_admin_tag BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()), UNIQUE(name); 태그 생성 시 요청자가 admin 권한이면 is_admin_tag = TRUE로 기록하며 이후 변경하지 않는다. 태그 삭제는 is_active = FALSE로 비활성화하며 hard delete는 사용하지 않는다. -- FR-2: assets 테이블에 tag_ids UUID[] NOT NULL DEFAULT '{}' 컬럼을 추가한다. asset에 태그 적용 시 tags.is_admin_tag = TRUE이고 tags.is_active = TRUE인지 확인하고, 조건 불만족 시 HTTP 403을 반환한다. 비활성 태그(is_active = FALSE)는 적용 불가하며 목록 조회 시 별도 표시한다. -- FR-3: portfolios 테이블에 tag_ids UUID[] NOT NULL DEFAULT '{}' 컬럼을 추가하고 기존 portfolio_tags 테이블을 제거한다. portfolio에 태그 적용 시 tags.is_active = TRUE이고, tags.is_admin_tag = TRUE이거나 tags.created_by_user_id가 해당 portfolio 소유자인지 확인하고, 조건 불만족 시 HTTP 403을 반환한다. -- FR-4: 태그 HTTP API를 제공한다. - - 태그 마스터 관리: POST /v1/tags (태그 생성 — trade-write 이상), GET /v1/tags (태그 목록 조회, is_active 필터 지원 — read 이상), PATCH /v1/tags/{tag_id}/deactivate (태그 비활성화 — admin 또는 태그 생성자 본인) - - asset 태그 적용·해제: POST /v1/assets/{asset_id}/tags (body: {"tag_id": "..."}, asset 태깅 — admin), DELETE /v1/assets/{asset_id}/tags/{tag_id} (asset 태그 해제 — admin) - - portfolio 태그 적용·해제: POST /v1/portfolios/{portfolio_id}/tags (body: {"tag_id": "..."}, portfolio 태깅 — trade-write 이상, 소유자 본인 또는 admin), DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id} (portfolio 태그 해제 — trade-write 이상, 소유자 본인 또는 admin) -- FR-5: 기존 portfolio_tags 데이터를 마이그레이션한다. tag_value를 소문자 정규화 후 tags 테이블에 INSERT(ON CONFLICT DO NOTHING, is_admin_tag = FALSE, created_by_user_id는 마이그레이션 실행 전 운영자가 확인해 지정하는 특정 admin 계정 ID로 설정)하고, 각 portfolio의 tag_ids 배열에 해당 tag_id를 추가한 뒤 portfolio_tags 테이블을 제거한다. 마이그레이션 스크립트는 실행 전 지정 admin 계정이 users 테이블에 존재하는지 사전 검증한다. -- FR-6: 태그 CLI 명령어를 제공한다. `pfhub tag create ` (태그 생성), `pfhub tag list` (태그 목록, --inactive 플래그로 비활성 포함), `pfhub tag deactivate ` (태그 비활성화), `pfhub tag apply asset ` (asset 태깅), `pfhub tag apply portfolio ` (portfolio 태깅), `pfhub tag remove asset ` / `pfhub tag remove portfolio ` (태그 해제) - -## 비기능 요구사항 -성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. - -- NFR-1: tags.name은 UNIQUE 제약으로 전역 중복을 방지한다. 저장 전 애플리케이션 레이어에서 소문자로 정규화(str.lower())하며, DB 컬럼은 TEXT UNIQUE 그대로 사용한다. 동일 이름의 대소문자 변형은 중복으로 처리해 거부한다. -- NFR-2: tags는 hard delete를 사용하지 않으므로 배열 참조 무결성은 애플리케이션 레이어에서 보장한다. tag_ids 배열에서 tag를 조회할 때 is_active = FALSE인 항목은 비활성 표시(archived)로 반환하고 조용히 제거하지 않는다. assets.tag_ids, portfolios.tag_ids에는 GIN 인덱스를 생성해 배열 조회 성능을 확보한다. -- NFR-3: 적용 정책 위반(asset에 non-admin 생성 태그 적용 시도, portfolio에 타인 생성 태그 적용 시도) 시 HTTP 403 Forbidden을 반환한다. - -## 범위 경계 -이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. - -- In Scope - - tags 마스터 테이블 도입 (is_admin_tag, is_active 포함) - - assets.tag_ids UUID[] 컬럼 추가 - - portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거 - - 태그 생성·목록·비활성화 HTTP API (/v1/tags) - - assets/portfolios 태그 적용·해제 API - - 태그 관리 CLI 명령어 (pfhub tag ...) - - 기존 portfolio_tags.tag_value 데이터 마이그레이션 -- Out of Scope - - 태그 이름 변경(rename) — 전파 영향 범위가 커서 별도 검토 필요 - - 태그 계층 구조(parent-child) — 단일 레이블 모델만 지원 - - 태그 검색/자동완성 API — 기본 목록 조회만 포함 - -# 6. 리스크/가정 목록 -불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. - -## 리스크 -- R-1 (High): 기존 portfolio_tags.tag_value 마이그레이션 시 created_by_user_id를 소급 지정할 정보가 없다. 잘못된 소유권 설정이 이후 적용 정책 오류로 이어질 수 있다. - - 완화 방안: 마이그레이션 시 시스템 admin 계정 또는 별도 지정 사용자로 일괄 설정하고, 마이그레이션 완료 후 개별 태그 소유권 재지정 스크립트를 제공한다. -- R-2 (Medium): dcy-001의 assets 테이블이 확정되기 전에 assets.tag_ids 컬럼을 추가하면 스키마 충돌이 발생할 수 있다. - - 완화 방안: dcy-001 확정 및 assets 마이그레이션 완료 후 dcy-002 구현을 시작한다. batch 계획 시 의존 순서를 명시한다. -- R-3 (Low): tags.name UNIQUE 제약 추가 후 기존 portfolio_tags.tag_value에 대소문자만 다른 동일 의미 태그가 존재할 수 있다. - - 완화 방안: 마이그레이션 전 중복 tag_value를 조사하고, 소문자 정규화 또는 선택적 병합 스크립트를 실행한다. - -## 가정 - -- A-1: 태그 생성자가 admin인지는 tags.is_admin_tag(생성 시점 스냅샷)로 판단한다. 이후 권한 변경과 무관하게 고정되므로 런타임 JOIN이 불필요하다. -- A-2: 기존 portfolio_tags.tag_value 마이그레이션 시 충돌 방지를 위해 name을 소문자 정규화 후 UNIQUE 체크한다. -- A-3: 태그 삭제는 is_active = FALSE로 처리(soft delete)하며 hard DELETE 및 CASCADE DELETE를 사용하지 않는다. assets.tag_ids, portfolios.tag_ids의 UUID[] 배열은 비활성 태그를 자동 제거하지 않고 조회 시 archived로 표시한다. - -## 오픈 질문 -답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. - -- OQ-1: tags.name 대소문자 구분 여부 — case-sensitive(기본 TEXT UNIQUE)인지, case-insensitive(CITEXT 또는 lower() 인덱스 기반 UNIQUE)인지. - - 상태: Answered - - 처리 방안: case-insensitive, 애플리케이션 레이어에서 소문자 정규화 후 저장 방식으로 결정. extension 의존성 없이 TEXT UNIQUE 제약을 그대로 사용한다. - - 반영 위치: NFR-1 수정 완료. -- OQ-2: 태그 적용 시 get-or-create 정책 — 태그 이름으로 적용 요청 시 tags 테이블에 없으면 자동 생성(get-or-create)할지, 먼저 태그를 생성한 뒤 tag_id로만 적용을 허용할지. - - 상태: Answered - - 처리 방안: explicit-create 방식으로 결정. 태그 생성(POST /v1/tags)과 태그 적용(apply)을 별도 단계로 분리하며, 존재하지 않는 tag_id로 apply 요청 시 HTTP 404를 반환한다. - - 반영 위치: FR-4(태그 생성·적용 분리 명시), FR-6(CLI create/apply 분리 설계) 현행 유지. -- OQ-3: 마이그레이션 시 기존 portfolio_tags.tag_value의 created_by_user_id를 어느 admin 계정으로 지정할지. - - 상태: Answered - - 처리 방안: 특정 admin 계정을 지정하는 방식으로 결정. 마이그레이션 실행 전 운영자가 대상 admin 계정 ID를 확인해 스크립트에 명시한다. is_admin_tag = FALSE로 설정(기존 태그는 사용자 포트폴리오에서 유래했으므로 asset 적용 대상으로 간주하지 않음). - - 반영 위치: FR-5 수정 완료. -- OQ-4: 태그 생성자가 admin인지 판단하는 방식 — 런타임 JOIN 방식과 생성 시점 스냅샷 방식 중 어느 쪽을 선택할지. - - 상태: Answered - - 처리 방안: 생성 시점 스냅샷 방식으로 결정. tags 테이블에 is_admin_tag BOOLEAN NOT NULL DEFAULT FALSE 컬럼을 추가해 태그 생성 시 권한을 고정 기록한다. - - 반영 위치: FR-1 스키마(is_admin_tag 컬럼 추가), FR-2 검증 로직(is_admin_tag = TRUE 조건), FR-3 검증 로직(is_admin_tag = TRUE OR 소유자 조건), A-1 수정 완료. - -### 오픈 질문 처리 기준 -- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. -- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. -- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. -- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. -- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다. -- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다. -- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다. - -# 7. 초기 성공 기준 -이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. - -- S-1: tags 마스터 테이블이 생성되고 모든 태그가 created_by_user_id와 함께 정규화되어 저장된다. -- S-2: asset_tags에 admin이 생성한 태그만 적용 가능하고, 비admin 생성 태그 적용 시 HTTP 403이 반환된다. -- S-3: portfolios.tag_ids UUID[] 배열로 이전되고 기존 portfolio_tags 테이블이 제거되며, portfolio에 admin 또는 소유자 생성 태그만 적용 가능하다. -- S-4: 기존 portfolio_tags.tag_value 데이터가 새 스키마로 누락 없이 마이그레이션된다. - -## 측정 방식 및 데이터 출처 -각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. - -- S-1 측정 방식: 마이그레이션 실행 후 tags 테이블 row 수가 기존 DISTINCT tag_value 수와 일치하는지 SQL로 확인 / 데이터 출처: 마이그레이션 전후 DB 쿼리 결과 -- S-2 측정 방식: 통합 테스트 — 비admin 사용자 토큰으로 asset 태깅 요청 시 403 응답 검증 / 데이터 출처: pytest 테스트 결과 -- S-3 측정 방식: 통합 테스트 — admin 태그 및 소유자 태그는 portfolio 태깅 성공, 타인 생성 태그 적용 시 403 검증 / 데이터 출처: pytest 테스트 결과 -- S-4 측정 방식: 마이그레이션 후 portfolio별 태그 목록이 기존 tag_value 목록과 일치하는지 SQL로 확인 / 데이터 출처: 마이그레이션 전후 DB 쿼리 결과 - -# 8. REQ로 넘기기 전 확인 체크 -REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. - -- [x] 범위(In Scope/Out of Scope) 확정 - - 기준: 사용자가 In/Out of Scope 목록을 검토하고 승인 - - 확인: confirm-discovery 실행(2026-05-19) — 시스템 소유자 승인 -- [x] 성공 지표의 측정 방식/데이터 출처 확정 - - 기준: 각 성공 기준에 측정 방식과 데이터 출처가 명시됨 - - 확인: S-1~S-4 각각 SQL 검증/pytest 출처 명시됨 (# 7 참조) -- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 - - 기준: R-1(마이그레이션 소유권 문제) 해소 방안과 담당자 확정 - - 확인: R-1 완화 방안 명시됨; FR-5에서 사전 검증 절차 정의; 담당자 — 시스템 소유자(마이그레이션 실행 운영자) -- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - - 기준: OQ 목록에 `Open` 상태 항목이 0개이거나, 잔여 항목 전부 `Deferred` 처리 완료 -- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 - - 기준: 사용자(시스템 소유자)가 CONFIRMED_BY로 지정되어 승인 - - 확인: Justin Ko (genichin@gmail.com) - -# 9. 파일 처리 결과 -이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. - -- 처리 결과: 생성 -- 생성 경로: docs/discovery/dcy-002_20260519_tag-ownership-and-applicability-policy.md -- 참조 문서: - - docs/data-model.md - - docs/interface-contract.md - - docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md - -# 10. 사용자 결정 필요 항목 요약 -사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. - -## DECIDE (완료) -- ~~DECIDE_NAME_CASE~~: OQ-1 Answered — case-insensitive, 애플리케이션 레이어 str.lower() 정규화 후 TEXT UNIQUE 저장 (NFR-1 반영 완료) -- ~~DECIDE_TAG_APPLY_POLICY~~: OQ-2 Answered — explicit-create, 미존재 tag_id apply 시 HTTP 404 (FR-4, FR-6 반영 완료) - -## CONFIRM (완료) -- ~~CONFIRM_MIGRATION_OWNER~~: OQ-3 Answered — 특정 admin 계정 지정, is_admin_tag=FALSE, 마이그레이션 전 계정 존재 사전 검증 (FR-5 반영 완료) - -## DATA -- 없음 — 기존 tag_value 중복 검사는 소문자 정규화 후 UNIQUE 체크 방식으로 접근법 확정(R-3, A-2, NFR-1). 실제 데이터 검증은 마이그레이션 스크립트 사전 검증 단계(FR-5)에서 수행. - -# 11. Discovery Freeze -REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. - -## 섹션 참조 -- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) -- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) -- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) -- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) -- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) - -## Freeze 플래그 -- Handoff Decision: 승인 -- Handoff Rationale: OQ-1~OQ-4 모두 Answered 완료. 범위 확정, Section 8 전체 체크, 저장소 사실 관계 검증 완료. REQ 초안 작성 진행 가능. -- Ready for REQ Drafting: true -- Confirmed By: Justin Ko (genichin@gmail.com) -- Confirmed At (KST): 2026-05-19 diff --git a/docs/discovery/dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md b/docs/discovery/dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md deleted file mode 100644 index 501c50e..0000000 --- a/docs/discovery/dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md +++ /dev/null @@ -1,304 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-003_20260520_user-portfolio-mutation-v1-api-migration -- 이슈명: User Portfolio Mutation v1 API 이전 및 path 명명 분리 (/v1/portfolios* 신설) -- 작성 시각(KST): 2026-05-20T00:00+09:00 -- 마지막 갱신 시각(KST): 2026-05-20T15:00+09:00 (draft-req 완료 — REQ-025~032 8건 생성. FR-1+2 통합, FR-3/4/5/6/7/8/10 각 분리, FR-9는 REQ-013 Deprecated로 처리 완료라 별도 REQ 없음, NFR-1~4 흡수, docs/srs/index.md 갱신) -- 대체됨: 없음 -- 후속 Discovery 참조: docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md (client token 발급 경로 폐지 + 토큰 surface 정비, 2026-05-20 confirmed) -- 생성된 REQ 참조: REQ-025, REQ-026, REQ-027, REQ-028, REQ-029, REQ-030, REQ-031, REQ-032 - -# 1. 계획 상태 요약 -현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. - -- 연계 문서 현황: - - 관련 점수 파일: 미생성 - - 관련 TODO/메모: temp/2026-05-18_todo.md (구현 결함 inventory — `POST /v1/assets/{asset_id}/ledger/trades`의 path param 의미 불일치 항목이 본 Discovery로 해소될 가능성) -- 해석: - - dcy-001(asset catalog 분리, confirmed)이 `# 5. 요구사항 목록 > 범위 경계 > Out of Scope`에서 "user portfolio mutation HTTP API ... 별도 Discovery에서 `/v1/` 경로로 이전 예정"을 명시했고, 본 Discovery가 그 후속에 해당한다. dcy-001 FR-2/FR-5가 정의한 `/v1/assets` path 위에 portfolio CRUD를 더 얹는 대신, path를 `/v1/portfolios*`로 완전 분리(옵션 B)하여 catalog와 포지션의 의미를 갈라낸다. - - 본 Discovery는 dcy-001 FR-5의 "GET /v1/assets = portfolio 조회 endpoint" 결정을 명시적으로 override한다. dcy-001 REQ-009~015는 2026-05-19 일괄 Approved, REQ-013(FR-5 산출물)은 본 Discovery 확정(2026-05-20) 후 change-req(CHG-20260520-01)로 Deprecated 처리 완료. 본 Discovery REQ 작성을 위한 선행 조건은 모두 충족됨. - -# 2. 요구사항 판정 결과 -이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. - -- 판정: 신규 -- 근거 - - 현재 활성 SDLC(docs/discovery/, docs/srs/) 어디에도 user portfolio mutation HTTP API 요구사항이 존재하지 않으며, dcy-001 Out of Scope가 "별도 Discovery에서 처리" 명시 예고를 남겼다. - - `temp/20260519_old_sdlc/discovery/dcy-007_20260518_v1-asset-crud-endpoints.md`는 폐기된 옛 SDLC의 draft 잔재로 active 후보가 아니다. 본 Discovery는 dcy-001(catalog 분리)과 dcy-002(tag_ids 정책)로 재정의된 새 데이터 모델 위에서 path 명명을 분리해 재구성하므로 기존 어떤 주기와도 중복되지 않는다. - -# 3. 문제점의 요약 -문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. - -- 현재 상태 - - v1 API에 portfolio mutation 경로가 존재하지 않는다. `app/api/v1_assets.py`(GET /v1/assets, daemon-scoped 조회), `app/api/v1_user_info.py`(GET /v1/user_info), `app/api/v1_ledger.py`(POST /v1/assets/{asset_id}/ledger/trades) 3종만 라우터에 등록되어 있다. - - `POST /v1/assets/{asset_id}/ledger/trades`의 path parameter 이름은 `asset_id`이나 실제 값은 `portfolios.portfolio_id`(UUID)로, asset과 portfolio의 의미가 한 path에 혼용된다. - - dcy-001 FR-5는 `GET /v1/assets`를 "토큰 타입별 통합 portfolio 조회"로 재정의했지만, 같은 path가 dcy-001 FR-2 정의의 catalog CRUD(`POST/PATCH /v1/assets`)와 포지션 조회 두 의미를 동시에 가져 응답 schema가 토큰 타입별로 갈라진다. - - portfolio 생성/수정/삭제/매수/매도/입출금/이체/태그/코멘트의 도메인 로직은 `app/services/portfolio_service.py`(`create_portfolio`, `update_portfolio`, `delete`, `buy`, `sell`, `invest`, `transfer`, `add_tag`, `remove_tag`, `create_comment`, `update_comment`, `delete_comment` 등)에 이미 구현되어 있으나 v1 라우터로 노출되지 않는다. CLI(`pfhub portfolio ...`)와 비-v1 `app/api/portfolios.py` 라우트만 해당 기능을 노출한다. - - baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md`, `docs/project-structure.md`)는 모두 존재하며 dcy-001/002 변경 사항을 아직 반영하지 않은 직전 baseline 상태다. -- 기대 상태 - - `/v1/assets*` = admin asset catalog 전용 경로(dcy-001 FR-2 정의 범위만 유지). - - `/v1/portfolios*` = user/daemon/admin이 portfolio(포지션)를 CRUD하고 ledger·tags·comments를 다루는 경로 집합. - - `GET /v1/portfolios`는 토큰 타입별 분기 조회(admin: 본인 또는 `?user_id=`로 타 user, user: 본인, daemon: `managed_by_daemon_id` 필터 + `?market` 지원). - - ledger 기록은 `POST /v1/portfolios/{portfolio_id}/ledger/trades`로 이전, path param 이름과 의미가 일치한다. - - portfolio 생성 시 dcy-001 catalog 검증(`asset_id`가 `status='active'`인지 확인) 적용, portfolio 태그 적용 시 dcy-002 tag_ids 정책(is_admin_tag=TRUE 또는 소유자 생성 태그) 준수. -- 이해관계자 - - admin: portfolio HTTP CRUD를 통해 본인 또는 타 user의 포지션을 프로비저닝하는 운영자 - - 로그인 user: 자신의 portfolio를 HTTP로 생성/수정/삭제/거래하려는 사용자 - - daemon (trex-basic): 배정된 portfolio에 대해 조회·ledger 기록을 수행하는 서비스 에이전트. 본 Discovery 시점에 v1 path가 `/v1/portfolios*`로 변경되어 클라이언트 코드 수정이 필요하다 - - repository-maintainers: 본 Discovery의 REQ 작성·구현·검증 담당 -- 제약사항 - - dcy-001 FR-5 결정을 override → dcy-001 REQ-013(Approved)에 change-req 처리 완료(2026-05-20). REQ-013 Status → Deprecated, dcy-001 §0 후속 Discovery 참조 갱신, REQ-010 Notes에 GET /v1/assets catalog 명세 처리 계획 추가. - - 선행 조건 충족: dcy-001 REQ-009~015 모두 Approved 상태(2026-05-19 일괄 승인). 본 Discovery REQ 작성은 즉시 가능. - - dcy-002 portfolios.tag_ids 정책(is_admin_tag 또는 소유자 검증) 준수 - - trex-basic은 본 batch와 동시 배포 필요(한시적 별칭 운영 없이 — OQ-3 Answered 2026-05-20). 구 path는 동일 release에서 제거된다. - - 인증 모델은 현행 Bearer token 체계(env token 또는 DB user/service token + Permission 체계 ADMIN/TRADE_WRITE/READ)를 그대로 사용한다 - -## 주요 사용자 시나리오 -사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. - -- admin이 `POST /v1/portfolios`에 body `{"asset_id": "", "broker": "kiwoom", "cash_balance": 1_000_000, "user_id": "alice-uuid"}`로 요청해 alice의 portfolio를 프로비저닝한다. asset_id가 catalog에 `status='active'`로 존재하지 않으면 거부된다. -- alice가 로그인으로 발급받은 session token으로 `PATCH /v1/portfolios/{portfolio_id}`로 본인 portfolio의 `strategy_name`을 변경한다. bob의 token으로 alice의 portfolio를 수정하려 하면 HTTP 403을 받는다. -- alice가 `DELETE /v1/portfolios/{portfolio_id}`로 본인 portfolio를 종료한다. portfolio는 `status='disabled'`로 전환되고 `managed_by_daemon_id`는 자동 해제되며, ledger·comments·tags는 보존된다(soft delete, OQ-1 Answered 2026-05-20). -- daemon(service-token, daemon-bound)이 `GET /v1/portfolios?market=KRX`를 호출해 KRX 시장 활성 자산에 매핑되고 자신에게 배정된 portfolio만 받는다. -- daemon이 체결 발생 시 `POST /v1/portfolios/{portfolio_id}/ledger/trades`로 idempotent하게 거래 기록을 등록한다. -- user가 `POST /v1/portfolios/{portfolio_id}/tags` body `{"tag_id": ""}`로 태그를 적용한다. 태그가 admin 생성이 아니고 소유자가 아니라면 HTTP 403(dcy-002 FR-3 정책)을 받는다. - -# 4. 이번에 정의할 변경 -이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. - -- `/v1/portfolios*` 신설 — POST/GET/PATCH/DELETE 및 `/ledger/trades`·`/tags`·`/comments` 서브 라우트 일괄 구현. 단일 라우트에서 토큰 타입(admin/user/daemon) 분기로 authorization 처리. -- `GET /v1/assets`의 portfolio 조회 의미 회수 → catalog 전용으로 복원. dcy-001 FR-5 산출물 REQ-013에 change-req(CHG-20260520-01) 적용 완료(Status → Deprecated, 2026-05-20). -- `POST /v1/assets/{asset_id}/ledger/trades`를 `POST /v1/portfolios/{portfolio_id}/ledger/trades`로 이전, path param 이름과 의미를 일치시킴. 동일 batch에서 구 path 제거. -- portfolio 생성 시 dcy-001 catalog 검증(asset_id `status='active'`) 통합, portfolio tag 적용 시 dcy-002 tag_ids 정책 준수. -- baseline 문서 갱신: `docs/interface-contract.md`, `docs/runtime-flows.md` (path 분리, daemon authorization, catalog 검증 흐름 반영) - -## 영향 범위 -변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. - -- 코드 영향: - - `app/api/v1_portfolios.py`: 신규 파일 — `/v1/portfolios*` 라우트 전체 (CRUD + ledger + tags + comments). 토큰 타입 분기 인증 헬퍼(`_require_portfolio_access`) 포함. - - `app/api/v1_assets.py`: GET 동작에서 portfolio 조회 로직 제거, catalog 전용으로 회수. dcy-001 FR-2의 admin catalog CRUD는 유지. - - `app/api/v1_ledger.py`: 신규 path(`/v1/portfolios/{portfolio_id}/ledger/trades`)로 라우트 prefix 이전하거나 `v1_portfolios.py`에 흡수. 구 path는 제거. - - `app/main.py`: v1 라우터 include 목록 갱신. - - `app/schemas/v1.py`: `PortfolioCreateRequest`, `PortfolioUpdateRequest`, `PortfolioItem`, `PortfolioListResponse`, `PortfolioTagApplyRequest`, `PortfolioCommentRequest` 등 신규 schema. 기존 `AssetItem`/`AssetsResponse`는 catalog 응답 의미로 회수 또는 분리. - - `app/services/portfolio_service.py`: 기존 메서드 재사용. `create_portfolio`에 catalog 검증(asset_id active 확인) 추가. - - `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`: 신규 메서드 도입 없음 또는 catalog 검증용 read-path만 추가. 기존 mutation 메서드 재사용. - - `app/api/portfolios.py`(legacy 비-v1 `/portfolios/*` 라우트): 본 Discovery 범위 밖. 제거 시점은 후속 Discovery로 분리. -- 문서 영향: - - `docs/interface-contract.md`: `/v1/portfolios*` 명세 추가, `/v1/assets*`의 의미 회수 반영, daemon authorization model 갱신 - - `docs/runtime-flows.md`: portfolio mutation flow, catalog 검증 flow, /v1/portfolios path 명명 분리 정책, ledger path 이전 흐름 - - `docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md`: §0 후속 Discovery 참조 → dcy-003 추가 완료(2026-05-20), 생성된 REQ 참조에 REQ-013 Deprecated 표기. REQ-013(`docs/srs/Interface/req-013_get-v1-assets-token-branching.md`)은 Deprecated 전환 + Change Log CHG-20260520-01 기록. REQ-010 Notes는 GET /v1/assets catalog 명세 처리 계획 보강 완료. - - `docs/project-structure.md`: `app/api/v1_portfolios.py` 신규 파일 추가 및 라우터 책임 갱신 — 필요 시 baseline 갱신 -- 운영 영향: - - trex-basic은 본 batch release window에 새 path로 동시 배포 필요. release plan에 동시 배포 절차와 verification checklist 명시. - - admin이 HTTP로 타 user portfolio를 생성/삭제할 수 있게 되므로 admin token 발급·관리 절차에 권한 영향 반영 - - DELETE 방식: soft delete로 확정(OQ-1 Answered 2026-05-20). 운영 SOP — portfolio status='disabled' 전환 + admin이 `PATCH status='enabled'`로 복구, 의존 데이터(ledger·comments·tags) 보존, 누적 disabled 정리는 별도 운영 Discovery로 위임 - -# 5. 요구사항 목록 -실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. - -## 기능 요구사항 -- FR-1: `POST /v1/portfolios` 신설 — Bearer 인증 후 portfolio 생성. body 필수 필드: `asset_id`, `broker`. 선택 필드: `name`, `cash_balance`, `asset_quantity`, `asset_price`, `profit_buffer`, `strategy_name`, `strategy_params`, `managed_by_daemon_id`, `user_id`. **토큰 분기 규칙 (OQ-2, OQ-5 Answered 2026-05-20)**: - - (1) `token_type ∈ {service, client}` → HTTP 403 fail-closed. `service`는 daemon-bound이며 portfolio CRUD 주체가 아님(OQ-2). `client`는 발급 경로 자체가 [dcy-004](./dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md)(confirmed 2026-05-20)에서 폐지 확정. 본 Discovery는 폐지 release 이전 운영에서도 v1 portfolio mutation 진입을 사전 차단해 일관성 유지(OQ-5). - - (2) 그 외(`token_type=session` 또는 env token, `token_source` 무관): - - `permission='admin'` → admin 분기. body `user_id` non-null이면 해당 user의 portfolio로, null이면 인증 주체의 user_id로 생성. - - `permission ∈ {read, trade-write}` → user 분기. 인증 주체의 user_id로 생성. body `user_id` 지정은 무시 또는 HTTP 422. - - env admin token과 admin permission을 가진 session token은 admin 분기에서 동일하게 처리된다. -- FR-2: portfolio 생성 시 `asset_id`가 dcy-001 catalog의 `assets` 테이블에 존재하고 `status='active'`인지 검증한다. 미등록 또는 inactive면 HTTP 422(요청 검증) 또는 403(권한/정책) 중 dcy-001 FR-4와 일관된 응답 코드로 거부한다. 검증 위치(서비스 계층 공통 진입점 vs v1 라우터 레이어 한정)는 REQ 작성 단계에서 결정한다 — 공통 서비스 진입점에 두면 CLI 회귀 영향이 있고(R-5), 라우터 레이어에 두면 CLI는 무영향이나 검증 중복이 발생한다. -- FR-3: `PATCH /v1/portfolios/{portfolio_id}` 신설 — Bearer 인증 후 부분 수정. **토큰 분기 규칙 (OQ-2, OQ-5 Answered 2026-05-20, FR-1과 동일 패턴)**: - - (1) `token_type ∈ {service, client}` → HTTP 403 fail-closed. - - (2) 그 외(`token_type=session` 또는 env token): - - `permission='admin'` → 모든 portfolio 수정 가능. - - `permission ∈ {read, trade-write}` → 자기 소유(`owner_user_id = auth.user_id`) portfolio만 수정 가능, 불일치 시 HTTP 403. - - 비존재 portfolio는 HTTP 404. 수정 가능 필드는 기존 `UpdatePortfolioRequest` 범위(`broker`, `status`, `name`, `symbol`, `strategy_name`, `strategy_params`, `asset_price`, `managed_by_daemon_id` 등). -- FR-4: `DELETE /v1/portfolios/{portfolio_id}` 신설 — Bearer 인증 후 portfolio를 **soft delete**한다(OQ-1 Answered 2026-05-20). **토큰 분기 규칙 (OQ-2, OQ-5 Answered 2026-05-20, FR-1과 동일 패턴)**: - - (1) `token_type ∈ {service, client}` → HTTP 403 fail-closed. - - (2) 그 외(`token_type=session` 또는 env token): - - `permission='admin'` → 모든 portfolio 삭제 가능. - - `permission ∈ {read, trade-write}` → 자기 소유 portfolio만 삭제 가능, 불일치 시 HTTP 403. - - 동작: `portfolios.status = 'disabled'`로 전환, `managed_by_daemon_id`는 `NULL`로 자동 해제, `updated_at` 갱신. 의존 데이터(`portfolio_ledger`, `ledger_trades`, `portfolio_comments`, `portfolios.tag_ids`)는 모두 보존한다. disabled 상태 portfolio는 다음 write/도메인 동작이 차단된다: `PATCH`(status 전이 외 필드 변경), `POST /ledger/trades`, tag/comment 추가·수정·삭제. disabled portfolio는 `GET /v1/portfolios` 기본 응답에서 제외하되 `?include_disabled=true`로 조회 가능. 복구는 admin이 `PATCH /v1/portfolios/{portfolio_id}`로 `status='enabled'`(또는 그 전 status)로 전환하면 가능하며, daemon 재배정은 별도 호출로 수행한다. hard delete 경로는 v1에 노출하지 않는다. 비존재 portfolio는 HTTP 404, 이미 `disabled` 상태인 portfolio에 재호출 시 HTTP 200 + 멱등 응답(또는 HTTP 409 — REQ 단계에서 응답 코드 확정). -- FR-5: `GET /v1/portfolios` 신설 — 토큰 분기 조회 (dcy-001 FR-5의 정의를 path만 변경해 흡수). **토큰 분기 규칙 (OQ-5 Answered 2026-05-20)**: - - `token_type='service'`: daemon-bound 조회. `daemon_id is None`이면 HTTP 403 fail-closed. 그 외 `managed_by_daemon_id = auth.daemon_id` 필터 + `?market=` 지원. - - `token_type='client'`: HTTP 403 fail-closed ([dcy-004](./dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md) confirmed 2026-05-20 — 발급 경로 폐지 확정, OQ-5). - - `token_type ∈ {session, None(env)}`: - - `permission='admin'` → 기본 `owner_user_id = auth.user_id`로 본인 portfolio 반환. `?user_id=` 지정 시 해당 user의 portfolio 반환. - - `permission ∈ {read, trade-write}` → `owner_user_id = auth.user_id` 필터, 본인 portfolio만 반환. - - 응답 항목의 `asset_id`는 dcy-001 catalog UUID 참조. 항목 schema는 새 `PortfolioItem`으로 정의. -- FR-6: `POST /v1/portfolios/{portfolio_id}/ledger/trades` 신설 — 기존 `POST /v1/assets/{asset_id}/ledger/trades` 동작을 path만 변경해 그대로 이전. daemon assignment 3단계 fail-closed 인가(token daemon_id NULL → 403, asset 미배정 → 403, 불일치 → 403), idempotency(uq_ledger_trades_idempotency), duplicate/conflict 응답(HTTP 200 status="duplicate" / HTTP 409) 동일 유지. 동일 batch에서 구 path는 제거되어 호출 시 HTTP 404. -- FR-7: portfolio tag 적용/해제/조회 — `POST /v1/portfolios/{portfolio_id}/tags` (body: `{"tag_id": ""}`), `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}`, `GET /v1/portfolios/{portfolio_id}/tags`. dcy-002 FR-3 정책 준수: `tags.is_active=TRUE`이고 `tags.is_admin_tag=TRUE` 또는 `tags.created_by_user_id = portfolio.owner_user_id`인 경우에만 적용 허용. 정책 위반 시 HTTP 403. ownership 규칙은 FR-3과 동일. -- FR-8: portfolio comment CRUD — `POST/GET /v1/portfolios/{portfolio_id}/comments`, `PATCH/DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}`. ownership 규칙은 FR-3과 동일. 기존 `CommentCreateRequest`/`CommentUpdateRequest`/`CommentResponse` schema 재사용 또는 v1 전용 래핑. -- FR-9: dcy-001 FR-5의 "GET /v1/assets = portfolio 조회 endpoint" 결정을 회수한다. `GET /v1/assets`는 dcy-001 FR-2의 admin catalog 조회 전용 의미로 복원. **dcy-001 FR-5 산출물 REQ-013에 change-req CHG-20260520-01 적용 완료(2026-05-20)** — Status → Deprecated, dcy-001 §0 후속 Discovery 참조 갱신, REQ-010 Notes 보강. `GET /v1/assets` catalog 조회 명세는 dcy-001 batch design 단계에서 REQ-010 보강 또는 신규 REQ로 처리 예정. -- FR-10: baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`) 갱신 — `/v1/portfolios*` 신설 명세, `/v1/assets*` 의미 회수, ledger path 이전, daemon authorization model 갱신, portfolio create flow에 catalog 검증 단계 추가. - -## 비기능 요구사항 -성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. - -- NFR-1: 모든 `/v1/portfolios*` 엔드포인트는 Bearer token 필수. 미인증 요청은 HTTP 401, 권한 부족은 HTTP 403, 비존재 자원은 HTTP 404로 통일한다. -- NFR-2: trex-basic과 portfolio-hub는 본 batch release window에서 **동시 배포된다(OQ-3 Answered 2026-05-20 — 한시적 별칭/redirect 채택하지 않음)**. 구 `/v1/assets`의 portfolio 조회 의미와 구 `POST /v1/assets/{asset_id}/ledger/trades` path는 동일 release에서 제거되며, 신 path(`/v1/portfolios*`)만 단일 서빙된다. release plan에 동시 배포 절차, 배포 순서(예: trex-basic 클라이언트 변경 사전 빌드 완료 → portfolio-hub 배포 → trex-basic 배포의 시점 잠금), rollback 조건(portfolio-hub 단독 rollback 시 trex-basic 영향 격리 방법)을 명시한다. -- NFR-3: authorization 분기는 라우터 레이어의 토큰 타입(admin/user/daemon) 분기로 단일 라우트 내에서 처리한다. principal별로 별도 라우트(예: `/v1/admin/portfolios`)를 신설하지 않는다. -- NFR-4: 기존 `app/services/portfolio_service.py`의 메서드 시그니처와 동작은 catalog 검증 통합 외 변경을 최소화한다. 서비스 계층 회귀를 줄이고 라우터 레이어 추가에 집중한다. - -## 범위 경계 -이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. - -- In Scope - - `/v1/portfolios*` 라우트 신설: CRUD + ledger + tags + comments - - `/v1/assets`의 portfolio 조회 의미 회수 (catalog 전용 복원) - - `POST /v1/assets/{asset_id}/ledger/trades` → `POST /v1/portfolios/{portfolio_id}/ledger/trades` 이전 및 구 path 제거 - - portfolio 생성 시 dcy-001 catalog 검증 통합 - - portfolio tag 적용 시 dcy-002 tag_ids 정책 준수 - - baseline 문서 갱신 (interface-contract.md, runtime-flows.md) - - dcy-001 FR-5 change-req 등록 — REQ-013 Deprecated 처리 완료(2026-05-20) - - trex-basic 동시 배포 운영 계획 수립 (절차 상세는 NFR-2 source of truth) -- Out of Scope - - 비-v1 `app/api/portfolios.py`의 legacy `/portfolios/*` 라우트 제거 — 후속 Discovery로 분리 - - 사용자 로그인/세션 시스템 신설 — 현행 Bearer token 체계 유지 - - 매수/매도/입출금/이체 등 거래 동사(`buy`, `sell`, `invest`, `transfer`)의 v1 HTTP 노출 — OQ-4 결정으로 본 Discovery 범위 밖 확정(2026-05-20). v1에서는 `POST /v1/portfolios/{portfolio_id}/ledger/trades`(idempotency key 기반 ledger 기록)만 제공하며, 거래 동사는 현행 CLI(`pfhub portfolio buy/sell/invest/transfer`)로 계속 운용한다. HTTP 노출 필요성이 실증되면 후속 Discovery에서 거래 의미론(사후 기록 vs broker 호출 위임)과 daemon ledger 경로 책임 분리를 별도 정의한 뒤 추가한다. - - daemon(service-token)이 portfolio를 v1 API로 직접 생성·수정·삭제하는 경로 — OQ-2 결정으로 거부 확정(HTTP 403). daemon의 v1 권한은 조회(GET /v1/portfolios)와 ledger 기록(POST /v1/portfolios/{id}/ledger/trades)에 한정한다. - - trex-basic 클라이언트 코드 수정 — portfolio-hub 측 release plan에 동시 배포만 포함, trex-basic 측 변경은 해당 저장소 작업 - -# 6. 리스크/가정 목록 -불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. - -## 리스크 -- R-1 (High): trex-basic 동시 배포(NFR-2) 실패 시 trex-basic이 구 `/v1/assets`(포지션 조회) 또는 구 `POST /v1/assets/{asset_id}/ledger/trades`를 계속 호출해 HTTP 404가 발생하고 거래 기록이 차단되는 운영 장애가 발생할 수 있다. OQ-3에서 한시적 별칭을 채택하지 않기로 결정했으므로 동시 배포 절차의 정확성이 본 리스크의 유일한 방어선이다. - - 완화 방안: (a) release plan에 trex-basic 배포 의존성과 배포 순서 잠금(trex-basic 신 path 클라이언트 빌드 사전 완료 → portfolio-hub 배포 → trex-basic 배포)을 명시. (b) batch verification 단계에서 trex-basic의 신 path 호출 evidence(통합 smoke test 또는 staging 환경 dry-run)를 확보. (c) trex-basic 측 클라이언트가 양방향 호환(구·신 path 둘 다 시도) 가능하도록 일시 코드를 둘지 여부는 trex-basic 저장소 책임으로 분리 — portfolio-hub 측에서는 신 path 단일 서빙. (d) rollback 시나리오: portfolio-hub 단독 rollback이 trex-basic을 깨뜨리므로, rollback은 trex-basic 동반 rollback을 전제로 release plan에 명시한다. -- R-2 (Medium): DELETE를 soft delete로 결정(OQ-1 Answered)했으므로 hard delete로 인한 데이터 손실 리스크는 해소되었으나, `status='disabled'` portfolio가 누적되면 운영 가시성 저하와 `GET /v1/portfolios` 기본 응답 필터링 회귀 가능성이 남는다. 또한 disabled 상태에서 write 차단 경계(ledger trade·tag·comment 거부)가 누락되면 데이터 일관성이 깨질 수 있다. - - 완화 방안: disabled portfolio는 `GET /v1/portfolios` 기본 응답에서 제외하고 `?include_disabled=true` opt-in으로만 노출(FR-5 보강 필요 — REQ 단계에서 명시). 모든 write/도메인 동작 경로(`PATCH` status 외 필드, ledger trade, tag/comment 변경)에 disabled guard를 일관 적용하고 회귀 테스트로 검증한다. 누적 disabled 정리(아카이브, 영구 삭제 운영 SOP)는 별도 운영 Discovery로 위임한다. -- R-3 (해소됨, 잔여 모니터링 Low): dcy-001 FR-5 → REQ-013(Approved)에 대한 change-req 처리는 2026-05-20에 REQ-013 Deprecated 전환으로 완료. dcy-001 batch 일정 영향은 REQ-010 보강(GET /v1/assets catalog 명세) 작업이 batch design 단계에 추가되는 정도로 한정. 잔여 리스크는 REQ-010 보강 작업의 batch 편입 시점이 dcy-001 batch 일정을 지연시킬 가능성. - - 완화 방안: dcy-001 batch planning 시 REQ-010 보강 작업(또는 GET /v1/assets catalog용 신규 REQ)을 명시 항목으로 포함. dcy-003 신규 REQ들(`GET /v1/portfolios` 등)과 dcy-001 batch의 의존성을 batch planning 단계에서 정리. -- R-4 (Medium): `/v1/portfolios*` 신규 라우트가 기존 `app/api/portfolios.py`의 legacy `/portfolios/*` 라우트와 책임 중복을 일으켜 API 사용자 혼동 및 코드 유지보수 비용 증가 가능. - - 완화 방안: 본 Discovery에서 두 라우트 set의 책임 경계와 client guidance(권장 path, deprecation 안내)를 `docs/interface-contract.md`에 명시한다. legacy 제거는 후속 Discovery로 분리하되 본 Discovery 시점에 deprecation 표시. -- R-5 (Low): `app/services/portfolio_service.py`의 메서드 시그니처에 catalog 검증을 추가하면 CLI(`pfhub portfolio create`) 경로에도 영향을 미친다. CLI 회귀 가능성. - - 완화 방안: catalog 검증을 service 계층 공통 진입점에 두되 CLI 경로의 기존 동작과 호환되도록 입력 형태(legacy: symbol+broker, 신규: asset_id)를 모두 처리하거나 CLI 측 입력 변환 단계를 추가한다. REQ 작성 시 CLI 영향 범위 명시. - -## 가정 -아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. - -- A-1: dcy-001 FR-5는 dcy-001 draft-req 단계(2026-05-19)에서 REQ-013(`GET /v1/assets` 통합 endpoint)으로 작성되어 Approved 상태였으며, 본 Discovery 확정(2026-05-20) 후 REQ-013에 change-req(CHG-20260520-01) 적용으로 Deprecated 전환 완료. `GET /v1/assets` catalog 조회 의미는 REQ-010 보강 또는 신규 REQ로 dcy-001 batch design 단계에서 처리. 본 Discovery의 FR-9는 이 처리의 근거 문서다. -- A-2: dcy-002 portfolios.tag_ids 정책(`tags.is_admin_tag=TRUE` 또는 `tags.created_by_user_id = portfolio.owner_user_id`)은 본 Discovery FR-7의 portfolio tag 적용 라우트에서 그대로 적용된다. tag entity 자체의 CRUD는 dcy-002 범위. -- A-3: 기존 `app/services/portfolio_service.py`의 메서드는 catalog 검증을 추가하는 형태로 재사용 가능하다. 서비스 계층의 시그니처 변경은 catalog 검증 통합에 한정하며, 신규 메서드를 대량 추가하지 않는다. -- A-4: trex-basic은 portfolio-hub 본 batch release window 내에 클라이언트 코드를 갱신해 동시 배포 가능하다. 별도 별칭/redirect 운영 없이 deploy 시점 조율만으로 충분하다(OQ-3 Answered 2026-05-20). trex-basic 측 release readiness는 DATA_TREX_BASIC_RELEASE_WINDOW로 외부 확인이 남아 있다. -- A-5: 인증 모델은 현행 `app/core/auth.py`의 Permission 체계(ADMIN/TRADE_WRITE/READ) + `AuthContext`(token_source, token_type, user_id, daemon_id, runtime_id) 기반으로 충분하며, 본 Discovery는 신규 권한 체계나 토큰 종류를 도입하지 않는다. - -## 오픈 질문 -답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. - -- OQ-1: `DELETE /v1/portfolios/{portfolio_id}` 방식 — soft delete(`status='disabled'` 전환, 데이터 보존) vs hard delete(DB row 제거, ledger 함께 또는 `?keep_ledger=true` 옵션 노출)? - - 상태: Answered - - 처리 방안: soft delete로 결정(2026-05-20). `status='disabled'` 전환 + `managed_by_daemon_id` NULL 자동 해제 + 의존 데이터(ledger·comments·tags) 보존. disabled 상태에서 write/도메인 동작은 모두 차단하고, 복구는 admin의 `PATCH status` 전환으로 수행. hard delete 경로는 v1에 노출하지 않는다. - - 반영 위치: FR-4 본문 갱신 완료, R-2 완화 방안 갱신 완료, §10 DECIDE에서 DECIDE_DELETE_STRATEGY 처리 완료. -- OQ-2: `POST /v1/portfolios`에서 daemon(service-token)이 portfolio를 직접 생성할 수 있는가? 허용 시 `owner_user_id`는 token에 연결된 owner_user_id로 강제되는가? 거부 시 HTTP 403 응답 정책은 어떻게 정의하는가? - - 상태: Answered - - 처리 방안: 거부로 결정(2026-05-20). daemon(service-token)은 `POST /v1/portfolios` 호출 시 token_type 검사 단계에서 HTTP 403 + `detail="service token cannot create portfolios"`로 fail-closed 응답한다. daemon은 v1 API에서 portfolio CRUD 주체가 아니며 권한은 조회(GET)와 ledger 기록(POST /ledger/trades)에 한정한다. - - 반영 위치: FR-1 본문 갱신 완료, 범위 경계 Out of Scope 갱신 완료, §10 DECIDE에서 DECIDE_DAEMON_CREATE_ALLOWANCE 처리 완료. -- OQ-3: trex-basic 마이그레이션 전략 — 한시적 별칭(구 `/v1/assets` portfolio 조회와 구 ledger path를 신 path로 redirect 또는 dual-serve) 없이 동시 배포만으로 충분한가, 아니면 별칭/소프트 deprecation 기간이 필요한가? - - 상태: Answered - - 처리 방안: 한시적 별칭/redirect 없이 동시 배포로 결정(2026-05-20). 구 path는 동일 release에서 제거되고 신 `/v1/portfolios*`만 단일 서빙. rollback 시 portfolio-hub 단독 rollback은 trex-basic을 깨뜨리므로 release plan에 trex-basic 동반 rollback 전제를 명시한다. - - 반영 위치: NFR-2 본문 갱신 완료, R-1 완화 방안 갱신 완료, A-4 갱신 완료, §10 DECIDE에서 DECIDE_TREX_MIGRATION_STRATEGY 처리 완료. trex-basic 측 release readiness(DATA_TREX_BASIC_RELEASE_WINDOW)는 외부 확인 항목으로 잔존. -- OQ-4: 매수/매도/입출금/이체 등 거래 동사를 별도 sub-route(`POST /v1/portfolios/{portfolio_id}/buy`, `/sell`, `/invest`, `/transfer`)로 노출할 것인가, `PATCH /v1/portfolios/{portfolio_id}`로 통합할 것인가, 또는 v1에는 `POST /v1/portfolios/{portfolio_id}/ledger/trades`만 노출하고 거래 동사는 CLI 전용으로 분리할 것인가? - - 상태: Answered - - 처리 방안: 옵션 C로 결정(2026-05-20). v1에는 `POST /v1/portfolios/{portfolio_id}/ledger/trades`만 노출하고 거래 동사(`buy`/`sell`/`invest`/`transfer`)는 본 Discovery 범위에서 CLI 전용으로 유지. 근거: (a) ledger 기록 경로 단일화 — daemon ledger/trades와 user 거래 동사 sub-route가 동일 portfolio에 ledger를 작성하는 책임 중복을 방지. (b) §3 user 시나리오에 거래 동사 HTTP 호출이 명시되지 않음 — 본 Discovery 핵심 가치는 CRUD + tags + comments + 조회. (c) v1 API 표면 최소화로 batch 인가/검증/테스트 부하 감소. (d) HTTP 노출이 후속에서 필요해지면 별도 Discovery에서 거래 의미론(사후 기록 vs broker 호출 위임) 정의 후 추가 — 회수 비용 낮음. - - 반영 위치: §5 범위 경계 Out of Scope 갱신 완료, §10 DECIDE에서 DECIDE_TRADE_VERB_ROUTING 처리 완료. FR 추가는 없음(기존 FR-6 ledger/trades 노출로 충분). -- OQ-5: 본 Discovery에서 일관 사용하는 "user token" 개념의 `AuthContext.token_type` 매핑은 무엇인가? 현행 코드의 token_type은 `session`(로그인 발급), `client`(CLI/API 발급), `service`(daemon-bound) 3종이며, env admin token과 DB admin token이 동일하게 admin 분기에 들어가는지도 함께 정의해야 한다. FR-1/FR-3/FR-5의 토큰 분기 정책은 이 매핑이 확정되어야 REQ에서 인가 헬퍼(`_require_portfolio_access` 등)로 구현 가능하다. - - 상태: Answered - - 처리 방안: token_type 게이트 + permission 기준 분기로 결정(2026-05-20). (a) `token_type ∈ {service, client}` → HTTP 403 fail-closed. service는 daemon-bound로 portfolio CRUD 주체가 아님(OQ-2). client는 발급 경로 자체를 [dcy-004](./dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md)(confirmed 2026-05-20)에서 폐지 확정 — 본 Discovery는 폐지 release 이전 운영에서도 v1 portfolio mutation 진입을 차단해 일관성 유지. (b) 그 외(`token_type=session` 또는 env token)는 `permission` 기준 분기 — admin이면 admin 분기, `read`/`trade-write`이면 user 분기. `token_source`(env/db)는 분기 기준에 사용하지 않으며 env admin token과 admin permission을 가진 session token은 admin 분기에서 동일 처리(dcy-004 OQ-5와 동일 결정). GET /v1/portfolios(FR-5)에서도 client 토큰은 거부되며 service 토큰은 daemon-bound 조회 의미로 별도 분기에서 정상 처리. - - 반영 위치: FR-1 본문 갱신 완료, FR-3 본문 갱신 완료, FR-4 본문 갱신 완료, FR-5 본문 갱신 완료, §3 사용자 시나리오 "session token"으로 명시 변경 완료, §10 DECIDE에서 DECIDE_USER_TOKEN_MAPPING 처리 완료. client token 발급 경로 폐지는 dcy-004(confirmed 2026-05-20)에서 확정되어 §10 DATA_CLIENT_TOKEN_DEPRECATION_DISCOVERY는 Collected 상태로 종료. - -### 오픈 질문 처리 기준 -- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. -- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. -- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. -- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. -- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다. -- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다. -- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다. - -# 7. 초기 성공 기준 -이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. - -- S-1: `POST /v1/portfolios`를 admin/user 토큰으로 호출했을 때 토큰 타입별 ownership 규칙(admin + user_id body로 타 user 생성, user 본인 생성)이 정확히 적용되고, daemon(service-token) 호출은 HTTP 403으로 fail-closed 거부되며, dcy-001 catalog 검증(asset_id active hit/miss 케이스)이 기대대로 응답한다. -- S-2: `PATCH /v1/portfolios/{id}`와 `DELETE /v1/portfolios/{id}`에서 ownership 불일치 시 HTTP 403, 미인증 시 HTTP 401, 비존재 portfolio 호출 시 HTTP 404를 반환한다. daemon(service-token)이 PATCH/DELETE를 호출하면 token_type 검사 단계에서 HTTP 403 fail-closed로 거부된다(FR-1 CREATE 거부와 동일 패턴). DELETE 호출 후 `portfolios.status='disabled'` + `managed_by_daemon_id IS NULL` 사후 상태 및 의존 데이터(ledger/comments/tags) 보존이 검증 가능하다. disabled portfolio에 대한 write 차단(PATCH status 외 필드, ledger trade, tag/comment 변경)이 일관 적용된다. -- S-3: `GET /v1/portfolios`가 service token + daemon-scoped 필터, user token 본인 portfolio, admin token + `?user_id`로 타 user portfolio, `?market` 필터를 모두 정확히 처리한다. NULL daemon_id service token은 fail-closed(HTTP 403). -- S-4: `POST /v1/portfolios/{portfolio_id}/ledger/trades`가 구 `POST /v1/assets/{asset_id}/ledger/trades`와 동일한 idempotency, daemon assignment authorization 3단계, duplicate/conflict 응답(HTTP 200/409)을 보장한다. 동일 batch에서 구 path는 HTTP 404를 반환한다. -- S-5: `/v1/portfolios/{portfolio_id}/tags`와 `/v1/portfolios/{portfolio_id}/comments`가 ownership 규칙과 dcy-002 tag_ids 정책을 위반한 호출에 대해 HTTP 403을 반환한다. -- S-6: trex-basic이 본 batch release window에 새 path로 무중단 이전되어 release 후 portfolio-hub `/health`는 200 OK를 반환하고 sample ledger write가 정상 성공한다. -- S-7: baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`)에 `/v1/portfolios*` 명세, `/v1/assets*` 의미 회수, ledger path 이전 흐름이 모두 반영되어 후속 REQ/Batch가 해당 문서를 단일 source of truth로 사용할 수 있다. - -## 측정 방식 및 데이터 출처 -각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. - -- S-1 측정 방식: pytest로 admin/user principal별 create 시나리오(catalog active hit/miss, ownership 정상/위반, admin `user_id` body 처리) + daemon principal 호출 시 HTTP 403 거부 회귀를 검증 / 데이터 출처: 신규 `tests/test_v1_portfolios_crud.py` -- S-2 측정 방식: pytest로 PATCH/DELETE의 ownership 위반·미인증·비존재 케이스 검증 + daemon(service-token) 호출 시 HTTP 403 fail-closed 거부 회귀 + DELETE 사후 상태(`status='disabled'`, `managed_by_daemon_id IS NULL`, 의존 데이터 보존) 확인 + disabled portfolio에 대한 후속 write(ledger/tag/comment) 차단 회귀 검증 / 데이터 출처: 신규 `tests/test_v1_portfolios_crud.py` -- S-3 측정 방식: pytest로 GET /v1/portfolios 토큰별 분기, `?user_id`, `?market` 필터, NULL daemon_id 403 회귀 검증 / 데이터 출처: 신규 `tests/test_v1_portfolios_list.py` -- S-4 측정 방식: 기존 `tests/test_v1.py`의 ledger trade idempotency·daemon authorization 케이스를 신 path로 갱신해 회귀 통과 + 구 path 호출 시 HTTP 404 확인 / 데이터 출처: 갱신 `tests/test_v1.py`, 신규 `tests/test_v1_portfolios_ledger.py` -- S-5 측정 방식: pytest로 tag/comment CRUD 시나리오와 dcy-002 정책(is_admin_tag, 소유자 검증) 위반 케이스 검증 / 데이터 출처: 신규 `tests/test_v1_portfolios_tags_comments.py` -- S-6 측정 방식: release verification checklist의 health endpoint + sample portfolio create → ledger write smoke / 데이터 출처: 본 Discovery 후속 release 문서의 verification 섹션 -- S-7 측정 방식: baseline 문서 갱신 후 docs/interface-contract.md, docs/runtime-flows.md를 검토해 `/v1/portfolios*` 명세, `/v1/assets*` 의미 회수, ledger path 이전 항목이 모두 반영되었는지 확인 / 데이터 출처: 갱신된 baseline 문서 - -# 8. REQ로 넘기기 전 확인 체크 -REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. - -- [x] 범위(In Scope/Out of Scope) 확정 - - 기준: OQ-1~5 전체 Answered 완료(2026-05-20). 토큰 분기 정책까지 명확해 In/Out of Scope 경계 최종화 완료. CONFIRM 2건 + DATA 2건은 §10에 잔존(사용자 확인 + 외부 데이터 + 후속 Discovery 작성 필요). -- [x] 성공 지표의 측정 방식/데이터 출처 확정 - - 기준: S-1~7 측정 방식이 §7 측정 방식 섹션에 각각 신규/갱신 pytest 시나리오(`tests/test_v1_portfolios_crud.py`, `tests/test_v1_portfolios_list.py`, `tests/test_v1_portfolios_ledger.py`, `tests/test_v1_portfolios_tags_comments.py`) 또는 release verification 체크 항목(S-6/S-7)으로 명시 연결됨. -- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 - - 기준: R-1(trex-basic 동시 배포) 완화 방안 4단계(a~d) 명시, R-2(disabled 누적·write 차단) 완화 방안 명시, R-3 해소(REQ-013 Deprecated 처리 완료), R-4(legacy 라우트 책임 경계)·R-5(CLI 회귀) 완화 방안 명시. 책임자 = repository-maintainers(본 Discovery 단계). REQ 작성 시 작업별 책임자가 REQ Impacted Area에 반영될 예정. -- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - - 기준: OQ-1~5 전체 Answered 완료(2026-05-20). Open 상태 OQ 0건. -- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 - - 기준: §11 Confirmed By에 Justin 지정 완료(2026-05-20). -- [x] dcy-001 FR-5 change-req 처리 계획 확정 + 실행 - - 기준: REQ-013(Approved, dcy-001 FR-5 산출물)에 change-req CHG-20260520-01 적용 완료 (Status → Deprecated, 2026-05-20). dcy-001 §0 후속 Discovery 참조 갱신 + REQ-010 Notes 보강 처리 완료. 후속: `GET /v1/assets` catalog 명세는 dcy-001 batch design 단계에서 REQ-010 보강 또는 신규 REQ로 처리. - -# 9. 파일 처리 결과 -이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. - -- 처리 결과: 생성 -- 생성 경로: docs/discovery/dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md -- 참조 문서: - - docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md (선행 Discovery — FR-5 override 대상, Out of Scope 후속 예고의 본 Discovery 해당) - - docs/discovery/dcy-002_20260519_tag-ownership-and-applicability-policy.md (병행 Discovery — portfolio tag 적용 정책 준수) - - docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md (후속 Discovery, confirmed 2026-05-20 — client token 발급 경로 폐지 + 토큰 surface 정비. 본 Discovery의 FR-1/3/4/5 token_type 게이트가 dcy-004 폐지 결정과 정합) - - temp/20260519_old_sdlc/discovery/dcy-007_20260518_v1-asset-crud-endpoints.md (참조 — 폐기된 옛 SDLC의 draft 초안, FR/OQ 일부 승계 및 재구성) - - docs/interface-contract.md (현행 v1 API 계약, 본 Discovery로 갱신 예정) - - docs/runtime-flows.md (현행 v1 API flow, 본 Discovery로 갱신 예정) - - docs/project-structure.md (현행 패키지/모듈 baseline, v1 라우터 파일 추가에 따라 일부 영향) - -# 10. 사용자 결정 필요 항목 요약 -사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. - -## DECIDE -- ~~{{DECIDE_DELETE_STRATEGY}}~~: **Decided** — soft delete(`status='disabled'` 전환, 데이터 보존)로 확정. hard delete 경로는 v1에 노출하지 않음. FR-4·R-2·OQ-1 본문 반영 완료. (OQ-1 Answered 2026-05-20) -- ~~{{DECIDE_DAEMON_CREATE_ALLOWANCE}}~~: **Decided** — daemon(service-token)의 `POST /v1/portfolios` 호출 거부 확정. HTTP 403 + `detail="service token cannot create portfolios"` fail-closed 응답. FR-1·범위 경계 Out of Scope·OQ-2 본문 반영 완료. (OQ-2 Answered 2026-05-20) -- ~~{{DECIDE_TREX_MIGRATION_STRATEGY}}~~: **Decided** — 한시적 별칭/redirect 없이 trex-basic + portfolio-hub 동시 배포로 확정. 구 path는 동일 release에서 제거. rollback 시 trex-basic 동반 rollback 전제. NFR-2·R-1·A-4·OQ-3 본문 반영 완료. (OQ-3 Answered 2026-05-20) -- ~~{{DECIDE_TRADE_VERB_ROUTING}}~~: **Decided** — 옵션 C로 확정. v1에는 `POST /v1/portfolios/{portfolio_id}/ledger/trades`만 노출, 거래 동사(buy/sell/invest/transfer)는 본 Discovery 범위에서 CLI 전용 유지. ledger 기록 경로 단일화, v1 표면 최소화, 후속 Discovery에서 거래 의미론 정의 후 추가 가능. §5 범위 경계 Out of Scope·OQ-4 본문 반영 완료. (OQ-4 Answered 2026-05-20) -- ~~{{DECIDE_USER_TOKEN_MAPPING}}~~: **Decided** — token_type 게이트 + permission 기준 분기. `service`/`client` token은 v1 portfolio mutation 진입 차단(HTTP 403). `session` 또는 env token은 `permission`이 admin이면 admin 분기, 그 외는 user 분기. `token_source`(env/db)는 분기 기준에 사용하지 않음. FR-1·FR-3·FR-4·FR-5·§3 시나리오·OQ-5 본문 반영 완료. client token 발급 경로 폐지는 후속 Discovery로 분리(DATA 항목 추적). (OQ-5 Answered 2026-05-20) - -## CONFIRM -- ~~{{CONFIRM_DCY001_FR5_CHANGE_REQ}}~~: **Confirmed + Executed (2026-05-20)** — 옵션 B-3(REQ-013 Deprecated + REQ-010 보강 + 신규 REQ로 분리) 채택. REQ-013(Approved, dcy-001 FR-5 산출물)에 change-req CHG-20260520-01 적용 완료, Status → Deprecated. dcy-001 §0 후속 Discovery 참조 갱신, REQ-010 Notes 보강, docs/srs/index.md Register/Change Log Summary 갱신 완료. `GET /v1/assets` catalog 명세 처리는 dcy-001 batch design 단계로 이관. -- ~~{{CONFIRM_LEGACY_PORTFOLIOS_ROUTE_SCOPE}}~~: **Confirmed (2026-05-20)** — 본 Discovery는 `/v1/portfolios*` 신설만 다루고 기존 `app/api/portfolios.py`의 legacy `/portfolios/*` 라우트 제거는 후속 Discovery로 분리하는 범위 결정에 사용자 동의 완료. §4 영향 범위·§5 Out of Scope·R-4 본문에 이미 명시됨. 후속 Discovery 작성 시점은 본 Discovery REQ drafting 또는 batch 진행 중 별도 결정. - -## DATA -- ~~{{DATA_TREX_BASIC_RELEASE_WINDOW}}~~: **External tracking (release plan 단계 수집)** — trex-basic 배포 운영 담당자가 본 batch release window에 동시 배포 가능한 일정과 rollback 절차를 확정해 제공한다. 본 Discovery는 정책(NFR-2 동시 배포·R-1 완화 방안)을 정의하고 실제 release window 확정은 batch design / release plan 단계 운영 작업이라 confirm-discovery 게이트 차단 사유 아님. release plan 작성 시 evidence(예: trex-basic 배포 일정, rollback 절차 협의 결과)를 batch verification 문서에 기록. -- ~~{{DATA_CLIENT_TOKEN_DEPRECATION_DISCOVERY}}~~: **Collected (2026-05-20)** — 후속 Discovery [dcy-004](./dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md) (confirmed 2026-05-20) 작성 완료. dcy-004 FR-1에서 `POST /auth/tokens`, `issue_client_token`, `CreateClientTokenRequest`, `IssuedClientTokenResponse` 모두 폐지로 확정. DATA-1 inventory 0건 확인으로 grace period/revoke 스크립트 불필요. 본 Discovery의 FR-1/3/4/5 token_type 게이트는 dcy-004 폐지 release 이전·이후 모두 정합. - -# 11. Discovery Freeze -REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. - -## 섹션 참조 -- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) -- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) -- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) -- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) -- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) - -## Freeze 플래그 -- Handoff Decision: 승인 -- Handoff Rationale: OQ-1~5 Answered + CONFIRM 2건 Confirmed + DATA-1 Collected(dcy-004) + DATA-2 External tracking(release plan 단계) + §8 체크리스트 6건 모두 [x] 완료(2026-05-20). confirm-discovery 게이트 통과로 본 Discovery는 `confirmed` 상태로 승급되어 REQ 초안 작성 가능. **외부 의존**: DATA_TREX_BASIC_RELEASE_WINDOW(release plan 단계 수집), 향후 batch design 단계에서 dcy-001 REQ-010 보강 또는 신규 REQ(`GET /v1/assets` catalog 명세) 처리. -- Ready for REQ Drafting: true -- Confirmed By: Justin -- Confirmed At (KST): 2026-05-20T14:00+09:00 diff --git a/docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md b/docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md deleted file mode 100644 index 7a7e159..0000000 --- a/docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md +++ /dev/null @@ -1,322 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-004_20260520_token-issuance-and-lifecycle-overhaul -- 이슈명: 토큰 발급 및 관리 전면 정비 — client token 폐지, service-token rotate 정상화, 발급 권한 매트릭스, 만료·회전·자동 revoke 정책 -- 작성 시각(KST): 2026-05-20T07:00+09:00 -- 마지막 갱신 시각(KST): 2026-05-20T14:30+09:00 (draft-req 완료 — REQ-016~024 9건 생성. FR-1/2/3/4+10/5/6/7/8/9 분해, NFR-1~6 흡수, docs/srs/index.md 갱신) -- 대체됨: 없음 -- 후속 Discovery 참조: 없음 -- 생성된 REQ 참조: REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024 - -# 1. 계획 상태 요약 -현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. - -- 연계 문서 현황: - - 관련 점수 파일: 미생성 - - 관련 TODO/메모: temp/2026-05-18_todo.md (운영 결함 inventory — service-token rotate 실패, HTTP daemon_id forward 누락, rebind/AuthContext 문서 누락), temp/20260519_old_sdlc/releases/rel-006_20260513_daemon-model-implementation.md (rotate verification 실패 기록 + Feedback Handoff에 NULL daemon_id 마이그레이션·daemon deactivation 자동 revoke 항목 잔존) -- 해석: - - 현행 토큰 surface는 4종(session/client/service/env)이 발급 경로·만료·회전·문서화에서 부분 정합성 결함을 누적해 왔고, 작은 결함들(client v1 mutation 진입, service-token rotate 실 DB 실패, HTTP body daemon_id silently drop, rebind/AuthContext 문서 누락)이 운영 단계에서 노출되었다. dcy-003에서 client token 폐지를 후속 Discovery에 명시 위임하면서 토큰 surface 정비를 단일 batch로 묶을 시점이 됐다. - - 본 Discovery는 토큰 surface 전면 정비 — client token 발급 경로 폐지, rotate 정상화, HTTP/CLI 일관성, 발급 권한 매트릭스, 만료·회전 정책, daemon deactivation 자동 revoke, NULL daemon_id legacy token 마이그레이션, env admin token 운영 SOP — 를 단일 주기로 처리한다. - -# 2. 요구사항 판정 결과 -이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. - -- 판정: 신규 -- 근거 - - 활성 SDLC(dcy-001 asset catalog, dcy-002 tag 정책, dcy-003 portfolio v1)는 모두 도메인 모델·portfolio API에 집중하며 토큰 surface 정비를 다루는 active Discovery는 없다. dcy-003 §10 DATA_CLIENT_TOKEN_DEPRECATION_DISCOVERY가 본 Discovery의 명시 예고에 해당한다. - - 폐기된 옛 SDLC의 dcy-005(production-admin-bootstrap)는 운영 admin 토큰 절차의 일부만 다루며 본 Discovery의 토큰 surface 전반 정비 범위와는 부분 겹침에 그친다. 새 SDLC 리셋(2026-05-19) 이후 토큰 surface 정비는 미수용 backlog로 남아 있었다. - -# 3. 문제점의 요약 -문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. - -- 현재 상태 - - 토큰 surface 4종: `app/core/auth.py`의 `AuthContext`가 `token_type ∈ {session, client, service, None(env)}` × `token_source ∈ {db, env}` 두 축으로 분류. 발급 경로는 `app/services/auth_service.py`의 `login`(session), `issue_client_token`(client), `issue_service_token`(service)에 분리. env token은 `settings.admin_tokens`/`trade_write_tokens`/`read_tokens`로 환경 변수에 박힌 형태. - - **결함 1 — client token 발급 경로 잔존**: `POST /auth/tokens` HTTP API([app/api/auth_routes.py:100-109](app/api/auth_routes.py#L100-L109))로 user가 본인 명의 발급 가능. dcy-003에서 v1 portfolio mutation 진입을 차단하기로 결정(token_type 게이트)했으나 발급 경로·`issue_client_token`·`CreateClientTokenRequest` 코드는 잔존. - - **결함 2 — service-token rotate 실 DB 실패**: `app/repositories/postgres.py:161-167`의 `idx_auth_tokens_service_name_unique` partial index가 `WHERE token_type='service'` 조건만 적용해 revoked row와 새 row가 같은 name으로 unique 충돌. [REL-006 verification](temp/20260519_old_sdlc/releases/rel-006_20260513_daemon-model-implementation.md#L74)에 "rotate insert 막힘" 검증 실패 기록. - - **결함 3 — POST /auth/service-tokens body `daemon_id` silently drop**: `app/schemas/auth.py:74-80`의 `IssueServiceTokenRequest.daemon_id` 필드는 노출되나 [app/api/auth_routes.py:131-137](app/api/auth_routes.py#L131-L137)에서 `service.issue_service_token` 호출 시 forward 하지 않음. CLI `--daemon-id`는 정상 forward하나 HTTP 경로만 daemon binding 불가. - - **결함 4 — `pfhub service-token rebind` CLI 문서화 누락**: [app/cli/commands/service_tokens.py:119-141](app/cli/commands/service_tokens.py#L119-L141)에 구현 완료, repository protocol/postgres 모두 존재하나 `docs/interface-contract.md` CLI surface 열거에 명시 없음. - - **결함 5 — runtime-flows.md AuthContext 필드 목록 daemon_id 누락**: `docs/runtime-flows.md:76`이 `user_id, token_type, permission, runtime_id`로 `daemon_id` 누락. 실제 코드는 보유. - - **정책 공백 1 — 발급 권한 매트릭스 부재**: 누가 어떤 token type을 발급/회전/폐기할 수 있는가가 코드에 흩어진 dependency(require_admin/require_read)로만 표현되고 baseline에 명시 없음. - - **정책 공백 2 — 만료·회전 정책 부재**: session은 `settings.session_token_ttl_hours` 강제, client는 발급 시 ttl 인자, service는 `--no-expiry` 허용. 일관 기준·강제 회전 주기 미정의. - - **정책 공백 3 — daemon deactivation 자동 revoke 부재**: `pfhub daemon update --deactivate`가 daemon `is_active=false`만 설정하고 바인딩 service token에 영향 없음 (REL-006 Feedback Handoff에 후속 항목으로 기록). - - **정책 공백 4 — NULL daemon_id legacy token 마이그레이션 자동화 부재**: REL-006 Feedback Handoff에 "rotate + rebind 자동화" 후보로 기록만 됨. - - **정책 공백 5 — env admin token 운영 SOP 부재**: 환경 변수 갱신·회전·폐기 절차가 baseline에 없음. -- 기대 상태 - - 토큰 surface 3종(session/service/env)으로 정리, client token 발급 경로 코드 폐지 - - service-token rotate가 실 DB에서 정상 작동 - - HTTP/CLI 토큰 발급 surface가 일관 (daemon_id forward 누락 해소, rebind/AuthContext 문서 반영) - - 발급 권한 매트릭스·만료·회전 정책이 baseline에 명시 - - daemon deactivation 시 바인딩 service token 자동 revoke (또는 grace period) - - NULL daemon_id legacy token 마이그레이션 자동화(CLI 서브커맨드 또는 스크립트, dry-run 포함) - - env admin token 회전·폐기 운영 SOP 정의 -- 이해관계자 - - admin/운영자: 토큰 발급·회전·폐기 SOP의 일관성 확보, env admin token 회전 책임 - - 로그인 user: client 폐지 후 자동화 use case의 대체 경로 명확화 - - daemon 운영 주체 (trex-basic 등): rotate/rebind 정상 작동, daemon deactivation 동기화 - - repository-maintainers: 토큰 surface 정합성 유지, baseline 문서 일관성 -- 제약사항 - - dcy-003(draft, 본 Discovery 결정 완료 후 진행 재개 예정)이 client token v1 portfolio mutation 진입 차단을 이미 결정(OQ-5 Answered 2026-05-20) → 본 Discovery는 그 결정 위에서 발급 경로 자체 폐지로 진행. 진행 순서: 본 Discovery 결정·confirm 완료 → dcy-003 잔여 항목(CONFIRM 2건, DATA 1건, dcy-001 REQ Approved 선행 조건) 처리 재개 - - 기존 발급된 client token이 운영 중일 수 있어 폐지 마이그레이션 절차 필요 (DB inventory 사전 조사) - - service-token rotate 정상화는 unique index 변경을 포함 → Alembic migration 추가 - - env admin token 회전은 코드 변경과 환경 변수 갱신을 동시 수행해야 운영 차단 회피 - - 본 Discovery 범위는 토큰 surface 정비에 한정. 신규 토큰 타입 도입(OAuth, PAT 등), 외부 SSO 통합, 감사 로그 정책은 별도 Discovery로 분리 - -## 주요 사용자 시나리오 -사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. - -- admin이 alice의 trex-basic daemon용 service-token을 발급한다. `pfhub service-token issue NAME --user alice --daemon-id DID` (CLI) 또는 `POST /auth/service-tokens` body `{"name": "...", "username": "alice", "daemon_id": "DID", ...}` (HTTP) 두 경로 모두 동일하게 daemon_id binding이 작동한다. -- admin이 alice의 service-token을 회전한다. `pfhub service-token rotate NAME --force` 실행 시 새 raw token이 발급되고 unique index 충돌 없이 성공한다. 새 토큰의 `runtime_id`는 새로 발급, `daemon_id`는 유지(invariant). -- admin이 alice의 daemon을 비활성화한다(`pfhub daemon update DID --deactivate`). 해당 daemon에 바인딩된 service token이 자동 revoke되어 후속 v1 호출은 HTTP 401/403. -- user가 본인 명의 client token 발급을 시도하면 HTTP 404 또는 405 (path 자체 제거)를 받는다. 자동화 use case는 admin이 발급한 service token으로 흡수 또는 OQ-8 결정에 따른 새 경로. -- 운영자가 NULL daemon_id legacy service token을 일괄 마이그레이션한다. `pfhub service-token migrate-null-daemon --dry-run`으로 inventory 확인 후 매핑 결정, 실제 실행으로 rebind 또는 revoke 처리. -- env admin token 회전 시점에 운영자가 SOP에 따라 신규 admin token을 환경 변수에 추가 → service rolling restart → 구 admin token 환경 변수 제거 절차를 단계적으로 수행한다. - -# 4. 이번에 정의할 변경 -이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. - -- client token 발급 경로 코드 폐지: `POST /auth/tokens` HTTP route, `issue_client_token` service 메서드, `CreateClientTokenRequest`/`IssuedClientTokenResponse` schemas, 관련 테스트 -- service-token rotate unique index 보강: `idx_auth_tokens_service_name_unique`에 `AND revoked_at IS NULL` 조건 추가 또는 동등 효과 정책 채택 -- `POST /auth/service-tokens` HTTP body의 `daemon_id` forward 정상화: `auth_routes.py:131-137` 호출 시 `payload.daemon_id` 전달 -- 토큰 발급 권한 매트릭스·만료·회전 정책을 baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`)에 명시 -- daemon deactivation 시 바인딩 service token 자동 revoke 동작 추가 (`pfhub daemon update --deactivate` 흐름) -- NULL daemon_id legacy token 마이그레이션 자동화: 신규 CLI 서브커맨드(`pfhub service-token migrate-null-daemon`) 또는 일괄 스크립트, dry-run + report 포함 -- env admin token 운영 SOP 정의: `docs/runtime-flows.md` 또는 `README.md`에 회전·폐기 절차 추가 -- baseline 문서 정합성 회복: `pfhub service-token rebind` CLI 명세 추가, `AuthContext` 필드 목록에 `daemon_id` 추가, 토큰 surface 전체 명세 갱신 - -## 영향 범위 -변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. - -- 코드 영향: - - `app/api/auth_routes.py`: `POST /auth/tokens` 라우트 + 관련 dependency 제거; `POST /auth/service-tokens`에서 `payload.daemon_id` forward 보정 - - `app/services/auth_service.py`: `issue_client_token` 메서드 제거; `revoke_tokens_for_daemon(daemon_id)` 등 신규 헬퍼 도입 검토 - - `app/schemas/auth.py`: `CreateClientTokenRequest`, `IssuedClientTokenResponse` 제거 - - `app/repositories/postgres.py`: `idx_auth_tokens_service_name_unique` 조건 보강(`ensure_schema` + Alembic migration), daemon deactivation 시 바인딩 token 일괄 revoke를 위한 repository 메서드 추가 - - `app/repositories/portfolio_repository.py`: Protocol에 신규 메서드 선언 - - `app/cli/commands/daemon.py`: `update --deactivate` 동작에 자동 revoke 호출 추가 - - `app/cli/commands/service_tokens.py`: `migrate-null-daemon` 신규 서브커맨드(또는 별도 모듈) 추가 - - `migrations/versions/`: 신규 Alembic revision — partial index 조건 보강(CONCURRENTLY 적용 권장) - - `tests/`: client 폐지 후 호출 경로 회귀 테스트, rotate 정상화 통합 테스트, daemon deactivation 자동 revoke 회귀 테스트, NULL daemon_id 마이그레이션 dry-run 테스트 -- 문서 영향: - - `docs/interface-contract.md`: client 발급 경로 제거, `pfhub service-token rebind` 명세 추가, 토큰 발급 권한 매트릭스 표 추가, 만료·회전 정책 명시 - - `docs/runtime-flows.md`: 토큰 발급/회전/리바인드/자동 revoke flow, `AuthContext` 필드 목록에 `daemon_id` 추가, env admin token 운영 SOP flow - - `docs/data-model.md`: `auth_tokens` 인덱스 변경 반영, 토큰 surface 3종 정리 - - `docs/project-structure.md`: 신규 마이그레이션 스크립트/CLI 서브커맨드 추가에 따른 패키지 책임 갱신(필요 시) - - `README.md`: daemon provisioning runbook 갱신 (auto-revoke 정책 반영), env admin token 회전 SOP 섹션 추가 -- 운영 영향: - - 기존 발급된 client token 보유자 영향: 폐지 release 전 inventory 조사 + 보유자 통보 필요 (R-1) - - service-token rotate migration이 partial index recreate를 포함하므로 운영 시간대·CONCURRENTLY 적용 검토 필요 (R-2) - - daemon deactivation 자동 revoke가 in-flight 거래에 영향 가능 — graceful drain 절차 검토 (R-3) - - env admin token 회전 SOP가 잘못 적용되면 운영 차단 발생 — 다단계 절차 + rollback 명시 (R-4) - -# 5. 요구사항 목록 -실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. - -## 기능 요구사항 -- FR-1: client token 발급 경로 코드 폐지 — `POST /auth/tokens` HTTP route, `app/services/auth_service.py:issue_client_token`, `CreateClientTokenRequest`, `IssuedClientTokenResponse`, 관련 테스트 모두 제거. 기존 발급된 client token 처리 정책(OQ-1 Answered 2026-05-20): **즉시 코드 폐지로 진행**. DATA-1 inventory(2026-05-20)에서 active client token 0건 확인됐으므로 별도 revoke 스크립트 실행이나 grace period 운영 불필요. 폐지 release 직전 동일 쿼리로 재확인하는 절차만 release plan에 포함한다. -- FR-2: `idx_auth_tokens_service_name_unique` partial index 조건 보강 (OQ-2 Answered 2026-05-20) — 현행 `WHERE token_type='service'`에 `AND revoked_at IS NULL` 추가. `ensure_schema()`(app/repositories/postgres.py)와 신규 Alembic migration 양쪽 갱신. 기존 active service token 행에는 영향 없음. migration은 `CREATE INDEX CONCURRENTLY` 적용으로 R-2 lock 영향 최소화(autocommit 외부 트랜잭션 필요). -- FR-3: `POST /auth/service-tokens` HTTP body `daemon_id` forward 정상화 — `app/api/auth_routes.py:131-137`이 `payload.daemon_id`를 `service.issue_service_token` 호출에 전달. CLI `--daemon-id`와 동등 동작 보장. 비존재/비활성 daemon 지정 시 동일한 ValueError → HTTP 4xx 변환. -- FR-4: baseline 문서 정합성 회복 — `docs/interface-contract.md` CLI surface에 `service-token rebind` 추가, `docs/runtime-flows.md` `AuthContext` 필드 목록에 `daemon_id` 추가, 토큰 surface 전체(env vs DB, session/service 만료 정책, rotate runtime_id/daemon_id invariant) 명세 갱신. -- FR-5: 토큰 발급 권한 매트릭스 정의 — type(session/service/env) × 발급자 권한(admin/trade-write/read) × 발급 가능 여부의 표를 `docs/interface-contract.md`에 명시. (client는 폐지 후 표에서 제거.) 매트릭스 위반 호출은 HTTP 403 fail-closed. **admin이 자기 자신에게 service token을 발급하는 경로는 허용**(OQ-4 Answered 2026-05-20)하며 admin은 본인 user_id를 대상으로 daemon-bound 또는 unbound service token을 발급할 수 있다. 발급된 self-service token의 권한 한도는 admin이 명시한 permission이지만, 관행상 자기 자동화에 필요한 최소 권한(예: trade-write)으로 발급할 것을 매트릭스 주석에 권장. -- FR-6: 토큰 만료·회전 정책 정의 (OQ-5 Answered 2026-05-20 — 현행 유지) — type별 기본 TTL을 `docs/runtime-flows.md`에 명시: session = `settings.session_token_ttl_hours`(현행 값 유지), service = 365일 default, env = 만료 없음. `no_expiry` 허용 범위는 **service 한정**(session/client 발급 경로 모두 거부). 강제 회전 주기는 본 정책에 두지 않으며 env admin token 회전 주기만 OQ-6에서 별도 결정한다. 정책 위반 발급(예: `no_expiry`를 session에 요청)은 HTTP 422 또는 ValueError로 거부. -- FR-7: daemon deactivation 자동 revoke (OQ-3 Answered 2026-05-20 — 즉시 revoke) — `pfhub daemon update --deactivate` 실행 시 해당 daemon에 바인딩된 service token(`auth_tokens.daemon_id == ` AND `revoked_at IS NULL`)을 **동일 트랜잭션 내에서 즉시 revoke**. graceful drain은 daemon 측 책임이며 trex-basic 등 daemon 구현이 종료 전 in-flight 처리를 수행할 것을 release plan에 명시. CLI에 `--keep-tokens` 같은 회피 옵션은 노출하지 않는다(정책 일관성). 동작 evidence는 revoked_at 갱신 timestamp로 추적. -- FR-8: NULL daemon_id legacy token 마이그레이션 (OQ-7 Answered 2026-05-20 — 운영자 수동 매핑) — 신규 CLI 서브커맨드 `pfhub service-token migrate-null-daemon` 또는 동등 진단 스크립트. dry-run(`--dry-run`) 옵션으로 inventory + 매핑 후보(같은 `user_id`의 active daemon 목록) 출력. 실제 적용은 운영자가 token별로 `pfhub service-token rebind NAME --daemon-id ID`(매핑) 또는 `pfhub service-token revoke NAME`(폐기)을 수동 실행한다. 자동 매핑/auto-create daemon 경로는 노출하지 않는다(권한 escalation 위험). idempotent 작동(재실행 안전) 필수. 본 batch release 시점에는 DATA-2 inventory에서 발견된 admin 테스트 fixture 1건을 운영자가 직접 revoke 처리(별도 운영 작업). -- FR-9: env admin token 운영 SOP 정의 (OQ-6 Answered 2026-05-20 — 분기 권장 주기, 강제 없음) — `docs/runtime-flows.md` 또는 `README.md`에 명시할 항목: (1) **권장 회전 주기**: 분기(3개월). 강제 회전·만료 알람 없음. (2) **운영자 권한 매트릭스**: env admin token 추가/제거는 인프라 운영자 권한. (3) **다단계 회전 절차**: 신규 token 생성(`python -c "import secrets; print(secrets.token_urlsafe(48))"` 권장) → `PORTFOLIOHUB_ADMIN_TOKENS`에 신규 token 추가(콤마 분리) → service rolling restart → 운영 검증 → 구 token 제거 → service rolling restart. (4) **회전 트리거 조건**: 정기(분기) / token leak 의심 / 운영자 교체·퇴사 / 보안 사고 대응. (5) **rollback 절차**: 회전 실패 시 환경 변수에 구 token 재추가 후 rolling restart. (6) **token 강도 가이드**: 최소 48-byte URL-safe 무작위. 평문 단어·예측 가능한 패턴 금지. -- FR-10: `docs/data-model.md`, `docs/project-structure.md` baseline 갱신 — `auth_tokens` 인덱스 변경 반영, 토큰 surface 3종 정리, 신규 CLI/migration 파일 책임 반영(필요 시). - -## 비기능 요구사항 -성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. - -- NFR-1: client token 폐지 후 dcy-003 v1 portfolio mutation 동작에 회귀 없음. `token_type='client'` 자체가 생성 불가하므로 dcy-003 FR-1/3/4/5의 게이트 로직은 유지하되 실 트래픽에서는 자연 사라짐. -- NFR-2: service-token rotate는 `docker-compose.local-test.yml` 실 DB 환경에서 unique 위반 없이 성공해야 한다. staging 환경 통합 검증 필수. -- NFR-3: 기존 service-token의 daemon_id invariant(rotate 시 daemon_id 유지)는 본 Discovery 변경 후에도 보존. runtime_id는 매 rotate마다 갱신. -- NFR-4: 토큰 발급 권한 매트릭스의 위반 호출은 HTTP 403 fail-closed로 통일. -- NFR-5: NULL daemon_id 마이그레이션은 idempotent. 재실행 시 안전, 부분 실패 시 보고된 예외 목록과 일치. -- NFR-6: `idx_auth_tokens_service_name_unique` 변경 migration은 운영 환경에서 long-running lock 회피를 위해 CONCURRENTLY 옵션 적용 또는 운영 시간대 적용 권장. - -## 범위 경계 -이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. - -- In Scope - - client token 발급 경로 코드 폐지 + 기존 토큰 처리 절차 - - service-token rotate unique index 보강 + migration - - HTTP `POST /auth/service-tokens` body daemon_id forward 정상화 - - 토큰 발급 권한 매트릭스 정의 (baseline 명시) - - 토큰 만료·회전 정책 정의 (baseline 명시) - - daemon deactivation 자동 revoke - - NULL daemon_id legacy token 마이그레이션 자동화 (CLI + dry-run) - - env admin token 운영 SOP 정의 - - baseline 문서 정합성 회복 (rebind, AuthContext daemon_id, 토큰 surface 전체) -- Out of Scope - - 신규 토큰 타입 도입 (Personal Access Token, OAuth, API key 서비스) — 별도 Discovery - - user 신원/SSO 통합 — 별도 Discovery - - 토큰 사용 감사 로그·audit trail 정책 — 별도 Discovery (단, daemon deactivation 자동 revoke의 logging은 본 Discovery 범위) - - token 시각 동기화·시간대 정책 — 별도 운영 Discovery - - service-token 발급량/rate-limit 정책 — 별도 Discovery - -# 6. 리스크/가정 목록 -불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. - -## 리스크 -- R-1 (해소됨, 잔여 모니터링 Low): DATA-1 inventory(2026-05-20)에서 active client token 0건 확인됐으므로 폐지 release 시 보유자 영향 없음. 잔여 리스크는 release 직전 새로 발급된 client token이 0건이 아닐 가능성뿐이다. - - 완화 방안: 폐지 release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` 재확인을 release plan에 포함. >0이면 발급 사유 조사 후 즉시 revoke 또는 release 보류. -- R-2 (High): `idx_auth_tokens_service_name_unique` partial index 재생성이 long-running lock을 유발하면 운영 중 auth_tokens 쓰기가 블로킹된다. - - 완화 방안: Alembic migration에 `CREATE INDEX CONCURRENTLY` 적용(단, autocommit 외부 트랜잭션 필요), 운영 시간대 적용, staging 환경 dry-run으로 lock 시간 사전 측정. partial index 변경이 어려우면 OQ-2의 대안(name suffix 부여)으로 전환. -- R-3 (Medium): daemon deactivation 즉시 revoke(OQ-3 Answered)는 정책상 단순하나 in-flight ledger trade가 진행 중인 시점에 deactivation이 실행되면 거래 기록이 누락될 수 있다. - - 완화 방안: graceful drain은 daemon 구현 측 책임으로 분리(trex-basic이 종료 신호 수신 시 in-flight 마무리 후 종료). release plan에 deactivation SOP를 "(1) daemon 프로세스 종료/drain 완료 확인 → (2) `pfhub daemon update --deactivate` 실행 → (3) 자동 revoke" 순서로 명시. deactivation 호출 전 daemon process health check 권장. -- R-4 (Medium): env admin token 회전 절차가 잘못 적용되면 운영자가 admin HTTP/CLI 접근을 잃어 회복 자체가 불가. - - 완화 방안: env admin token 회전 SOP에 다단계 절차(신규 token 추가 → 동시 운영 기간 → 구 token 제거) + rollback 절차 명시. 회전 중 fallback admin 접근 경로(예: DB 직접 접근으로 토큰 재생성) 보장. -- R-5 (해소됨, 잔여 모니터링 Low): OQ-7에서 자동 매핑/auto-create daemon 경로를 채택하지 않기로 결정(2026-05-20)했으므로 권한 escalation 리스크는 본 Discovery 범위에서 발생하지 않는다. DATA-2 inventory(2026-05-20)에서 NULL daemon service token 1건만 발견됐고 운영자 확인 결과 테스트 fixture로 분류돼 본 batch 시점 release에 운영자가 직접 revoke 처리한다. - - 완화 방안: release 직전 동일 쿼리로 NULL daemon token 0건 재확인. >0이면 운영자가 token별 `rebind` 또는 `revoke` 수동 실행. `pfhub service-token migrate-null-daemon --dry-run` 보고에 `token.user_id`와 매핑 후보 daemon의 `owner_user_id` 일치 여부를 명시 표시(자동 매핑은 하지 않더라도 진단 데이터 제공). -- R-6 (Low): 토큰 발급 권한 매트릭스 정의가 현행 dependency 체계(require_admin/require_read)와 불일치하면 구현 단계에서 광범위 회귀 가능. - - 완화 방안: 매트릭스 정의 단계에서 코드 grep으로 현행 dependency 사용처와 1:1 매핑 확인, 불일치 시 매트릭스나 dependency 중 한쪽 우선 조정. -- R-7 (High, 본 Discovery 범위 밖 — 별도 hotfix 권장): DATA-3 수집(2026-05-20) 결과 **prod 환경에 env admin token이 약한 placeholder 값(`admin-token-789` — 예측 가능한 영문+숫자 패턴)으로 운영 중**. 환경 변수 leak 또는 단순 brute-force 시 즉시 admin 권한 escalation 가능. 본 Discovery는 토큰 surface 정비 batch이므로 단일 token 값 교체는 본 Discovery 외부 hotfix로 분리하며, 본 Discovery FR-9의 SOP가 이 hotfix의 표준 절차를 제공한다. - - 완화 방안: 본 Discovery confirm-discovery 이전이라도 보안상 즉시 hotfix 권장. 절차(FR-9 SOP 미리 적용): (1) 강한 무작위 token 생성, (2) `PORTFOLIOHUB_ADMIN_TOKENS`에 신규 token 추가, (3) service rolling restart, (4) 운영 검증, (5) 구 token(`admin-token-789`) 제거, (6) service rolling restart, (7) 회전 evidence 기록. hotfix 추적은 §10 DATA_PROD_ADMIN_TOKEN_HOTFIX 항목으로 관리. - -## 가정 -아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. - -- A-1: 본 Discovery 후 토큰 surface는 4종(session/client/service/env)에서 3종(session/service/env)으로 정리된다. user의 자동화 use case는 admin이 발급한 service token으로 흡수한다(OQ-8 Answered 2026-05-20 — admin 발급 service token으로 충분). PAT 등 새 토큰 타입 도입은 본 batch 후 운영 피드백을 보고 별도 Discovery에서 판단한다. -- A-2: dcy-003 FR-1/3/4/5의 token_type 게이트(`token_type ∈ {service, client}` 거부)는 client 폐지 후 "client는 더 이상 발급 불가"로 자연 강화. 게이트 자체는 env token 외부 침투 방어로 유지. -- A-3: `idx_auth_tokens_service_name_unique` 보강은 단일 Alembic migration으로 처리 가능. 기존 active service token 행 데이터에는 변경 없음. -- A-4: daemon deactivation 자동 revoke는 기존 daemon registry CRUD(dcy-006 모델 — `daemons` 테이블, `is_active` 플래그) 위에 얹는 동작이며 daemon 모델 자체에는 변경 없음. -- A-5: env admin token 회전 SOP는 환경 변수 다중 등록(예: `PORTFOLIOHUB_ADMIN_TOKENS` 콤마 분리)이 가능한 현행 구조([app/core/auth.py:43-50](app/core/auth.py#L43-L50)의 `_resolve_permission`이 set 기반)를 활용해 다단계 운영이 가능하다. -- A-6: 본 Discovery는 기존 service-token의 정상 운영 중 daemon binding을 변경하지 않는다. NULL daemon_id token만 마이그레이션 대상이며, 이미 daemon-bound token은 그대로 유지. - -## 오픈 질문 -답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. - -- OQ-1: 기존 발급된 client token 처리 정책 — 폐지 release에서 즉시 일괄 revoke vs grace period(기간 명시) vs 자연 만료 대기? - - 상태: Answered - - 처리 방안: 즉시 코드 폐지로 결정(2026-05-20). DATA-1 inventory에서 active client token 0건 확인됐으므로 grace period/revoke 스크립트 불필요. 폐지 release 직전 동일 쿼리로 재확인하는 절차만 release plan에 포함. - - 반영 위치: FR-1 본문 갱신 완료, R-1 완화 방안 갱신 완료, §10 DECIDE에서 DECIDE_CLIENT_TOKEN_DEPRECATION_POLICY 처리 완료, §10 DATA에서 DATA_CLIENT_TOKEN_INVENTORY 수집 완료(0건). -- OQ-2: `idx_auth_tokens_service_name_unique` 보강 방식 — `AND revoked_at IS NULL` partial 조건 추가 vs rotate 시 revoked row의 name suffix 부여(예: `__revoked_`) vs unique 제약을 active row로만 한정하는 별도 모델 재설계? - - 상태: Answered - - 처리 방안: partial 조건 추가로 결정(2026-05-20). `WHERE token_type='service' AND revoked_at IS NULL`. 단일 Alembic migration + CONCURRENTLY 적용으로 R-2 lock 영향 완화. ensure_schema와 migration 양쪽 갱신. - - 반영 위치: FR-2 본문 갱신 완료, §10 DECIDE에서 DECIDE_ROTATE_UNIQUE_INDEX_STRATEGY 처리 완료. -- OQ-3: daemon deactivation 자동 revoke 동작 — 즉시 revoke vs grace period(예: 30분 drain) vs 운영자 별도 확정 명령? - - 상태: Answered - - 처리 방안: 즉시 revoke로 결정(2026-05-20). `pfhub daemon update --deactivate` 실행 시 동일 트랜잭션 내에서 바인딩 service token 모두 revoke. graceful drain은 daemon 측 책임으로 분리. release plan에 deactivation SOP(drain 완료 확인 → deactivate 실행 → 자동 revoke) 명시. - - 반영 위치: FR-7 본문 갱신 완료, R-3 완화 방안 갱신 완료, §10 DECIDE에서 DECIDE_DAEMON_DEACTIVATION_REVOKE_TIMING 처리 완료. -- OQ-4: 토큰 발급 권한 매트릭스에서 admin이 자기 자신에게 service token을 발급할 수 있는가? (admin이 본인 user_id로 service token 발급해 운영 자동화에 사용하는 시나리오) - - 상태: Answered - - 처리 방안: 허용으로 결정(2026-05-20). admin이 본인 user_id 대상으로 daemon-bound 또는 unbound service token 발급 가능. 권한 한도는 admin이 명시한 permission이며, 관행상 최소 권한(예: trade-write) 발급을 매트릭스 주석에 권장. - - 반영 위치: FR-5 본문 갱신 완료, §10 DECIDE에서 DECIDE_ADMIN_SELF_SERVICE_TOKEN 처리 완료. -- OQ-5: 토큰 type별 기본 TTL — session(현행 `session_token_ttl_hours`), service(현행 365일 default), env(만료 없음) 유지 vs 신규 정책? - - 상태: Answered - - 처리 방안: 현행 유지로 결정(2026-05-20). session/service/env 각 정책이 운영상 안정. `no_expiry` 옵션은 service 한정. 강제 회전 주기는 OQ-6 env admin 결정으로 흡수. - - 반영 위치: FR-6 본문 갱신 완료, §10 DECIDE에서 DECIDE_TOKEN_TTL_POLICY 처리 완료. -- OQ-6: env admin token 회전 주기 — 권장 주기(예: 분기/반기/연간), 강제 회전 여부? - - 상태: Answered - - 처리 방안: 분기(3개월) 권장 주기, 강제 회전 없음으로 결정(2026-05-20). 만료 알람 없음. SOP에 회전 트리거 조건(정기·leak 의심·운영자 교체·보안 사고)과 다단계 절차·rollback·token 강도 가이드를 명시. - - 반영 위치: FR-9 본문 갱신 완료, §10 DECIDE에서 DECIDE_ENV_ADMIN_ROTATION_INTERVAL 처리 완료, §10 DATA에서 DATA_ENV_ADMIN_TOKEN_ROTATION_HISTORY 수집 완료. 추가로 R-7(prod 약한 token hotfix) 신규 추가 및 §10 DATA_PROD_ADMIN_TOKEN_HOTFIX 추적 항목 추가. -- OQ-7: NULL daemon_id legacy token 마이그레이션 매핑 전략 — 운영자 수동 매핑(token name 기반 추정) vs daemon auto-create 후 매핑 vs 폐기 후 재발급? - - 상태: Answered - - 처리 방안: 운영자 수동 매핑으로 결정(2026-05-20). CLI는 dry-run으로 inventory + 매핑 후보(같은 user_id의 active daemon) 진단만 제공하고 실제 적용은 운영자가 `rebind` 또는 `revoke`를 명시적으로 실행. 자동 매핑/auto-create daemon은 권한 escalation 리스크로 채택하지 않음. DATA-2 inventory(2026-05-20)에서 발견된 admin 테스트 fixture 1건은 운영자가 직접 revoke 처리. - - 반영 위치: FR-8 본문 갱신 완료, R-5 완화 방안 갱신 완료, §10 DECIDE에서 DECIDE_NULL_DAEMON_MIGRATION_STRATEGY 처리 완료, §10 DATA에서 DATA_NULL_DAEMON_TOKEN_INVENTORY 수집 완료(1건, 테스트 fixture). -- OQ-8: client token 폐지 후 user 자동화 경로 — admin 발급 service token으로 충분한가, user가 본인 명의로 발급 가능한 새 토큰 타입(예: Personal Access Token)을 후속 Discovery에서 도입할지? - - 상태: Answered - - 처리 방안: admin 발급 service token으로 충분으로 결정(2026-05-20). 본 batch 후 토큰 surface 3종(session/service/env)으로 종료. PAT 등 새 토큰 타입은 본 batch 후 운영 피드백을 확인한 뒤 필요 시 별도 Discovery에서 검토. - - 반영 위치: A-1 갱신 완료, §10 DECIDE에서 DECIDE_USER_AUTOMATION_PATH_AFTER_CLIENT 처리 완료. - -### 오픈 질문 처리 기준 -- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. -- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. -- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. -- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. -- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다. -- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다. -- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다. - -# 7. 초기 성공 기준 -이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. - -- S-1: codebase에서 client token 관련 코드 모두 제거 — `POST /auth/tokens` 라우트, `issue_client_token` 메서드, `CreateClientTokenRequest`/`IssuedClientTokenResponse` schemas grep 결과 0건. -- S-2: 실 DB 환경에서 `pfhub service-token issue NAME --user U --no-expiry` → `pfhub service-token rotate NAME --force` 시퀀스가 unique 위반 없이 성공. 새 token의 `runtime_id`는 갱신, `daemon_id`는 유지. -- S-3: `POST /auth/service-tokens` body `{"daemon_id": "", "name": "...", "username": "..."}`로 호출 시 issued token의 `auth_token.daemon_id == ` 반환. -- S-4: `pfhub daemon update --deactivate` 실행 후 해당 daemon에 바인딩된 service token이 모두 `revoked_at IS NOT NULL` 상태로 전환 (또는 OQ-3 결정 방식대로 grace period 후 전환). -- S-5: `pfhub service-token migrate-null-daemon --dry-run` 실행 시 NULL daemon_id token inventory + 매핑 후보가 보고로 출력. 실 실행 후 NULL daemon_id token이 0건 또는 예외 보고 목록과 일치. -- S-6: baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md`)에 토큰 surface 변경(client 제거, rebind 추가, AuthContext daemon_id, 발급 권한 매트릭스, 만료·회전 정책, 자동 revoke flow, env admin SOP) 모두 반영. baseline grep으로 누락 없음 확인. -- S-7: env admin token 회전 SOP가 `docs/runtime-flows.md` 또는 `README.md`에 명시되어 있고, staging 환경에서 dry-run으로 회전 절차 실행 가능. - -## 측정 방식 및 데이터 출처 -각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. - -- S-1 측정 방식: codebase grep(`grep -rn "issue_client_token\|CreateClientTokenRequest\|POST /auth/tokens"`) + pytest로 폐지된 endpoint 호출 시 HTTP 404/405 확인 / 데이터 출처: codebase grep 결과 + 신규 `tests/test_auth_surface_after_client_deprecation.py` -- S-2 측정 방식: 실 DB(`docker-compose.local-test.yml`) 환경 통합 테스트 / 데이터 출처: 신규 `tests/test_service_token_rotate_integration.py` (DB 의존 마킹) -- S-3 측정 방식: pytest로 HTTP request 시뮬레이션 / 데이터 출처: 갱신 `tests/test_auth.py` -- S-4 측정 방식: pytest로 daemon deactivation 후 바인딩 token 상태 검증 / 데이터 출처: 갱신 `tests/test_daemon_registry.py` -- S-5 측정 방식: CLI smoke + 마이그레이션 스크립트 단위 테스트 (dry-run 출력 검증 + idempotency 회귀) / 데이터 출처: 신규 `tests/test_null_daemon_migration.py` -- S-6 측정 방식: baseline 문서 grep + 사람 리뷰 (`grep -n "service-token rebind\|AuthContext.daemon_id\|발급 권한 매트릭스\|만료·회전 정책" docs/`) / 데이터 출처: 갱신된 baseline 문서 -- S-7 측정 방식: SOP 문서 + 운영 환경 dry-run 실행 결과 / 데이터 출처: 갱신 `README.md` 또는 `docs/runtime-flows.md`의 env admin SOP 섹션 - -# 8. REQ로 넘기기 전 확인 체크 -REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. - -- [x] 범위(In Scope/Out of Scope) 확정 - - 기준: OQ-1~8 전체 Answered 완료(2026-05-20). In/Out of Scope 경계 최종화 완료. CONFIRM 2건 모두 Confirmed. DATA-1/2/3 Collected. R-7(prod 약한 token hotfix)는 본 Discovery 범위 밖 별도 운영 작업으로 분리. -- [x] 성공 지표의 측정 방식/데이터 출처 확정 - - 기준: S-1~7 측정 방식이 §7 측정 방식 섹션에 각각 신규/갱신 pytest 시나리오(`tests/test_auth_surface_after_client_deprecation.py`, `tests/test_service_token_rotate_integration.py`, `tests/test_auth.py`, `tests/test_daemon_registry.py`, `tests/test_null_daemon_migration.py`) 또는 SOP 문서(`README.md`/`docs/runtime-flows.md`)로 명시 연결됨. -- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 - - 기준: R-1(client token 보유자 식별)은 DATA-1 0건 확인으로 해소(잔여 Low), R-2(migration lock)는 CONCURRENTLY 적용으로 완화. R-7(prod 약한 admin token)은 본 Discovery 외부 hotfix로 분리. 책임자 = repository-maintainers(본 Discovery 단계). REQ 작성 시 개별 작업 책임자가 REQ Impacted Area에 반영될 예정. -- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - - 기준: OQ-1~8 전체 Answered 완료(2026-05-20). Open 상태 OQ 0건. -- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 - - 기준: §11 Confirmed By에 Justin 지정 완료(2026-05-20). -- [x] dcy-003 의존성 확인 - - 기준: dcy-003(draft)의 client token 폐지 결정이 본 Discovery로 이관됐음을 §3 제약사항·§9 참조에 반영 완료. 진행 순서: 본 Discovery 결정·confirm 완료 → dcy-003 잔여 항목 처리 재개(사용자 결정 2026-05-20). -- [x] 운영 inventory 수집 - - 기준: DATA-1(client token, 0건) / DATA-2(NULL daemon service token, 1건 테스트 fixture) / DATA-3(env admin token 1개, 약한 placeholder, 회전 이력 없음) 모두 Collected(2026-05-20). DATA_PROD_ADMIN_TOKEN_HOTFIX는 본 Discovery 외부 hotfix 추적 항목으로 잔존. - -# 9. 파일 처리 결과 -이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. - -- 처리 결과: 생성 -- 생성 경로: docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md -- 참조 문서: - - docs/discovery/dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md (선행 Discovery — client token 폐지 결정 위임, DATA_CLIENT_TOKEN_DEPRECATION_DISCOVERY 명시 예고) - - temp/2026-05-18_todo.md (운영 결함 inventory — service-token rotate 실패, HTTP body daemon_id 무시, rebind/AuthContext 문서 누락) - - temp/20260519_old_sdlc/releases/rel-006_20260513_daemon-model-implementation.md (rotate verification 실패 기록 + Feedback Handoff에 NULL daemon_id 마이그레이션·daemon deactivation 자동 revoke 잔존) - - app/core/auth.py, app/services/auth_service.py, app/api/auth_routes.py, app/schemas/auth.py, app/repositories/postgres.py, app/cli/commands/service_tokens.py, app/cli/commands/daemon.py (저장소 근거) - - docs/interface-contract.md, docs/runtime-flows.md, docs/data-model.md, docs/project-structure.md (본 Discovery로 갱신 예정) - -# 10. 사용자 결정 필요 항목 요약 -사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. - -## DECIDE -- ~~{{DECIDE_CLIENT_TOKEN_DEPRECATION_POLICY}}~~: **Decided** — 즉시 코드 폐지. DATA-1 inventory(2026-05-20) active client token 0건 확인, grace period/revoke 스크립트 불필요. release 직전 재확인만 release plan에 포함. FR-1·R-1·OQ-1 본문 반영 완료. (OQ-1 Answered 2026-05-20) -- ~~{{DECIDE_ROTATE_UNIQUE_INDEX_STRATEGY}}~~: **Decided** — `idx_auth_tokens_service_name_unique`에 `AND revoked_at IS NULL` partial 조건 추가. CONCURRENTLY migration으로 R-2 lock 완화. FR-2·OQ-2 본문 반영 완료. (OQ-2 Answered 2026-05-20) -- ~~{{DECIDE_DAEMON_DEACTIVATION_REVOKE_TIMING}}~~: **Decided** — daemon deactivation 즉시 revoke. graceful drain은 daemon 측 책임 분리. release plan에 SOP 명시. FR-7·R-3·OQ-3 본문 반영 완료. (OQ-3 Answered 2026-05-20) -- ~~{{DECIDE_ADMIN_SELF_SERVICE_TOKEN}}~~: **Decided** — admin이 본인 user_id 대상으로 service token 발급 허용. 권한 한도는 명시 permission, 최소 권한 발급 권장. FR-5·OQ-4 본문 반영 완료. (OQ-4 Answered 2026-05-20) -- ~~{{DECIDE_TOKEN_TTL_POLICY}}~~: **Decided** — 현행 TTL 정책 유지(session/service/env 각 정책). `no_expiry`는 service 한정. 강제 회전 주기는 env admin(OQ-6)에서 별도 결정. FR-6·OQ-5 본문 반영 완료. (OQ-5 Answered 2026-05-20) -- ~~{{DECIDE_ENV_ADMIN_ROTATION_INTERVAL}}~~: **Decided** — 분기(3개월) 권장 주기, 강제 회전 없음. SOP에 다단계 절차·rollback·token 강도 가이드 명시. FR-9·OQ-6 본문 반영 완료. (OQ-6 Answered 2026-05-20) -- ~~{{DECIDE_NULL_DAEMON_MIGRATION_STRATEGY}}~~: **Decided** — 운영자 수동 매핑. CLI는 dry-run 진단(inventory + 매핑 후보 user_id 일치 표시)만 제공, 실제 rebind/revoke는 운영자가 명시적으로 실행. 자동 매핑/auto-create 미채택(권한 escalation 리스크). DATA-2 inventory(2026-05-20) 1건(admin 테스트 fixture)은 운영자가 직접 revoke 처리. FR-8·R-5·OQ-7 본문 반영 완료. (OQ-7 Answered 2026-05-20) -- ~~{{DECIDE_USER_AUTOMATION_PATH_AFTER_CLIENT}}~~: **Decided** — admin 발급 service token으로 충분. PAT 등 새 토큰 타입은 본 batch 후 운영 피드백 확인 후 별도 Discovery에서 검토. A-1·OQ-8 본문 반영 완료. (OQ-8 Answered 2026-05-20) - -## CONFIRM -- ~~{{CONFIRM_DISCOVERY_SCOPE_OPTION_C}}~~: **Confirmed** — 옵션 C(Wide — 토큰 발급/관리 전면 정비) 범위로 9개 변경 항목을 단일 batch로 다루는 결정 사용자 동의 완료. (2026-05-20) -- ~~{{CONFIRM_DCY003_DEPENDENCY}}~~: **Confirmed** — dcy-003(draft)에서 결정된 client token v1 mutation 진입 차단 정책이 본 Discovery로 이관됐다는 의존성 관계 사용자 동의. 진행 순서: 본 Discovery 결정·confirm 완료 → dcy-003 잔여 항목 처리 재개. §3 제약사항·§8 체크리스트·§11 Handoff Rationale 반영 완료. (2026-05-20) - -## DATA -- ~~{{DATA_CLIENT_TOKEN_INVENTORY}}~~: **Collected (2026-05-20)** — active client token 0건. `SELECT ... WHERE token_type='client' AND revoked_at IS NULL` 결과 0행. OQ-1 즉시 폐지 결정의 evidence. -- ~~{{DATA_NULL_DAEMON_TOKEN_INVENTORY}}~~: **Collected (2026-05-20)** — NULL daemon_id active service token 1건. token name `admin`(user `500e336a-...`, created 2026-05-11, no_expiry). 운영자 확인 결과 **테스트 fixture, 운영 용도 없음**. release 시점에 운영자가 `pfhub service-token revoke admin` 수동 실행으로 정리. OQ-7 수동 매핑 결정의 evidence. -- ~~{{DATA_ENV_ADMIN_TOKEN_ROTATION_HISTORY}}~~: **Collected (2026-05-20)** — prod 환경 `PORTFOLIOHUB_ADMIN_TOKENS=admin-token-789` 1개 운영 중. **token 강도: 약함**(예측 가능 영문+숫자 placeholder, dev default 추정). 회전 이력 없음(최초 발급 후 미회전). SOP 부재. OQ-6 회전 주기 결정 및 R-7 hotfix 트리거의 evidence. -- ~~{{DATA_PROD_ADMIN_TOKEN_HOTFIX}}~~: **External tracking (본 Discovery 외부 hotfix)** — prod 환경 `admin-token-789` 교체는 §6 R-7로 추적. 본 Discovery는 SOP(FR-9)를 정의하고 hotfix는 그 SOP를 적용하는 운영 작업이라 본 Discovery confirm-discovery 게이트 차단 사유는 아님. 실행 evidence는 운영 노트에 별도 기록. - -# 11. Discovery Freeze -REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. - -## 섹션 참조 -- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) -- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) -- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) -- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) -- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) - -## Freeze 플래그 -- Handoff Decision: 승인 -- Handoff Rationale: OQ-1~8 전체 Answered + CONFIRM 2건 모두 Confirmed + DATA-1/2/3 모두 Collected + §8 체크리스트 7건 모두 [x] 완료(2026-05-20). confirm-discovery 게이트 통과로 본 Discovery는 `confirmed` 상태로 승급되어 REQ 초안 작성 가능. **외부 의존 항목**: R-7(prod 약한 admin token) hotfix는 본 Discovery 외부 운영 작업이라 별도 처리, dcy-003 잔여 처리 + dcy-001 REQ Approved는 batch 통합 시점 의존성으로 §3 제약사항에 명시됨. -- Ready for REQ Drafting: true -- Confirmed By: Justin -- Confirmed At (KST): 2026-05-20T11:00+09:00 diff --git a/docs/discovery/dcy-005_20260601_rel-001-feedback-cleanup.md b/docs/discovery/dcy-005_20260601_rel-001-feedback-cleanup.md deleted file mode 100644 index a70cad8..0000000 --- a/docs/discovery/dcy-005_20260601_rel-001-feedback-cleanup.md +++ /dev/null @@ -1,225 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-005_20260601_rel-001-feedback-cleanup -- 이슈명: REL-001 Feedback 정합화 — ledger_trades.asset_id 명명 / session no_expiry enforcement / health db_enabled -- 작성 시각(KST): 2026-06-01 17:00 -- 마지막 갱신 시각(KST): 2026-06-01 19:00 -- 대체됨: 없음 -- 후속 Discovery 참조: 없음 -- 생성된 REQ 참조: REQ-033 ([req-033](../srs/Migration/req-033_ledger-trades-asset-id-to-portfolio-id-rename.md)), REQ-034 ([req-034](../srs/Interface/req-034_login-request-extra-forbid-enforcement.md)), REQ-035 ([req-035](../srs/Interface/req-035_health-response-db-enabled-field.md)) - -# 1. 계획 상태 요약 -현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. - -- 연계 문서 현황: - - 관련 점수 파일: 미생성 - - 관련 TODO/메모: [rel-001 Feedback Handoff](../releases/rel-001_20260521_v1-api-full-surface.md#L107-L126)에 등록된 3건의 follow-up 후보 -- 해석: - - REL-001 배포 준비 단계에서 누적된 3건의 잔여 정합화 항목(`ledger_trades.asset_id` 컬럼명 혼용, session no_expiry 코드 enforcement 누락, `/health` 응답 모델 `db_enabled` 부재)을 한 묶음 Discovery로 추적해 다음 batch에서 일괄 정리한다. - - 3건 모두 작은 범위 변경이며 backwards-compat 영향이 제한적이므로 단일 Discovery + 단일 batch(Lite 또는 표준)로 처리한다. REL-001은 현 상태로 배포 진행하고 dcy-005 3건은 **별도 REL-002로 분리**(OQ-3 결정, 2026-06-01). - -# 2. 요구사항 판정 결과 -이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. - -- 판정: 신규 -- 근거 - - 3건 모두 기존 REQ(REQ-021, REQ-029) Notes/follow-up에 등록된 잔여 항목이며 별도 REQ로 분해된 적이 없다. - - 정합화 묶음이라는 trigger(REL-001 Feedback 일괄 정리)는 기존 Discovery에 없다. - -# 3. 문제점의 요약 -문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. - -- 현재 상태 - - **3건-1 `ledger_trades.asset_id` 컬럼명 혼용**: REQ-029 구현에서 ledger 경로를 `/v1/portfolios/{id}/ledger/trades`로 이전했으나, `ledger_trades` 테이블의 `asset_id` 컬럼은 실제로는 portfolio_id를 저장한다([app/repositories/postgres.py:1284-1294](../../app/repositories/postgres.py#L1284-L1294) INSERT 컬럼 순서 + RETURNING). 컬럼명과 의미가 일치하지 않아 schema reader가 혼동. - - **3건-2 session no_expiry 코드 enforcement 누락**: REQ-021 정책 본문([docs/srs/Documentation/req-021_token-expiry-and-rotation-policy.md:15-17](../srs/Documentation/req-021_token-expiry-and-rotation-policy.md#L15-L17))은 "`no_expiry`는 service 한정, session/client에서 시도하면 HTTP 422 또는 ValueError로 거부"로 명시. 그러나 현재 `AuthService.login`([app/services/auth_service.py:47-65](../../app/services/auth_service.py#L47-L65))과 `LoginRequest` 스키마([app/schemas/auth.py:13-23](../../app/schemas/auth.py#L13-L23))에는 `no_expiry` 옵션 자체가 존재하지 않아 실질적으로 시도 불가하지만, 정책 명세 ↔ 코드 매핑(REQ-021 AC-5)에서 "명시적 거부 로직 미구현" follow-up이 등록됨. - - **3건-3 `/health` 응답에 `db_enabled` 필드 미노출**: `HealthResponse` 모델([app/api/health.py:17-21](../../app/api/health.py#L17-L21))이 `status`, `service`, `version`, `checked_at` 4필드만 노출. REL-001 verification 체크리스트는 "GET /health → HTTP 200 + `db_enabled: true`"를 요구하지만 응답 모델에 해당 필드 없음 — 외부 health probe가 DB 연결 상태를 직접 관찰할 수 없는 gap. -- 기대 상태 - - 3건-1: 컬럼명을 의미와 일치시키거나(`portfolio_id`로 rename), 또는 ledger_trades가 정말 asset 단위로 의미가 있는지 재정의. 마이그레이션 동반. - - 3건-2: session 발급 경로에서 `no_expiry` 시도 자체를 거부하는 명시적 enforcement 코드 추가(현재는 옵션 부재로 시도 불가지만 정책 명문화 완결성). 또는 정책 본문에 "현재 코드 구조상 시도 불가, 명시적 거부 로직 불요"로 갱신. - - 3건-3: `HealthResponse`에 `db_enabled: bool` 필드 추가 또는 별도 `/readiness` endpoint 분리. 어느 경로로 갈지 결정. -- 이해관계자 - - Backend 팀 — 코드 변경 및 마이그레이션 책임 - - Operations — `/health` 응답 변경 시 health probe 동작 검증 - - REL-001 배포 이후 trex-basic 등 ledger 호출 클라이언트 — `ledger_trades.asset_id` 컬럼명 변경 시 외부 query 의존성 확인 필요(있는 경우) -- 제약사항 - - REL-001은 현 상태로 배포 진행, dcy-005 결과물은 별도 REL-002로 분리(OQ-3 결정, 2026-06-01). REL-001 verification 재실행 불요. - - 마이그레이션을 동반하면 dcy-004 / REQ-017 패턴(CREATE INDEX CONCURRENTLY 등)과 정합 유지. REL-001 마이그레이션 chain(`20260521_0023`) 다음 revision으로 자연스럽게 추가. - - `/health` 응답 schema 변경은 backwards-compat 영향 — 기존 client(probe, monitoring)가 추가 필드를 허용하는지 확인. - -## 주요 사용자 시나리오 -사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. - -- DB schema reader가 `ledger_trades.asset_id`를 보고 assets 테이블 참조라고 오해해 잘못된 join query 작성. -- 정책 감사자가 REQ-021 vs 실제 코드를 매핑할 때 session에서 `no_expiry` 거부가 코드에 명시적으로 없어 명확성 부족으로 audit finding 작성. -- 운영자가 prod에서 health probe 응답을 monitoring 도구로 받을 때 DB 연결 끊김을 빠르게 감지하지 못함(현재는 status: ok만 노출). - -# 4. 이번에 정의할 변경 -이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. - -- `ledger_trades.asset_id` 컬럼명을 의미에 맞게 `portfolio_id`로 변경하는 마이그레이션 + 코드 경로 정합화. -- `LoginRequest`에 `model_config = ConfigDict(extra="forbid")` 적용으로 session `no_expiry` 시도 자체를 HTTP 422로 거부 (OQ-1 결정, 2026-06-01). -- `HealthResponse` 모델에 `db_enabled: bool` 필드 추가 + `healthcheck()` 핸들러가 `settings.db_enabled` 노출 (OQ-2 결정, 2026-06-01). - -## 영향 범위 -변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. - -- 코드 영향: - - 3건-1: `app/repositories/postgres.py` ledger_trades INSERT/SELECT 컬럼 변경, `app/domain/models.py` LedgerTrade dataclass 필드명 (있다면), `app/api/v1_portfolios.py` ledger route handler, `migrations/versions/` 신규 revision (rename column with backfill). - - 3건-2: `app/schemas/auth.py:LoginRequest` — `model_config = ConfigDict(extra="forbid")` 추가 (OQ-1 결정 (a) 경로). `app/services/auth_service.py:login`은 변경 없음. - - 3건-3: `app/api/health.py` HealthResponse에 `db_enabled: bool` 필드 추가 + `healthcheck()` 핸들러가 `settings.db_enabled` 노출 (OQ-2 결정 (a) 경로). 별도 `/readiness` router 신설은 본 Discovery 범위 밖. -- 문서 영향: - - 3건-1: `docs/data-model.md` ledger_trades 컬럼 명세, `docs/interface-contract.md` ledger 응답 schema(asset_id가 응답에 노출되는 경우) - - 3건-2: `docs/srs/Documentation/req-021_token-expiry-and-rotation-policy.md` (선택 시 본문 갱신), `docs/runtime-flows.md` Token Expiry 섹션 - - 3건-3: `docs/interface-contract.md` /health 응답 schema(`db_enabled` 추가), `docs/runtime-flows.md` health probe flow에 "db_enabled는 설정값 노출, actual ping 아님" 명문화 (OQ-2 결정 (a) 경로) - - 3건 공통: 본 Discovery + 후속 REQ + (선택) baseline 문서 정합 -- 운영 영향: - - 3건-1: 마이그레이션 실행 시 ledger_trades 테이블 잠금 영향. CREATE INDEX CONCURRENTLY 패턴 적용 또는 짧은 ALTER COLUMN RENAME 트랜잭션. - - 3건-2: `extra="forbid"` 적용으로 LoginRequest 전체에 추가 필드 거부 효과. 기존 client(현재는 username/password만 전송)는 영향 없음. 향후 외부 client가 ignored field를 보내고 있었다면 422로 깨질 수 있으나, 본 워크스페이스는 개발용이라 영향 무. - - 3건-3: health probe 응답 필드 확장 — 본 워크스페이스는 개발용이라 외부 monitoring 영향 없음. 미래 prod 도입 시 도구가 추가 필드를 허용하는지 사전 확인. - -# 5. 요구사항 목록 -실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. - -## 기능 요구사항 -- FR-1: `ledger_trades.asset_id` 컬럼을 `portfolio_id`로 rename하는 Alembic 마이그레이션을 작성한다. 기존 데이터는 동일 의미(portfolio_id 저장)이므로 backfill 불요. INSERT/SELECT/RETURNING SQL 및 LedgerTrade dataclass 필드명도 함께 정합화한다. -- FR-2: REQ-021의 "session에서 `no_expiry` 거부" 정책을 `LoginRequest` 스키마 단계에서 코드 enforcement로 명시적 거부한다(OQ-1 결정, 2026-06-01 — (a) 경로). 구현 방법: `LoginRequest`에 `model_config = ConfigDict(extra="forbid")` 적용 또는 별도 validator로 `no_expiry` 패턴 거부. 외부 client가 LoginRequest에 정의되지 않은 필드(`no_expiry` 포함)를 전송하면 HTTP 422로 거부된다. pytest 회귀 케이스 1건(TestClient `/auth/login` body에 `no_expiry: true` 포함 → 422 확인) 신설. -- FR-3: `/health` 응답 본문에 `db_enabled: bool` 필드를 노출한다(OQ-2 결정 (a) 경로, 2026-06-01). `HealthResponse` 모델에 `db_enabled: bool` 필드 추가 + `healthcheck()` 핸들러가 `settings.db_enabled` 값을 그대로 채운다. **본 값은 환경변수 `PORTFOLIOHUB_DB_ENABLED` 설정값을 노출하는 것이며 실제 DB connectivity ping이 아님**을 docs/runtime-flows.md(또는 응답 모델 docstring)에 명문화한다. 미래 production monitoring 정착 시 `/readiness` endpoint 분리는 별도 Discovery로 분리 가능. - -## 비기능 요구사항 -성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. - -- NFR-1: 3건 모두 backwards-compat 영향 최소화. 마이그레이션(FR-1)은 짧은 ALTER COLUMN RENAME으로 처리하고 운영 lock 시간을 < 1초 권장. /health 응답 확장(FR-3)은 필드 추가형으로 기존 client 영향 없음. -- NFR-2: 본 Discovery 결과물(후속 batch verification)은 자동 테스트로 검증 가능해야 한다. ledger_trades column rename은 마이그레이션 적용 후 INSERT/SELECT 회귀, `/health` 확장은 응답 본문 schema 검증, no_expiry enforcement는 `/auth/login` body에 추가 필드 포함 시 HTTP 422 회귀. - -## 범위 경계 -이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. - -- In Scope - - `ledger_trades.asset_id` 컬럼명 정합화 (rename + 코드/스키마 갱신) - - session `no_expiry` 코드 enforcement (`LoginRequest` extra="forbid") — OQ-1 결정 (a) - - `HealthResponse`에 `db_enabled: bool` 필드 추가 (settings 값 노출, OQ-2 결정 (a)) - - 위 3건의 마이그레이션 + 회귀 테스트 -- Out of Scope - - ledger_trades 의미 재정의(asset 단위 vs portfolio 단위 — 본 REQ는 portfolio_id 의미 확정 가정) - - session token TTL 정책 변경 (REQ-021 본문 자체 정책 갱신은 본 Discovery 범위 밖) - - prod 운영 health probe 도구 변경 (운영 영역) - - prod admin token rotation 등 보안 hotfix (본 워크스페이스는 개발용 — 참조 메모리) - -# 6. 리스크/가정 목록 -불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. - -## 리스크 -- R-1 (Low): `ledger_trades.asset_id` 컬럼명 변경 시 외부 client(trex-basic 등)가 컬럼명을 직접 query하면 깨질 수 있음. - - 완화 방안: trex-basic은 HTTP API(POST /v1/portfolios/{id}/ledger/trades)만 호출하므로 DB 컬럼명 의존 없음 추정. 마이그레이션 전 외부 query 의존성 확인. -- R-2 (Low): `/health` 응답 본문 확장 시 monitoring 도구가 strict JSON schema validation을 사용하면 깨질 수 있음. - - 완화 방안: 본 워크스페이스는 개발용이라 외부 monitoring 영향 없음. prod 배포 시 사전 점검. -- R-3 (Low): 마이그레이션 적용 순서가 잘못되어 ledger_trades INSERT가 일시 실패할 수 있음. - - 완화 방안: 배포 전 staging에서 dry-run, 또는 ALTER COLUMN RENAME과 코드 배포를 같은 release window에 묶음. - -## 가정 -아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. - -- A-1: `ledger_trades.asset_id`가 저장하는 값은 portfolio_id로 일관됨(과거 다른 의미가 섞이지 않음). REQ-029 구현 시점부터 portfolio_id를 저장한 것으로 확인. **사용자 confirm-discovery(2026-06-01)로 동의 확정**. -- A-2: 본 워크스페이스는 개발용이며 prod 배포 영향 검증은 release 단계에서 별도 수행. 본 Discovery 범위는 코드/문서/마이그레이션 작성에 한정. -- A-3: 3건은 같은 batch로 묶어 처리 가능하다(서로 독립적 변경이라 상호 충돌 없음). **사용자 confirm-discovery(2026-06-01)로 동의 확정**. - -## 오픈 질문 -답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. - -- OQ-1: session `no_expiry` 처리를 (a) 코드 enforcement 추가, (b) REQ-021 본문 갱신("코드 구조상 시도 불가") 중 어느 경로로 갈지. - - 상태: Answered (2026-06-01) - - 결정: (a) 코드 enforcement. `LoginRequest`에 `model_config = ConfigDict(extra="forbid")` 적용으로 정의되지 않은 필드(`no_expiry` 포함)를 HTTP 422로 거부. 정책 명문(REQ-021) 그대로 유지, 미래 안전망 효과 확보. - - 본문 반영: FR-2, # 4 코드/운영 영향, # 7 S-2 측정 방식, # 10 DECIDE 섹션에서 항목 제거 -- OQ-2: `/health` 응답에 `db_enabled` 추가 방식을 (a) HealthResponse 필드 추가, (b) 별도 `/readiness` endpoint 분리 중 어느 경로로 갈지. - - 상태: Answered (2026-06-01) - - 결정: (a) HealthResponse 필드 추가. `settings.db_enabled`(환경변수 PORTFOLIOHUB_DB_ENABLED 설정값)를 응답에 노출. **본 값은 설정값이며 actual DB connectivity ping이 아님**을 docs/runtime-flows.md 및 응답 모델 docstring에 명문화한다. 본 워크스페이스가 개발용이므로 Kubernetes-grade readiness 분리는 over-spec. 미래 production monitoring 정착 시 `/readiness` 분리는 별도 Discovery로 분리 가능. - - 본문 반영: FR-3, # 4 코드/문서/운영 영향, # 7 S-3 측정 방식, # 10 DECIDE 섹션에서 항목 제거 -- OQ-3: `ledger_trades.asset_id` rename 마이그레이션을 REL-001 배포 전/후 어디에 넣을지(별도 release 또는 REL-001 후속). - - 상태: Answered (2026-06-01) - - 결정: 별도 **REL-002**로 분리. REL-001은 현 상태(BAT-001~014 + REQ-028 augmentation, verification 완료, smoke 통과)로 배포 진행. dcy-005 3건은 신규 batch(BAT-015 가정) + REL-002로 분리해 sequence를 명확히 한다. - - 사유: REL-001 verification 안정성 최우선, 마이그레이션 sequence 분리, dcy-005가 "REL-001 잔여 정리" 묶음 trigger로 독립 추적 가치. REL-001 verification gap(`/health db_enabled` 미노출 [~] 부분 통과)은 우회 검증으로 blocker 아님. - - 본문 반영: # 1 해석, # 3 제약사항, # 10 DECIDE 섹션에서 항목 제거 - - **Superseded (2026-06-05)**: 사용자 결정으로 미배포 batch 전체(BAT-001~016)를 단일 REL-001로 통합 → 별도 REL-002는 생성하지 않음. BAT-015는 REL-001에 흡수. ([rel-001](../releases/rel-001_20260521_v1-api-full-surface.md) Scope 통합 노트) - -### 오픈 질문 처리 기준 -- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. -- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. -- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. -- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. - -# 7. 초기 성공 기준 -이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. - -- S-1: `ledger_trades` 테이블에 `portfolio_id` 컬럼이 존재하고 `asset_id` 컬럼이 더 이상 사용되지 않는다(코드 grep + DB \d 검증). 기존 INSERT/SELECT 회귀 테스트 모두 통과. -- S-2: REQ-021 정책과 실제 코드가 일치한다 — `/auth/login` body에 `no_expiry: true`(또는 LoginRequest에 정의되지 않은 추가 필드)를 포함해 시도 시 HTTP 422 응답 (OQ-1 결정 (a) 경로, 2026-06-01). -- S-3: `GET /health` 응답이 `db_enabled` 필드를 직접 노출하고 그 값이 `settings.db_enabled`와 일치한다 (OQ-2 결정 (a) 경로, 2026-06-01). 응답 본문 schema 검증 + grep. - -## 측정 방식 및 데이터 출처 -각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. - -- S-1 측정 방식: `grep -rn "ledger_trades\..*asset_id\|.asset_id\b" app/repositories/postgres.py app/domain/models.py app/api/v1_portfolios.py` 결과 0건 + DB `\d ledger_trades`에서 `portfolio_id` 컬럼 확인 / 데이터 출처: 코드 grep + alembic upgrade 후 DB schema -- S-2 측정 방식: pytest TestClient `/auth/login` body에 `no_expiry: true` 포함 → HTTP 422 응답 회귀 / 데이터 출처: 신규 pytest 케이스 (예: `tests/test_auth.py::test_login_rejects_no_expiry_extra_field`) -- S-3 측정 방식: pytest TestClient `GET /health` 응답 JSON에 `"db_enabled": true` 매치 (PORTFOLIOHUB_DB_ENABLED=true 환경) + 로컬 smoke `curl http://127.0.0.1:18000/health` / 데이터 출처: 신규 pytest 케이스 (예: `tests/test_app.py::test_health_exposes_db_enabled`) + 로컬 smoke - -# 8. REQ로 넘기기 전 확인 체크 -REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. - -- [x] 범위(In Scope/Out of Scope) 확정 - - 기준: OQ-1/OQ-2/OQ-3 모두 2026-06-01 Answered. 범위 + 배포 시점(REL-002 분리) 확정. -- [x] 성공 지표의 측정 방식/데이터 출처 확정 - - 기준: S-1(코드 grep + DB \d), S-2(pytest 422 회귀), S-3(pytest TestClient + 로컬 smoke) 모두 확정. -- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 - - 기준: 본 Discovery에는 High 리스크 없음 (R-1/R-2/R-3 모두 Low) — 해소 책임자 지정 불요. -- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - - 기준: OQ-1/OQ-2/OQ-3 모두 Answered (2026-06-01). Open 항목 0개. -- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 - - 기준: CONFIRMED_BY = "Justin Ko" (confirm-discovery 2026-06-01). - -# 9. 파일 처리 결과 -이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. - -- 처리 결과: 생성 -- 생성 경로: docs/discovery/dcy-005_20260601_rel-001-feedback-cleanup.md -- 참조 문서: - - [docs/releases/rel-001_20260521_v1-api-full-surface.md](../releases/rel-001_20260521_v1-api-full-surface.md) Feedback Handoff Discovery Input 항목 - - [docs/srs/Documentation/req-021_token-expiry-and-rotation-policy.md](../srs/Documentation/req-021_token-expiry-and-rotation-policy.md) (REQ-021 정책 본문) - - [docs/srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md](../srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md) Notes (ledger_trades 컬럼명 follow-up 출처) - - [app/api/health.py:17-32](../../app/api/health.py#L17-L32) HealthResponse 모델/handler - - [app/repositories/postgres.py:1284-1294](../../app/repositories/postgres.py#L1284-L1294) ledger_trades INSERT - - [app/services/auth_service.py:47-65](../../app/services/auth_service.py#L47-L65) AuthService.login - -# 10. 사용자 결정 필요 항목 요약 -사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. - -## DECIDE -- (none — OQ-3 결정 완료, DECIDE 섹션 비움) - -## CONFIRM -- (none — 두 항목 모두 사용자 confirm-discovery(2026-06-01)로 가정 A-1/A-3에 대한 동의 확정. 본문 가정 섹션에 반영됨.) - -## DATA -- (none — 본 Discovery는 본 repo 내부 정보만으로 자립) - -# 11. Discovery Freeze -REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. - -## 섹션 참조 -- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) -- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) -- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) -- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) -- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) - -## Freeze 플래그 -- Handoff Decision: 승인 -- Handoff Rationale: OQ-1/OQ-2/OQ-3 모두 Answered, CONFIRM 2건은 사용자 confirm-discovery(2026-06-01)로 가정 A-1/A-3 동의 확정. # 8 체크리스트 5건 모두 [x]. REQ Drafting 진입 준비 완료. -- Ready for REQ Drafting: true -- Confirmed By: Justin -- Confirmed At (KST): 2026-06-01 18:30 - -# 12. 작성 가이드 -- 이 파일은 dcy-005 작성 완료본이다. confirm-discovery 진입 전까지 OQ 결정 + 본문 반영 필요. -- 3건이 서로 독립이므로 OQ 결정도 항목별로 진행 가능. 결정이 끝나는 항목부터 본문(FR-2/FR-3)을 (a)/(b) 선택안으로 압축한다. -- 본 Discovery는 코드/문서/마이그레이션 모두 영향이 있는 표준 SDLC 트랙. minor-change fast path는 3건 묶음 특성상 부적합 — 표준 batch로 진행 권장. diff --git a/docs/discovery/dcy-006_20260605_ledger-trade-write-portfolio-state-return.md b/docs/discovery/dcy-006_20260605_ledger-trade-write-portfolio-state-return.md deleted file mode 100644 index f7ecbcb..0000000 --- a/docs/discovery/dcy-006_20260605_ledger-trade-write-portfolio-state-return.md +++ /dev/null @@ -1,332 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-006_20260605_ledger-trade-write-portfolio-state-return -- 이슈명: ledger trade write 회계 책임 단일화 — 응답에 portfolio 상태 포함 + portfolio_ledger/ledger_trades 테이블 통합 (trex-core drift 방지) -- 작성 시각(KST): 2026-06-05 16:00 -- 마지막 갱신 시각(KST): 2026-06-05 21:30 -- 대체됨: 없음 -- 후속 Discovery 참조: 없음 -- 생성된 REQ 참조: REQ-036 ([req-036](../srs/Migration/req-036_portfolio-ledger-consolidation-migration.md)), REQ-037 ([req-037](../srs/Interface/req-037_ledger-trade-write-accounting-and-state-response.md)), REQ-038 ([req-038](../srs/Interface/req-038_legacy-buy-sell-endpoint-deprecation.md)) - -# 1. 계획 상태 요약 -현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. - -- 연계 문서 현황: - - 관련 점수 파일: 미생성 - - 관련 TODO/메모: [GitHub issue #8](https://github.com/fourblocks2020/portfolio-hub/issues/8) (2026-06-05 작성, trex-core daemon 측 요청) -- 해석: - - trex-core daemon이 ledger write 후 portfolio cash/position 스냅샷을 로컬 replay로 누적 보정하는 방식에서 portfolio-hub와 drift가 누적된다. portfolio-hub가 ground truth이므로 ledger write 응답이 거래 반영 후 권위 상태를 함께 반환하면 trex-core가 그 값으로 스냅샷을 덮어써 drift 없이 유지 가능하고 read round-trip이 불필요해진다. - - **사용자 결정 (2026-06-05)** — 본 Discovery 범위가 격상되었다. 처음에는 단순 응답 schema 확장 + 회계 책임 단일화로 시작했으나, 두 ledger 시스템(`portfolio_ledger`와 `ledger_trades`)의 공존이 본질적 분기 원인임이 드러나면서 본 Discovery는 다음을 함께 다룬다: - 1. `POST /v1/portfolios/{portfolio_id}/ledger/trades`를 portfolio 회계의 단일 권위 endpoint로 단일화 (OQ-1 옵션 B): ledger 기록 시 portfolio cash/quantity 트랜잭션 갱신 - 2. **`portfolio_ledger` 테이블을 회계 원장으로 단일화 + `ledger_trades` 테이블의 idempotency/broker 식별 필드(`order_id`, `execution_id`, `side`, `executed_at`, UNIQUE constraint, `ledger_entry_id` BigInt Identity PK)를 portfolio_ledger에 흡수 후 ledger_trades 삭제** (OQ-6 옵션 (iv)) - 3. 응답 schema에 portfolio 상태 비파괴 확장 (`cash_balance_after`, `asset_quantity_after`, OQ-2 (b)) - 4. 레거시 `POST /portfolios/{id}/buy`·`/sell` deprecated 표기 (OQ-5) - - 부수 결정: 컬럼 타입 NUMERIC(20,8)로 일관, action 컬럼 제거 후 side 단일화, 테이블명 `portfolio_ledger` 유지. 본 워크스페이스 `ledger_trades` 미생성 상태라 backfill 비용 0(prod 마이그레이션은 별도 검토). - -# 2. 요구사항 판정 결과 -이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. - -- 판정: 신규 -- 근거 - - REQ-029(POST /v1/portfolios/{portfolio_id}/ledger/trades 이전)는 path 이전 + 기존 동작 보존이 핵심이며 응답 schema 확장이나 portfolio 상태 갱신은 다루지 않는다. - - dcy-005 REQ-033(ledger_trades 컬럼명 정합화)는 명명만 다뤘고 응답 schema에 portfolio 상태 필드 추가는 별도 trigger다. - -# 3. 문제점의 요약 -문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. - -- 현재 상태 - - **portfolio-hub에 거래 endpoint가 시간 순으로 3종 존재**: - 1. 레거시 buy/sell: `POST /portfolios/{id}/buy`, `POST /portfolios/{id}/sell` ([app/api/portfolios.py:158,176](../../app/api/portfolios.py#L158)). **현재 살아있음**. 회계 동반(cash/quantity 갱신) + `portfolio_ledger` 테이블 기록. CLI(`pfhub portfolio buy/sell`)와 옛 자동매매 흐름에서 사용. **사용자 결정(2026-06-05): 추후 deprecated/삭제 대상**. - 2. 구 path: `POST /v1/assets/{asset_id}/ledger/trades`. **REQ-029/BAT-011(2026-05-21)에서 제거됨**. asset_id 명명 혼용 결함으로 신 path로 이전됨. - 3. 신 path: `POST /v1/portfolios/{portfolio_id}/ledger/trades` ([app/api/v1_portfolios.py:514-588](../../app/api/v1_portfolios.py#L514-L588)). **현재 살아있음**. audit-only(`ledger_trades` 테이블 insert만, portfolio cash/quantity 갱신 안 함). trex-core daemon이 호출. **사용자 결정(2026-06-05): 본 endpoint를 portfolio 회계의 단일 권위 endpoint로 단일화 — 본 Discovery 핵심 변경 대상**. - - **두 ledger 테이블 공존**: - - `portfolio_ledger` ([postgres.py:263-282](../../app/repositories/postgres.py#L263-L282) ensure_schema): 시점별 잔고 snapshot(`cash_balance_after`, `asset_quantity_after`, `total_value_after`, `profit_buffer_after`) 기록. 회계 원장 성격. 레거시 buy/sell이 `repo.save`로 갱신. - - `ledger_trades` (migration `20260502_0014`): broker idempotency(`order_id` + `execution_id` UNIQUE)와 체결 raw 기록. 잔고 컬럼 없음. audit 성격. 신 path가 사용. - - `insert_ledger_trade`([postgres.py:1267-1315](../../app/repositories/postgres.py#L1267-L1315))는 `ledger_trades` INSERT만 수행하고 portfolios UPDATE 없음. - - 회계 흐름 구현은 이미 도메인 서비스([app/domain/services.py:87-145,148-205](../../app/domain/services.py#L87-L205))에 `apply_buy`/`apply_sell`로 존재한다(잔고 계산 + 음수 검증). 다만 신 path와 연결되어 있지 않다. - - 즉 trex-core가 신 path만 호출하면 portfolios.cash_balance/asset_quantity는 영원히 갱신되지 않으며, trex-core가 자체 replay로 보정 → portfolio-hub와 drift 누적. 이게 issue #8의 근본 원인. - - REL-001 배포(BAT-001~014)는 현 시점 draft. BAT-015(dcy-005 산출물)는 release-candidate, REL-002 미작성. -- 기대 상태 - - ledger write 응답이 거래 반영 후 portfolio 권위 상태(`cash_balance`, `asset_quantity`)를 포함한다. trex-core는 이 값으로 스냅샷을 즉시 덮어쓰고 별도 read 호출 없이 정확히 동기화 가능. - - `status="new"`(신규 ledger row): 거래 반영 **후** 상태 반환. - - `status="duplicate"`(idempotent 재호출): ledger row 재생성 없이 현재 권위 상태 동일 반환. - - 기존 인가(daemon 3단계), idempotency, 응답 필드, status 의미는 비파괴적으로 보존. -- 이해관계자 - - Backend 팀 (portfolio-hub) — 응답 schema 확장 + ledger insert 트랜잭션 동작 변경 + portfolio_ledger/ledger_trades 통합 마이그레이션 책임 - - trex-core daemon 팀 (slot-core, 별도 repo) — 본 응답으로 스냅샷 갱신 흐름 구현, 회계 정책 정합 확인 - - 레거시 ledger 호출자 (CLI `pfhub portfolio buy/sell`) — deprecated 표기 대상 + portfolio_ledger 신 스키마 하에서 동작 유지 검증 -- 제약사항 - - REQ-029(`POST /v1/portfolios/{id}/ledger/trades`) 기존 동작·인가·idempotency·status 의미는 보존(비파괴 확장). - - REL-001 배포 일정과 충돌 회피 — 본 Discovery는 REL-001/BAT-015와 분리된 별도 batch + release로 분리 가능. - - workspace_purpose 메모리 정합 — 본 워크스페이스는 개발용, prod 운영 검증은 release 단계로 위임. - -## 주요 사용자 시나리오 -사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. - -- trex-core daemon이 슬롯 매매 체결 후 ledger write → 응답에서 portfolio cash/quantity를 받아 자체 스냅샷에 덮어쓰기 → 매수 전략 모듈은 항상 최신 권위 상태를 본다. -- trex-core가 idempotent 재시도(같은 order_id + execution_id) 호출 → `status="duplicate"` 응답 + 현재 권위 portfolio 상태 → 재시도가 spurious raise 없이 idempotent 정합 유지. -- 기존 ledger row 호출자(있다면)는 새 필드를 무시(JSON 확장형)하여 기존 흐름 깨짐 없이 호환. - -# 4. 이번에 정의할 변경 -이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. - -- **회계 권위 endpoint 단일화** (OQ-1 결정 (B), 2026-06-05): `POST /v1/portfolios/{portfolio_id}/ledger/trades`가 portfolio cash/quantity 갱신 책임을 가지도록 단일 트랜잭션에 ledger INSERT + portfolio UPDATE를 묶는다. -- **회계 원장 테이블 단일화** (OQ-6 결정 (iv), 2026-06-05): `portfolio_ledger`를 단일 회계 원장으로 유지하고 `ledger_trades`의 idempotency/broker 식별 필드(`order_id`, `execution_id`, `side`, `executed_at`, UNIQUE constraint, `ledger_entry_id` BigInt Identity PK)를 portfolio_ledger에 흡수한다. 흡수 후 `ledger_trades` 테이블 + 도메인 모델(`LedgerTrade`) 삭제. portfolio_ledger의 수치 컬럼 타입은 DOUBLE PRECISION → NUMERIC(20,8)로 일관화. 기존 `action` 컬럼은 제거하고 `side`("buy"/"sell")로 통일. -- **응답 schema 확장** (OQ-2 결정 (b), 2026-06-05): `LedgerTradeResponse` 응답 root에 `cash_balance_after: float`, `asset_quantity_after: float` 두 필드만 flat으로 비파괴적 추가. -- **duplicate 호출 일관성** (OQ-4 결정 (b), 2026-06-05): `status="duplicate"` 응답은 duplicate 호출 시점의 portfolios 테이블 현재 권위 상태를 SELECT 1회로 조회해 반환. -- **레거시 endpoint deprecated 표기** (OQ-5 결정, 2026-06-05): `POST /portfolios/{id}/buy`·`/sell` 엔드포인트는 deprecated 분류 → 추후 삭제. 본 Discovery는 표기만 다루며 실제 제거는 후속 batch. -- **baseline 문서 갱신**: `docs/interface-contract.md` + `docs/runtime-flows.md` + `docs/data-model.md`에 회계 원장 단일화, ledger_trades 삭제, 응답 schema 확장, 회계 동작을 명문화. -- **도메인 모델 통합**: `LedgerEntry`와 `LedgerTrade` 두 dataclass를 통합 (단일 회계 원장 모델). 적절한 이름으로 명명. - -## 영향 범위 -변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. - -- 코드 영향 (대규모 — 격상된 범위): - - **마이그레이션 (신규 revision)**: - - `portfolio_ledger`에 컬럼 추가: `ledger_entry_id BigInt Identity PRIMARY KEY`, `order_id TEXT NULL`, `execution_id TEXT NULL`, `side TEXT NOT NULL`, `executed_at TIMESTAMPTZ NOT NULL` (OQ-7 (ii): order_id/execution_id는 NULL 허용) - - 기존 데이터의 신규 컬럼 backfill (recorded_at → executed_at 복사, 기존 action 'BUY'/'SELL'을 'buy'/'sell'로 mapping 후 side에 입력, order_id/execution_id는 NULL — synthetic 생성 불요) - - partial UNIQUE index `(portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL` 추가 (broker write만 idempotency; REQ-017 partial unique index 패턴 재사용) - - 컬럼 타입 변경: DOUBLE PRECISION → NUMERIC(20,8) (`price`, `quantity`, `fee_amount`, `tax_amount`, `cash_balance_after`, `asset_quantity_after`, `total_value_after`, `profit_buffer_after`) - - 기존 `action` 컬럼 제거 (side로 통합 후) - - 기존 인덱스 `idx_portfolio_ledger_portfolio_time`은 유지, 추가 인덱스 `idx_portfolio_ledger_portfolio_executed (portfolio_id, executed_at)` 신설 - - `ledger_trades` 테이블 DROP (본 워크스페이스 미생성이라 비용 0, prod는 데이터 검토 후 backfill or drop 결정) - - `app/domain/models.py`: - - `LedgerEntry`와 `LedgerTrade` 통합 → 단일 도메인 모델 (이름은 `LedgerEntry` 유지 권장, 신규 broker 필드 흡수) - - `LedgerTrade` 삭제 - - `app/repositories/postgres.py`: - - `insert_ledger_trade` 삭제 또는 통합 → portfolio_ledger insert + portfolios UPDATE 단일 트랜잭션 함수로 재정의 - - `_row_to_ledger_trade` 삭제 또는 `_row_to_ledger_entry`와 통합 - - `save()` 함수(`postgres.py:1175+`) — 기존 레거시 흐름과 정합 (호출자는 deprecated이지만 동작 유지) - - portfolio_ledger SQL 갱신 (신규 컬럼 INSERT/RETURNING) - - `SELECT ... FOR UPDATE` row lock 적용 - - `app/repositories/portfolio_repository.py` — interface 시그니처 갱신, `insert_ledger_trade` deprecated/통합 - - `app/api/v1_portfolios.py:record_ledger_trade_v1` — 새 통합 함수 호출 + 응답에 갱신 portfolio 상태 포함 - - `app/schemas/v1.py` — `LedgerTradeResponse`에 `cash_balance_after`, `asset_quantity_after` flat 추가 - - 음수 검증 제거 — 신 path 전용 회계 헬퍼 (`apply_buy_no_validation`/`apply_sell_no_validation` 가칭) 추출, 기존 `apply_buy`/`apply_sell`은 레거시 endpoint 전용 유지 -- 문서 영향: - - `docs/interface-contract.md` — `POST /v1/portfolios/{id}/ledger/trades` 회계 동작 + 응답 schema. 레거시 `/portfolios/{id}/buy|sell` deprecated 표기. - - `docs/runtime-flows.md` — ledger write flow에 회계 적용 + portfolio 갱신 + 트랜잭션 lock 단계 + ledger_trades 삭제 사실 + portfolio_ledger 단일 원장 명문화 - - `docs/data-model.md` — Portfolio entity ledger-driven mutation source 명시. **두 ledger 테이블 → portfolio_ledger 단일화 명시 + 신규 컬럼 명세 + 정밀도 NUMERIC(20,8) + side 컬럼 + order_id/execution_id(NULL 허용) + partial UNIQUE index `(portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL`**. `ledger_trades` entity 섹션 제거. -- 운영 영향: - - 응답 schema 확장은 비파괴적이라 기존 client 무영향 - - ledger insert가 portfolio mutation을 동반하므로 트랜잭션 격리/race condition 검토 필요. row lock 적용. - - 레거시 buy/sell endpoint 호출자(CLI `pfhub portfolio buy/sell`) — 본 Discovery는 deprecated 표기만, 실제 호출 차단/제거는 후속 작업. 다만 동작은 그대로 유지(`portfolio_ledger` 컬럼 타입 변경 영향만). - - prod에서 `ledger_trades` 데이터 보존이 필요하다면 신규 portfolio_ledger 컬럼으로 backfill 필요 — 본 워크스페이스 미생성이라 N/A, 별도 검토 항목으로 분리. - - workspace_purpose 메모리: prod 영향 검증은 release 단계 (별도 release로 분리 권장) - -# 5. 요구사항 목록 -실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. - -## 기능 요구사항 -- FR-1: `LedgerTradeResponse`에 portfolio 상태 필드를 응답 root에 flat으로 비파괴적으로 추가한다(OQ-2 결정 (b), 2026-06-05). 추가 필드는 정확히 2개: `cash_balance_after: float`, `asset_quantity_after: float` (issue #8 요구 그대로 최소). 추가 필드(`total_value_after`, `profit_buffer_after` 등)는 본 Discovery 범위 밖이며 후속 Discovery에서 다룬다. -- FR-2: `status="new"` 응답에 거래 반영 **후** 권위 상태를 반환한다(OQ-1 결정 (B), 2026-06-05). ledger insert 트랜잭션 내 portfolio cash/quantity 갱신 후 새 값 반환. -- FR-3: `status="duplicate"` 응답은 ledger row를 재생성하지 않고 **duplicate 호출 시점의 portfolios 테이블 현재 권위 상태**(`cash_balance`, `asset_quantity`)를 SELECT로 조회해 반환한다(OQ-4 결정 (b), 2026-06-05). 첫 호출 시점 결과의 영구 echo가 아니라 호출 시점 권위 상태 — trex-core가 항상 최신 권위 상태로 sync되도록 보장(issue #8 본문 명시 충족, drift 방지 일관성). 결과적으로 `portfolio_ledger` row는 정확히 1건 / portfolio 갱신은 1회만 발생 — 부수 효과의 멱등성은 유지된다. -- FR-4: 기존 인가(daemon 3단계), idempotency unique constraint, status 의미는 보존한다 (비파괴 확장). -- FR-5: `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md` baseline 갱신: 신 path의 회계 책임, 응답 schema, 두 ledger 테이블의 관계 명문화. -- FR-6: portfolio_ledger INSERT와 portfolio cash/quantity 갱신을 **단일 DB 트랜잭션**으로 묶는다. 회계 공식은 기존 도메인 서비스(`apply_buy`/`apply_sell`)와 동일: side="buy" → `cash -= qty*price + fee + tax`, `quantity += qty`. side="sell" → `cash += qty*price - fee - tax`, `quantity -= qty`. **신 path에서는 잔고/수량 부족 거부 검증을 적용하지 않는다(OQ-3 결정, 2026-06-05) — 음수 `cash_balance`와 음수 `asset_quantity` 모두 허용**. 신 path ledger 기록은 broker 체결의 사후 보고이므로 broker 정산 결과를 portfolio-hub가 그대로 수용한다. (레거시 `POST /portfolios/{id}/buy`·`/sell`은 기존 거부 정책 유지 — 이미 deprecated 분류이므로 본 변경 대상 아님.) 따라서 기존 `apply_buy`/`apply_sell`을 그대로 재사용하지 않고 **음수 검증을 제거한 별도 회계 헬퍼**를 ledger 흐름 전용으로 추출하거나 신 path 내부에 inline 구현한다. -- FR-7: 동시 ledger write race condition에 대한 row-level lock(`SELECT ... FOR UPDATE`) 적용. lost update 방지. -- FR-8: 레거시 `POST /portfolios/{id}/buy`·`/sell` endpoint에 deprecated 표기를 응답 헤더(`Deprecation: true`) 또는 OpenAPI metadata로 추가한다(OQ-5 결정, 2026-06-05). 실제 endpoint 제거는 본 Discovery 범위 밖, 후속 정리 batch에서 처리. -- FR-9 (OQ-6 결정 (iv) + OQ-7 결정 (ii), 2026-06-05): `portfolio_ledger`에 broker 식별 필드 추가 alembic 마이그레이션 작성. 추가 컬럼: `ledger_entry_id BigInt Identity PK`, `side TEXT NOT NULL`, `executed_at TIMESTAMPTZ NOT NULL`, `order_id TEXT NULL`, `execution_id TEXT NULL`. **partial UNIQUE index `(portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL`** (broker write만 idempotency, 레거시/수동 write는 NULL → append-only). 기존 레거시 데이터 backfill: action→side 매핑, recorded_at→executed_at 복사, order_id/execution_id는 **NULL**(synthetic 값 생성 불요). -- FR-10 (OQ-6 결정 (iv)): `portfolio_ledger`의 수치 컬럼(`price`, `quantity`, `fee_amount`, `tax_amount`, `cash_balance_after`, `asset_quantity_after`, `total_value_after`, `profit_buffer_after`)을 DOUBLE PRECISION → NUMERIC(20,8) 변경. `ledger_trades`와 정밀도 일관. -- FR-11 (OQ-6 결정 (iv)): 기존 `action` 컬럼 제거 (신규 `side` 컬럼으로 일관 — `action='BUY'` → `side='buy'`, `action='SELL'` → `side='sell'`). -- FR-12 (OQ-6 결정 (iv)): `ledger_trades` 테이블 DROP. 본 워크스페이스 `ledger_trades` 미생성이라 비용 0. prod 환경 데이터는 별도 검토 항목으로 분리(release 단계). -- FR-13 (OQ-6 결정 (iv)): 도메인 모델 통합 — `LedgerEntry` 단일화. `LedgerTrade` dataclass 삭제. broker 필드(`ledger_entry_id`, `order_id`, `execution_id`, `side`, `executed_at`)를 `LedgerEntry`에 흡수. -- FR-14 (OQ-6 결정 (iv)): repository 통합 — `insert_ledger_trade` 삭제 또는 새 통합 함수로 재정의. 신 path 호출은 portfolio_ledger insert + portfolios UPDATE 단일 트랜잭션. -- FR-15 (OQ-7 결정 (ii), 2026-06-05): 신 path duplicate 감지 SQL을 partial unique index 대상으로 전환 — `ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL DO NOTHING` (기존 `ON CONFLICT ON CONSTRAINT uq_ledger_trades_idempotency`를 대체). PostgreSQL partial index inference 사용. - -## 비기능 요구사항 -성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. - -- NFR-1: 응답 schema 확장은 기존 client(있다면)에 backwards-compat 영향이 없어야 한다(JSON 추가 필드형). 본 워크스페이스는 개발용이라 외부 client 영향은 release 단계 검증. -- NFR-2: portfolio_ledger insert + portfolio update가 단일 트랜잭션으로 묶여 부분 실패 시 ledger row만 또는 portfolio만 갱신되는 inconsistent state가 발생하지 않아야 한다. -- NFR-3: 동시 ledger write 시 race condition(같은 portfolio에 동시 buy/sell)에서 cash_balance/asset_quantity가 lost update 되지 않아야 한다. 적절한 row-level lock(예: `SELECT FOR UPDATE`) 또는 optimistic concurrency 적용. - -## 범위 경계 -이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. - -- In Scope - - `LedgerTradeResponse` portfolio 상태 필드 비파괴 추가 (FR-1) - - `status="new"` / `status="duplicate"` 응답 의미 명문화 (FR-2/3) - - 기존 인가·idempotency·status 보존 (FR-4) - - baseline 문서 3종(interface-contract / runtime-flows / data-model) 갱신 (FR-5) - - ledger INSERT + portfolio UPDATE 단일 트랜잭션 + 회계 공식 적용 (FR-6) — OQ-1 결정 (B) - - row-level lock으로 race condition 방지 (FR-7) - - 레거시 buy/sell endpoint deprecated 표기 (FR-8) - - **`portfolio_ledger` 테이블에 broker 식별 필드 추가 + 컬럼 타입 NUMERIC(20,8) 통일 + action→side 통일 (FR-9/10/11)** — OQ-6 결정 (iv) - - **`ledger_trades` 테이블 DROP** + 도메인 모델 통합(LedgerTrade 삭제, LedgerEntry 단일화) (FR-12/13) - - repository 통합 — `insert_ledger_trade` 재정의 또는 통합 (FR-14) - - partial UNIQUE index + `ON CONFLICT ... WHERE order_id IS NOT NULL` duplicate 감지 (FR-15) — OQ-7 결정 (ii) -- Out of Scope - - 레거시 `POST /portfolios/{id}/buy`·`/sell` endpoint **실제 제거** — 본 Discovery는 deprecated 표기만, 코드 제거는 후속 정리 batch - - prod 환경 `ledger_trades` 데이터 backfill 정책 — 본 워크스페이스는 미생성이라 N/A. prod는 release 단계에서 별도 검토 - - profit_buffer, asset_price 같은 추가 portfolio 응답 필드 — 본 issue는 cash_balance/asset_quantity만 요청 - - read 엔드포인트(`GET /v1/portfolios`) schema 변경 — 본 Discovery는 ledger write 응답만 다룸 - - trex-core daemon 측 구현 (외부 repo) - - 레거시 CLI(`pfhub portfolio buy/sell`) 호출 차단 또는 동작 변경 — 본 Discovery는 deprecated 표기만, 동작은 portfolio_ledger 신 스키마에서 유지 - -# 6. 리스크/가정 목록 -불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을 분리해 기록한다. - -## 리스크 -- R-1 (Medium): ledger insert + portfolio update 트랜잭션 race condition으로 lost update 발생. - - 완화 방안: portfolios row에 `SELECT FOR UPDATE` 적용 또는 `UPDATE ... WHERE updated_at = ` optimistic concurrency. 동시 write 회귀 테스트 신설. -- R-2 (Dropped, 2026-06-05): "회계 공식이 외부 portfolio mutation 흐름과 불일치" 리스크는 OQ-5(레거시 buy/sell deprecated)·OQ-6((iv) 단일 원장 통합) 결정으로 R-8(두 회계 헬퍼 divergence)로 흡수됨. R-8을 source of truth로 둔다. -- R-3 (Dropped, 2026-06-05): "옵션 A 채택 시 stale snapshot" 리스크는 OQ-1이 옵션 B로 확정되어 더 이상 유효하지 않음. -- R-4 (Low): 응답 shape 변경(중첩 vs flat)이 trex-core JSON parser와 호환 안 됨. - - 완화 방안: trex-core 팀과 사전 합의(OQ-2 (b) flat 결정 + issue #8 응답 명세 commented at 2026-06-05). -- R-5 (Medium): portfolio_ledger 컬럼 타입 변경(DOUBLE → NUMERIC) 시 기존 데이터 정밀도 변환 손실(IEEE 754 부동소수점에서 십진 NUMERIC으로 매핑 시 미세 오차) — 다만 본 워크스페이스 portfolio_ledger 기존 데이터는 개발용이라 영향 미미. - - 완화 방안: prod 마이그레이션 시점에 별도 검토 (release plan). 본 워크스페이스는 N/A. -- R-6 (Medium): `action` → `side` 변환 시 기존 portfolio_ledger 데이터의 action 값이 'BUY'/'SELL' 외 자유형식(예: 'manual')이면 매핑 누락. - - 완화 방안: 마이그레이션 전 action 값 분포 조회 → 누락 케이스 처리 정책 결정 (drop or 'unknown' fallback). 본 워크스페이스 데이터는 개발용이라 분포 단순. -- R-7 (Low): ledger_trades DROP 시점에 진행 중 트랜잭션이 있으면 실패 가능. - - 완화 방안: 본 워크스페이스 ledger_trades 미생성이라 N/A. prod는 release window 잠금 + 단순 DROP. -- R-8 (Medium): 신 path 회계 헬퍼와 레거시 apply_buy/apply_sell의 두 회계 공식이 long-term divergence할 위험(음수 정책 분기). - - 완화 방안: 공통 회계 공식 함수 추출 + 음수 검증 wrapper 패턴. 회귀 테스트로 두 흐름 일관성 검증. - -## 가정 -아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. - -- A-1: portfolio.cash_balance / asset_quantity는 ground truth이고 trex-core는 portfolio-hub 값을 신뢰한다(issue 본문 명시). -- A-2: 본 워크스페이스는 개발용이며 prod race condition / 트랜잭션 격리 / 마이그레이션 정밀도 검증은 release 단계에서 별도 수행. workspace_purpose 메모리 정합. -- A-3: 외부 ledger 호출자(trex-basic 등)는 응답 schema 확장에 backwards-compat 호환된다(JSON parser가 추가 필드 무시). -- A-4: 본 Discovery 결과물은 BAT-015(release-candidate)와 분리된 새 batch + 별도 release(REL-003 등)로 분리한다 — REL-001/REL-002 일정과 충돌 회피. **사용자 confirm-discovery(2026-06-05)로 동의 확정**. **Superseded (2026-06-05)**: 이후 사용자 결정으로 미배포 batch 전체(BAT-001~016)를 단일 REL-001로 통합 → 별도 REL-003 미생성, BAT-016은 REL-001에 흡수. ([rel-001](../releases/rel-001_20260521_v1-api-full-surface.md) Scope 통합 노트) - -## 오픈 질문 -답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. - -- OQ-1: ledger insert가 portfolio cash/quantity를 갱신할지 결정 — 옵션 A(단순 join 조회만) vs 옵션 B(ledger insert 트랜잭션 내 갱신). - - 상태: Answered (2026-06-05) - - 결정: **(B) ledger insert 트랜잭션 내 portfolio cash/quantity 갱신**. 사용자 의도: 신 path를 portfolio 회계의 단일 권위 endpoint로 단일화한다. trex-core가 ledger write 1회로 회계까지 완결되며 drift가 원천 차단된다. - - 본문 반영: FR-2/FR-6/FR-7, # 4 변경, # 5 In Scope -- OQ-2: 응답 shape — 중첩(`portfolio_state: {cash_balance, asset_quantity}`) vs flat(`cash_balance_after`, `asset_quantity_after`). - - 상태: Answered (2026-06-05) - - 결정: **(b) flat** — 응답 root에 `cash_balance_after`, `asset_quantity_after` 두 필드만 추가. 기존 PortfolioItem schema flat 컨벤션 + `portfolio_ledger` 테이블 컬럼명과 직접 정합. issue 본문 "shape 메인테이너 재량" 권한 행사. - - 추가 정책: 응답 필드는 issue #8 명시한 cash/quantity 2개로 **최소** 유지. `total_value_after`, `profit_buffer_after` 등 추가 후보는 본 Discovery 범위 밖, 후속 Discovery에서 검토. - - 본문 반영: FR-1, # 4 변경, # 5 In/Out Scope, # 10 DECIDE 섹션에서 항목 제거 -- OQ-3: 회계 공식 + 음수 잔고/수량 허용 여부. - - 상태: Answered (2026-06-05) - - 결정 (공식): 기존 도메인 서비스(`apply_buy`/`apply_sell`)와 동일 공식 사용 확인. buy → `cash -= qty*price + fee + tax`, `quantity += qty`. sell → `cash += qty*price - fee - tax`, `quantity -= qty`. 정밀 검토에서 기존 흐름과 4개 항목 모두 일치 확인. - - 결정 (음수 정책): **cash_balance, asset_quantity 모두 음수 허용**. ledger_trades는 broker 체결의 사후 보고이므로 broker 정산 결과를 portfolio-hub가 그대로 수용한다. 기존 `apply_buy`/`apply_sell`의 ValueError 거부 로직(`cash_balance < total_cost`, `asset_quantity < quantity`)을 신 path에서는 사용하지 않는다. 음수 검증 제거한 회계 헬퍼를 ledger 흐름 전용으로 추출하거나 inline 구현. - - 영향: 레거시 `POST /portfolios/{id}/buy`·`/sell`은 기존 거부 정책 유지(이미 deprecated 분류, 본 변경 대상 아님). 두 흐름의 잔고 정책이 endpoint별로 다르다는 사실이 baseline 문서에 명문화 필요. - - 본문 반영: FR-6, # 10 DECIDE 섹션에서 항목 제거 -- OQ-4: `status="duplicate"` 응답의 portfolio 상태 — 첫 호출 시점 결과 영구 echo vs duplicate 호출 시점의 현재 권위 상태. - - 상태: Answered (2026-06-05) - - 결정: **(b) 현 시점 권위 상태**. duplicate 호출 시점에 portfolios 테이블 SELECT 1회로 cash_balance/asset_quantity를 조회해 응답에 포함. issue #8 본문이 명시한 요구("현재 권위 cash_balance / asset_quantity 를 동일하게 반환해 소비자가 항상 최신 상태로 갱신")를 직접 충족하며 trex-core의 drift 방지 효과가 duplicate 경로에서도 유지된다. - - 옵션 A(첫 호출 결과 echo) 미채택 사유: ledger_trades에 잔고 컬럼 추가가 강제됨(OQ-6 (iii) 강결합) + trex-core가 옛 상태로 스냅샷을 덮어쓰는 위험. - - idempotency 본질 영향: (통합 후) portfolio_ledger row는 정확히 1건 + portfolio 갱신은 1회 발생 → 부수 효과 멱등성은 유지. 응답 stationary echo만 약해짐(부차적). - - OQ-6과의 관계: 본 결정은 OQ-6의 (i)/(ii)/(iii) 어느 옵션과도 호환 — OQ-6 독립. - - 본문 반영: FR-3, # 7 S-2 측정 방식, # 10 DECIDE 섹션에서 항목 제거 -- OQ-5: ledger write가 portfolio mutation을 동반하는 옵션 B 채택 시, 기존 레거시 `POST /portfolios/{id}/buy`·`/sell` endpoint와 그 회계 흐름(`apply_buy`/`apply_sell` + `repo.save` + `portfolio_ledger` 기록)과의 관계. - - 상태: Answered (2026-06-05) - - 결정: **레거시 buy/sell endpoint는 deprecated 분류 → 추후 삭제 대상**. 신 path가 portfolio 회계의 단일 권위 endpoint가 된다. 본 Discovery에서는 deprecated 표기(FR-8)만 다루며, 실제 endpoint 코드 제거 + CLI 호출자 전환은 후속 정리 batch 또는 별도 Discovery에서 처리. - - 본문 반영: FR-8, # 4 변경, # 5 Out of Scope (실제 제거 분리) - -- OQ-6: `portfolio_ledger`와 `ledger_trades` 두 ledger 테이블의 미래 정책. - - 상태: Answered (2026-06-05) - - 결정: **옵션 (iv) — `portfolio_ledger`를 단일 회계 원장으로 살리고 `ledger_trades`의 idempotency/broker 식별 필드를 portfolio_ledger에 흡수 + `ledger_trades` 테이블 DROP**. portfolio_ledger가 본질적으로 회계 원장의 정통 형태(잔고 snapshot 4종 + strategy + note 기존 보유)이고, ledger_trades는 broker 체결 보고로서의 좁은 책임만 가짐 → portfolio_ledger를 회계 원장 단일 source of truth로 통합하는 게 응집도 우수. - - 부수 결정: 컬럼 타입 DOUBLE PRECISION → NUMERIC(20,8) 일관, `action` 컬럼 제거 후 `side` 단일화, 테이블명 `portfolio_ledger` 유지. 본 워크스페이스 ledger_trades 미생성이라 데이터 backfill 비용 0(prod는 별도 검토). - - 본문 반영: # 1 해석 격상, # 4 변경 항목, # 4 영향 범위 (마이그레이션 + 도메인 모델 + 코드 + 문서), FR-9~14 신설, # 5 In/Out Scope 갱신, # 10 DECIDE 섹션에서 항목 제거 - -- OQ-7 (신규, 2026-06-05 review-discovery 발견): 통합된 `portfolio_ledger`의 `order_id`/`execution_id` nullability + 레거시 write(deprecated `POST /portfolios/{id}/buy`·`/sell`, CLI)의 broker 식별자/멱등키 정책. - - 상태: Answered (2026-06-05) - - 결정: **(ii) `order_id`/`execution_id` NULL 허용 + partial UNIQUE `WHERE order_id IS NOT NULL`**. broker write(신 path)만 idempotency 적용(NULL 아닌 식별자), 레거시/수동 write는 NULL → append-only 유지(멱등 dedup 없음 — buy 두 번 = 거래 두 건). 멱등성 범위가 의미와 정확히 일치하고, NULL = "broker 식별자 없음"으로 데이터 정직성 확보. - - 근거: (1) 멱등성은 broker write에만 필요 — partial UNIQUE가 정확히 구현. (2) NULL-ness로 broker write vs 레거시 write 깔끔히 구분. (3) **본 codebase에 이미 동일 패턴 확립** — REQ-017/BAT-006의 `idx_auth_tokens_service_name_unique` partial unique index([migrations/versions/20260520_0022_service_token_unique_index_fix.py:37-39](../../migrations/versions/20260520_0022_service_token_unique_index_fix.py#L37), [app/repositories/postgres.py:191-193](../../app/repositories/postgres.py#L191)). (4) 마이그레이션 단순 — 레거시 row는 order_id/execution_id NULL, synthetic backfill 불요. (5) 신 path duplicate 감지는 `ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL DO NOTHING`로 전환(PostgreSQL partial index inference 지원). - - 본문 반영: FR-9 확정(NULL 허용 + partial UNIQUE), # 4 영향 범위 마이그레이션 backfill, FR-15 신설(ON CONFLICT 전환), # 8 체크리스트, B1 해소 - - 종료 조건: 충족 (2026-06-05) - -### 오픈 질문 처리 기준 -- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. -- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. -- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. -- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. - -# 7. 초기 성공 기준 -이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. - -- S-1: `POST /v1/portfolios/{id}/ledger/trades` 응답 schema에 cash_balance/asset_quantity가 포함되고 그 값이 portfolios 테이블 (또는 트랜잭션 갱신 후) 권위 상태와 일치한다. -- S-2: `status="new"`와 `status="duplicate"` 모두 portfolios 테이블의 권위 상태를 반환한다(OQ-4 결정 (b)). 두 호출 사이에 외부 mutation이 없다면 동일 값, 있다면 duplicate는 현재 권위 상태를 반환. 핵심은 portfolio_ledger row가 1건만 존재 + portfolio 갱신이 1회만 발생 (멱등 부수 효과). -- S-3: 기존 ledger 응답 필드(ledger_entry_id, portfolio_id, order_id, ...)와 인가/idempotency 동작이 회귀 테스트로 보존된다. -- S-4: ledger insert + portfolio update 트랜잭션 격리가 회귀 테스트 또는 staging 측정으로 검증된다. -- S-5 (OQ-6 (iv) + OQ-7 (ii)): 신규 alembic revision 적용 후 `\d portfolio_ledger`에 `ledger_entry_id`, `order_id`(NULL 허용), `execution_id`(NULL 허용), `side`, `executed_at` 컬럼 존재 + **partial UNIQUE index `(portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL`** 존재 + 수치 컬럼 타입 NUMERIC(20,8) 확인. `ledger_trades` 테이블 부재 확인. -- S-6 (OQ-6 (iv)): 도메인 모델에 `LedgerEntry` 단일 존재 + `LedgerTrade` 부재. 코드 grep으로 검증. -- S-7 (OQ-6 (iv)): 신 path가 `portfolio_ledger`에 INSERT (단일 트랜잭션) + portfolios UPDATE. ledger_trades INSERT 코드 부재. 회귀 테스트로 검증. -- S-8 (OQ-7 (ii)): broker write는 같은 (portfolio_id, order_id, execution_id) 재호출 시 duplicate dedup. 레거시/수동 write(order_id NULL)는 두 번 호출 시 portfolio_ledger row 2건 생성(append-only, dedup 안 됨). pytest 회귀로 검증. - -## 측정 방식 및 데이터 출처 -각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. - -- S-1 측정 방식: pytest TestClient `POST /v1/portfolios/{id}/ledger/trades` 응답 본문에 cash_balance_after/asset_quantity_after 필드 매치 + DB SELECT로 portfolio 갱신 확인 / 데이터 출처: 신규 pytest 케이스 + DB 회귀 -- S-2 측정 방식: pytest로 같은 (portfolio, order_id, execution_id)로 두 번 호출. 첫 호출: status="new" + 회계 적용 후 portfolio 값. 두 번째: status="duplicate" + 현재 portfolios 테이블 권위 값(외부 mutation 없으면 첫 호출 결과와 동일). DB에서 portfolio_ledger row 1건 + portfolio updated_at 1회 변경 확인 / 데이터 출처: 신규 pytest 케이스 + DB SELECT 회귀 -- S-3 측정 방식: 기존 `tests/test_v1_portfolios_ledger.py` 회귀 통과 (응답 신규 필드 + portfolio_ledger 전환 반영하도록 갱신) / 데이터 출처: pytest -- S-4 측정 방식: 동시 ledger write 시뮬레이션 또는 staging concurrent test / 데이터 출처: 신규 회귀 또는 REL staging -- S-5 측정 방식: alembic upgrade 후 `\d portfolio_ledger` + `information_schema` 조회로 신규 컬럼/제약/타입 확인 + `ledger_trades` 테이블 부재 확인 / 데이터 출처: DB schema 조회 -- S-6 측정 방식: `grep -rn "class LedgerTrade\b\|LedgerTrade(" app/` 결과 0건 + `LedgerEntry` 단일 정의 확인 / 데이터 출처: 코드 grep -- S-7 측정 방식: 신 path 회귀 테스트에서 portfolio_ledger INSERT mock/DB 호출 확인 + `grep -rn "ledger_trades" app/` 0건 / 데이터 출처: pytest + 코드 grep - -# 8. REQ로 넘기기 전 확인 체크 -REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. - -- [x] 범위(In Scope/Out of Scope) 확정 - - 기준: OQ-1~7 모두 Answered (2026-06-05). FR-1~15 확정 → 범위 확정. -- [x] 성공 지표의 측정 방식/데이터 출처 확정 - - 기준: S-1~S-8 모두 pytest/DB schema 조회/코드 grep으로 측정 가능 형태 확정 -- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 - - 기준: 본 Discovery에는 High 리스크 없음 (R-1/R-5/R-6/R-8 Medium, R-4/R-7 Low; R-2/R-3 Dropped). Medium 리스크는 대부분 prod 마이그레이션/release 단계 검토로 위임됨. -- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - - 기준: OQ-1~7 모두 Answered. Open 항목 0개. -- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 - - 기준: confirm-discovery(2026-06-05)로 CONFIRMED_BY = "Justin Ko" 지정. CONFIRM_REL_SEPARATION 동의 완료. - -# 9. 파일 처리 결과 -이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. - -- 처리 결과: 생성 -- 생성 경로: docs/discovery/dcy-006_20260605_ledger-trade-write-portfolio-state-return.md -- 참조 문서: - - [GitHub issue #8](https://github.com/fourblocks2020/portfolio-hub/issues/8) — trex-core daemon 요구 + 응답 명세 회신 comment 2026-06-05 - - [docs/srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md](../srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md) (기존 path 이전 REQ) - - [docs/srs/Migration/req-033_ledger-trades-asset-id-to-portfolio-id-rename.md](../srs/Migration/req-033_ledger-trades-asset-id-to-portfolio-id-rename.md) (dcy-005 컬럼명 정합) - - [app/api/v1_portfolios.py:514-588](../../app/api/v1_portfolios.py#L514-L588) — `record_ledger_trade_v1` handler - - [app/repositories/postgres.py:263-282](../../app/repositories/postgres.py#L263-L282) — `portfolio_ledger` ensure_schema - - [app/repositories/postgres.py:1175-1230](../../app/repositories/postgres.py#L1175-L1230) — `save()` (레거시 회계 흐름) - - [app/repositories/postgres.py:1267-1315](../../app/repositories/postgres.py#L1267-L1315) — `insert_ledger_trade` (신 path audit-only) - - [app/domain/services.py:87-205](../../app/domain/services.py#L87-L205) — `apply_buy`/`apply_sell` 회계 공식 - - [app/domain/models.py:126,175](../../app/domain/models.py#L126) — `LedgerEntry`/`LedgerTrade` dataclass (통합 대상) - - [app/schemas/v1.py](../../app/schemas/v1.py) — `LedgerTradeResponse` 정의 - - [migrations/versions/20260502_0014_v1_api_schema.py](../../migrations/versions/20260502_0014_v1_api_schema.py) — `ledger_trades` 테이블 생성 마이그레이션 (본 Discovery에서 DROP 대상) - -# 10. 사용자 결정 필요 항목 요약 -사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. - -## DECIDE -- (none — 모든 DECIDE 항목 Answered) - -## CONFIRM -- (none — CONFIRM_REL_SEPARATION은 사용자 confirm-discovery(2026-06-05)로 동의 확정. 가정 A-4에 반영됨.) - -## DATA -- (none — 본 Discovery는 본 repo 내부 정보 + issue #8 본문만으로 자립) - -# 11. Discovery Freeze -REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. - -## 섹션 참조 -- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) -- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) -- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) -- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) -- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) - -## Freeze 플래그 -- Handoff Decision: 승인 -- Handoff Rationale: OQ-1~7 모두 Answered + CONFIRM_REL_SEPARATION 동의 완료 (2026-06-05). 범위 격상(신 path 회계 책임 단일화 + portfolio_ledger/ledger_trades 통합 + 응답 schema 확장 + 레거시 deprecated). review-discovery cleanup 완료. OQ-7은 (ii) NULL 허용 + partial UNIQUE로 확정(REQ-017 패턴 재사용). FR-1~15 + S-1~8 확정. REQ Drafting 진입 준비 완료. -- Ready for REQ Drafting: true -- Confirmed By: Justin -- Confirmed At (KST): 2026-06-05 21:00 - -# 12. 작성 가이드 -- 이 파일은 dcy-006 작성 완료본이다. confirm-discovery 진입 전까지 잔여 OQ(OQ-7) 결정 + 본문 반영 + CONFIRM_REL_SEPARATION 동의 필요. -- 본 Discovery는 격상되어 (1) 신 path 회계 책임 단일화 + (2) portfolio_ledger/ledger_trades 테이블 통합(ledger_trades DROP) + (3) 응답 schema 확장 + (4) 레거시 buy/sell deprecated를 함께 다룬다. 단순 응답 확장이 아니라 마이그레이션 + 도메인 모델 통합을 포함한 대규모 변경이다. -- REQ 분해 시: 응답/회계 동작(FR-1~8)과 테이블 통합 마이그레이션(FR-9~14)을 별도 REQ로 분리할지 단일 REQ 묶음으로 갈지 draft-req 시점에 결정. 마이그레이션 동반이라 표준 batch 권장(minor-change fast path 부적합). diff --git a/docs/discovery/dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md b/docs/discovery/dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md deleted file mode 100644 index 316af6c..0000000 --- a/docs/discovery/dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md +++ /dev/null @@ -1,210 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api -- 이슈명: 일일 거래내역 + 실현 PnL 조회 API (운영 리포트/프론트 데이터 소스) -- 작성 시각(KST): 2026-06-16 14:05 KST -- 마지막 갱신 시각(KST): 2026-06-18 (draft-req) -- 대체됨: 없음 -- 후속 Discovery 참조: 없음 -- 생성된 REQ 참조: - - REQ-039: GET /v1/portfolios/{id}/trades 체결 내역 조회 엔드포인트 신설 (Interface, Proposed) → [req-039](../srs/Interface/req-039_trade-history-query-endpoint.md) - - REQ-040: GET /v1/portfolios/{id}/pnl/realized 실현 PnL 집계 엔드포인트 신설 (Interface, Proposed) → [req-040](../srs/Interface/req-040_realized-pnl-query-endpoint.md) - - REQ-041: WAC 기반 cost_basis 산출 및 실현 PnL 계산 로직 (Integration, Proposed) → [req-041](../srs/Integration/req-041_wac-cost-basis-pnl-calculation.md) - - REQ-042: 체결 내역/실현 PnL API 테스트 커버리지 (Testing, Proposed) → [req-042](../srs/Testing/req-042_trade-pnl-api-test-coverage.md) - -# 1. 계획 상태 요약 -현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. - -- 연계 문서 현황: - - 관련 점수 파일: 없음 - - 관련 TODO/메모: GitHub issue #11 (https://github.com/fourblocks2020/portfolio-hub/issues/11) -- 해석: - - GitHub issue #11(2026-06-15, P1)에서 제기 — portfolio-hub가 체결 기록의 시스템 오브 레코드임에도 조회 API가 없어 운영 리포트 작성 시 trex_core ledger_outbox를 수기 계산해야 하는 상황이다. - - portfolio_ledger(TimescaleDB hypertable)에 side·quantity·price·fee_amount·tax_amount·executed_at이 이미 적재되어 있으므로 조회 엔드포인트와 PnL 집계 로직 추가로 해결 가능하다. 단, 실현 PnL 산출 공식(평균취득가 vs FIFO)과 cost_basis 저장 방식은 REQ 초안 이전에 확정이 필요하다. - -# 2. 요구사항 판정 결과 -이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. - -- 판정: 신규 -- 근거 - - dcy-006(ledger trade write 회계 책임 단일화)은 체결 *쓰기* 경로(POST /v1/portfolios/{id}/ledger/trades) 문제 정의이고, 이번은 체결 *조회/집계*(GET) 경로로 문제 정의가 완전히 다르다. - - 기존 dcy-001~006 모두 confirmed 상태이며 PnL 조회 관련 FR/NFR을 포함한 Discovery가 없다. - -# 3. 문제점의 요약 -문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. - -- 현재 상태 - - portfolio_ledger에는 체결 기록(side, quantity, price, fee_amount, tax_amount, executed_at 등)이 적재되어 있으나, 외부에서 조회할 수 있는 GET 엔드포인트가 존재하지 않는다. 현재 `list_recent_trades(portfolio_id, limit=5)` 내부 메서드만 있으며 날짜 필터 없이 최근 5건만 반환한다. - - 일일 실현 PnL을 확인하려면 trex_core의 ledger_outbox를 직접 조회해 수기 계산해야 하며, portfolio-hub가 시스템 오브 레코드임에도 운영 가시성이 없다. -- 기대 상태 - - 운영자 및 프론트엔드가 portfolio-hub API를 통해 일자별 체결 내역(매수/매도, 수량, 체결가, 수수료, 세금)을 직접 조회할 수 있다. - - 기간·종목별 실현 PnL(수수료·세금 반영)을 portfolio-hub API 단일 호출로 조회할 수 있어 trex_core 의존 수기 계산이 불필요해진다. -- 이해관계자 - - 운영자 / 리포트 담당자: 일일 PnL 보고서 자동화 목적으로 API 사용 - - 프론트엔드 / 대시보드: PnL 데이터 소스로 portfolio-hub API 호출 - - trex-core daemon: 현재 ledger_outbox를 PnL 산출 근거로 사용 중 — portfolio-hub 조회 API 도입 후 역할 분담 재정리 대상 -- 제약사항 - - portfolio_ledger는 TimescaleDB hypertable로, 파티션 키(recorded_at) 기반 범위 쿼리는 효율적이나 cost_basis(평균취득가)는 테이블에 직접 저장되어 있지 않다. - - 미실현 PnL 계산에는 현재가(kis-data-hub 연동)가 필요하며 이번 Discovery 범위 밖이다. - -## 주요 사용자 시나리오 -사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. - -- 운영자가 2026-06-15 체결된 포트폴리오 거래 내역(매도 2건, 매수 1건)을 조회하고 수수료·세금 포함 순 실현 손익을 확인한다. -- 프론트엔드 대시보드가 특정 포트폴리오의 월간 실현 PnL 합계를 API 한 번 호출로 렌더링한다. -- 운영 리포트 스크립트가 매일 오전 portfolio-hub API를 호출해 전일 체결 내역 CSV를 자동 생성하고, trex_core ledger_outbox 수기 조회를 대체한다. - -# 4. 이번에 정의할 변경 -이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. - -- 일자별 체결 내역 조회 엔드포인트 신설 — `GET /v1/portfolios/{portfolio_id}/trades` (날짜·방향 필터 지원) -- 기간별 실현 PnL 집계 엔드포인트 신설 — `GET /v1/portfolios/{portfolio_id}/pnl/realized` (수수료·세금 반영, 종목 단위 집계) -- portfolio_ledger 기반 PnL 산출 로직 구현 — 평균취득가 산출 방식 또는 FIFO cost_basis 추적 방식 선택 및 repository/service 계층 추가 - -## 영향 범위 -변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. - -- 코드 영향: - - `app/api/v1_portfolios.py` — GET 엔드포인트 2개 추가 - - `app/repositories/postgres.py` — `list_trades_by_date`, `aggregate_realized_pnl` 메서드 추가 - - `app/schemas/v1.py` — 응답 스키마(TradeHistoryResponse, RealizedPnlResponse) 추가 - - `app/services/portfolio_service.py` — PnL 산출 로직(cost_basis 계산) 추가 가능성 -- 문서 영향: - - `docs/discovery/index.md` — 이번 Discovery 행 추가 - - REQ 문서(draft-req 이후 생성) -- 운영 영향: - - trex-core와 운영 리포트의 PnL 산출 역할 분담 재정리 필요(별도 조율, 이번 배포 범위 밖) - - DB 마이그레이션 불필요 — 기존 portfolio_ledger 스키마로 조회 가능 - -# 5. 요구사항 목록 -실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. - -## 기능 요구사항 -- FR-1: 시스템은 `GET /v1/portfolios/{portfolio_id}/trades` 엔드포인트를 제공해야 하며, `date`(YYYY-MM-DD) 또는 `from`/`to` 날짜 범위 파라미터로 portfolio_ledger의 체결 기록(side, quantity, price, fee_amount, tax_amount, executed_at, order_id, execution_id)을 필터링해 반환해야 한다. -- FR-2: 시스템은 `GET /v1/portfolios/{portfolio_id}/pnl/realized` 엔드포인트를 제공해야 하며, 지정 기간 내 매도(side='sell') 체결에 대해 수수료·세금을 반영한 실현 PnL 합계 및 건별 내역을 반환해야 한다. -- FR-3: 실현 PnL 산출은 portfolio_ledger의 buy/sell 기록에서 평균취득가(weighted average cost) 방식으로 cost_basis를 계산해야 하며, 매도 실현 손익 = (매도가 − 평균취득가) × 수량 − 수수료 − 세금으로 정의한다. -- FR-4: 두 엔드포인트 모두 기존 인증 체계(read_token 이상 권한)로 접근 가능해야 한다. - -## 비기능 요구사항 -- NFR-1: 조회 응답 시간은 단일 포트폴리오 기준 30일 범위 쿼리에서 p95 500ms 이하여야 한다. (portfolio_ledger TimescaleDB idx_portfolio_ledger_portfolio_executed 인덱스 활용) -- NFR-2: 엔드포인트는 OpenAPI 스키마에 문서화되어야 하며, 날짜 파라미터 누락 시 명확한 422 응답을 반환해야 한다. -- NFR-3: PnL 산출 로직은 단위 테스트로 검증 가능해야 한다 (repository가 아닌 service/domain 계층에 격리). - -## 범위 경계 -이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. - -- In Scope - - `GET /v1/portfolios/{portfolio_id}/trades` — 체결 내역 조회 (날짜 필터) - - `GET /v1/portfolios/{portfolio_id}/pnl/realized` — 실현 PnL 집계 (기간·종목 필터) - - portfolio_ledger 기반 weighted average cost PnL 산출 로직 - - read_token 인증 통합 -- Out of Scope - - 미실현 PnL (현재가 연동 — kis-data-hub 연동 필요, 별도 Discovery로 분리) - - trex_core ledger_outbox 데이터 마이그레이션 또는 연동 - - 포트폴리오 전체 목록 PnL 일괄 조회 (단일 포트폴리오 단위만 이번 범위) - - 전체 portfolio 집계 PnL 뷰: **Out of Scope 확정(Answered, OQ-2)** — 이번 반복 제외, 필요 시 후속 Discovery - -# 6. 리스크/가정 목록 -불확실성과 전제 조건을 분리해서 적는다. - -## 리스크 -- R-1 (High): 매도 시점의 평균취득가 산출을 위해 portfolio_ledger의 전체 buy 이력을 집계해야 하는데, 포트폴리오 생성 초기부터의 누적 기록이 완전하지 않을 경우 PnL 계산 결과가 부정확할 수 있다. - - 완화 방안: 조회 시 cost_basis 산출 범위(집계 시작 시점)를 명시하고, 이력이 불완전한 경우 경고 필드를 응답에 포함한다. REQ 단계에서 데이터 완전성 확인 조건을 Acceptance Criteria에 명시한다. -- R-2 (Medium): TimescaleDB hypertable 기반 집계 쿼리(GROUP BY + SUM across partitions)가 대용량 이력에서 응답 시간 NFR-1을 초과할 수 있다. - - 완화 방안: 기존 인덱스(idx_portfolio_ledger_portfolio_executed) 활용 + EXPLAIN ANALYZE 검증. 필요 시 materialized view 또는 배치 집계 고려(이번 범위 밖, R-2가 현실화되면 후속 Discovery로 분리). -- R-3 (Low): trex-core와 portfolio-hub의 PnL 산출 역할 분담이 정리되지 않으면 운영 혼선이 발생할 수 있다. - - 완화 방안: 이번 Discovery는 portfolio-hub 단독 조회 API 제공에 집중하고, trex-core 역할 조율은 별도 협의 아이템으로 분리(이번 REQ 범위 밖). - -## 가정 -- A-1: portfolio_ledger의 buy/sell 체결 기록은 완전하고 신뢰할 수 있으며, portfolio 생성 이후 모든 체결이 portfolio_ledger에 기록되었다고 가정한다. -- A-2: 실현 PnL 산출 방식은 weighted average cost(가중평균 취득단가)를 사용한다고 가정한다. FIFO 방식이 필요하다면 OQ-1 확정 후 FR-3을 수정한다. -- A-3: 조회 엔드포인트는 기존 read_token 이상 권한으로 접근 가능하며, 별도 권한 체계 추가는 불필요하다. - -## 오픈 질문 -- OQ-1: 실현 PnL 산출 방식 — weighted average cost와 FIFO 중 어느 방식을 사용할 것인가? 현재 FR-3은 weighted average cost를 기본안으로 제시하나, 세무·회계 요건에 따라 FIFO가 필요할 수 있다. - - 상태: Answered - - 처리 방안: [결정] **weighted average cost(WAC)** — 사장님 2026-06-18. FR-3 기본안이 이미 WAC라 본문 변경 없음(A-2 가정 확정). - - 종료 조건: FR-3에 산출 방식이 확정 문장으로 반영되면 Answered로 처리. -- OQ-2: 전체 포트폴리오 집계 뷰(여러 portfolio_id에 걸친 PnL 합산) 필요 여부 — 이번 범위 Out of Scope 제안이지만, 운영 리포트 요건에 따라 P1 필요성이 있을 수 있다. - - 상태: Answered - - 처리 방안: [결정] 이번 반복 **Out of Scope 확정**. 운영 리포트가 전체 집계를 요구하면 후속 Discovery로 분리. - - 종료 조건: Out of Scope 확정 또는 In Scope 변경과 함께 FR 추가 시 Answered/Deferred 처리. - -### 오픈 질문 처리 기준 -- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. -- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. -- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. -- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. -- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다. -- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다. -- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다. - -# 7. 초기 성공 기준 -이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. - -- S-1: `GET /v1/portfolios/{id}/trades?date=2026-06-15` 호출 시 해당일 체결 기록(side, quantity, price, fee_amount, tax_amount, executed_at) 전체가 HTTP 200으로 반환된다. -- S-2: `GET /v1/portfolios/{id}/pnl/realized?from=2026-06-01&to=2026-06-15` 호출 시 수수료·세금 반영 실현 PnL 합계와 건별 내역이 HTTP 200으로 반환된다. -- S-3: 운영 담당자가 trex_core ledger_outbox를 수기 조회하지 않고 portfolio-hub API 단독으로 일일 PnL 리포트를 생성할 수 있다. - -## 측정 방식 및 데이터 출처 -각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. - -- S-1 측정 방식: 통합 테스트 — 알려진 체결 데이터 삽입 후 GET 호출, 응답 항목 수 및 필드 값 일치 확인 / 데이터 출처: portfolio_ledger 삽입 fixture -- S-2 측정 방식: 단위 테스트(PnL 산출 로직) + 통합 테스트(엔드포인트 응답) — 매수·매도 시나리오별 PnL 기댓값 검증 / 데이터 출처: portfolio_ledger 삽입 fixture, 수기 계산 대조값 -- S-3 측정 방식: 운영 리포트 스크립트 또는 운영자 확인 — trex_core 수기 조회 절차 제거 여부 / 데이터 출처: 운영 리포트 프로세스 문서 또는 운영자 확인 - -# 8. REQ로 넘기기 전 확인 체크 -REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. - -- [ ] 범위(In Scope/Out of Scope) 확정 - - 기준: 전체 portfolio 집계 뷰(OQ-2)와 미실현 PnL이 명시적으로 Out of Scope로 확정되거나 In Scope로 변경됨 -- [ ] 성공 지표의 측정 방식/데이터 출처 확정 - - 기준: S-1·S-2는 통합 테스트로 측정 가능한 fixture 데이터 설계가 완료됨 -- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정 - - 기준: R-1(이력 완전성) 처리 방식 — 경고 필드 포함 여부 — 이 REQ Acceptance Criteria에 반영됨 -- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - - 기준: OQ 목록에 `Open` 상태 항목이 0개이거나, 잔여 항목 전부 `Deferred` 처리 완료 -- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정 - - 기준: Justin Ko가 이 Discovery 내용을 검토하고 confirm-discovery로 승인 - -# 9. 파일 처리 결과 -이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. - -- 처리 결과: 생성 -- 생성 경로: docs/discovery/dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md -- 참조 문서: - - GitHub issue #11: https://github.com/fourblocks2020/portfolio-hub/issues/11 - - docs/discovery/dcy-006_20260605_ledger-trade-write-portfolio-state-return.md (연관 선행 Discovery — write 경로) - - app/repositories/postgres.py:1317 (list_recent_trades 현행 구현) - -# 10. 사용자 결정 필요 항목 요약 -사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. - -## DECIDE -- PnL 산출 방식 → **결정: weighted average cost(WAC)** (사장님 2026-06-18, FR-3 확정) (OQ-1) -- 전체 portfolio 집계 PnL 뷰 → **결정: Out of Scope** (이번 반복 제외, 필요 시 후속 Discovery) (OQ-2) - -## CONFIRM -- {{CONFIRM_AUTH_LEVEL}}: 조회 엔드포인트 접근 권한이 read_token으로 충분한지, 또는 별도 scope 필요한지 확인. -- {{CONFIRM_HISTORY_COMPLETENESS}}: portfolio_ledger 이력이 포트폴리오 생성 시점부터 완전하게 기록되어 있는지 확인. R-1 완화 방안 적용 범위에 영향. - -## DATA -- {{DATA_COST_BASIS_SAMPLE}}: 실제 운영 portfolio(005930.KS)의 buy/sell 이력 샘플 — PnL 기댓값 수기 계산 대조에 사용. - -# 11. Discovery Freeze - -## 섹션 참조 -- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) -- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) -- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) -- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) -- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) - -## Freeze 플래그 -- Handoff Decision: 확정 (confirm-discovery, 2026-06-18) -- Handoff Rationale: OQ-1=WAC(사장님 결정), OQ-2=Out of Scope. FR-3 기본안(WAC) 그대로라 본문 변경 없음. M1 fast-track으로 즉시 REQ 초안 작성 진행. R-1(이력 완전성)은 REQ AC에서 경고 필드로 완화. -- Ready for REQ Drafting: true -- Confirmed By: 사장님 (Justin Ko) -- Confirmed At (KST): 2026-06-18 diff --git a/docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md b/docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md deleted file mode 100644 index 28cdcf2..0000000 --- a/docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md +++ /dev/null @@ -1,161 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-008_20260625_current-holding-wac-cost-basis-read -- 이슈명: 현재 보유 종목별 평단(WAC) 조회 API — trex-core 중장기 cold-start 슬롯 평단 seed 의존 -- 작성 시각(KST): 2026-06-25 (KST) -- 마지막 갱신 시각(KST): 2026-06-25 (KST, confirm-discovery — 사장님 승인, OQ-1/OQ-2 Answered) -- 대체됨: 없음 -- 후속 Discovery 참조: 없음 -- 교차 서비스 발원(driver): trex-core `dcy-034_20260625_slot-account-blocking-reconcile-sync-coldstart` FR-7 — trex-core가 cold-start 시 슬롯 평단을 seed하려면 portfolio-hub의 현재 보유분 평단 read가 필요하다. 본 Discovery는 그 의존을 portfolio-hub측 변경 단위로 정식화한다. -- 생성된 REQ 참조: REQ-043 (GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단 read, Interface) → [req-043](../srs/Interface/req-043_holdings-cost-basis-read-endpoint.md) (Implemented — BAT-018 PR #14 origin/main 머지, 팀장 QC PASS, 배포 별도) - -# 1. 계획 상태 요약 -- 연계 문서 현황: - - 관련 점수 파일: 없음 - - 관련 TODO/메모: 교차 서비스 의존 — trex-core dcy-034(M3 중장기 cold-start) FR-7. GitHub 보드 #7 dcy-034 항목. - - 직계 선행: dcy-007(일일 거래내역 + 실현 PnL 조회 API) — WAC cost basis 산출 로직(REQ-041)을 이미 도입한 직계 형제. -- 해석: - - trex-core 중장기 인스턴스가 실계좌에 이미 보유 중인 종목 위에서 cold-start 할 때, 슬롯에 보유분을 onboarding하며 **슬롯 평단(cost basis)을 seed**해야 한다. 그 평단의 단일 정본 출처는 원장(ledger)을 보유한 portfolio-hub다. - - 그러나 portfolio-hub는 WAC를 내부 계산만 하고(미매도 보유분의 현재 평단을 주는 read가 없음), 외부 노출은 매도 시점 `cost_basis_per_unit`(dcy-007/REQ-040)뿐이다. 즉 "현재 보유분의 평단"을 조회할 표면이 비어 있다. - - 본 Discovery는 그 read 표면을 portfolio-hub 자체 변경(신규 조회 엔드포인트 + 현재 보유 WAC 집계)으로 정의한다. - -# 2. 요구사항 판정 결과 -- 판정: 신규 -- 근거 - - dcy-007은 **매도 기준 실현 PnL 집계**(GET /pnl/realized, 매도행 cost_basis_per_unit)다. 이번은 **미매도 현재 보유분의 평단 조회**로, 데이터 대상(미청산 포지션)과 용도(외부 seed)가 다르다. - - 기존 dcy-001~007 중 "현재 보유 평단 read"를 정의한 Discovery가 없다. WAC 산출 로직(REQ-041)은 재사용 가능하나 이를 노출하는 read 계약은 신규다. - -# 3. 문제점의 요약 -- 현재 상태 - - `app/domain/pnl.py`의 `calculate_realized_pnl`이 매수 누적으로 `running_wac`를 계산하지만, 이 값은 **매도 이벤트 행(`cost_basis_per_unit`)으로만** 응답에 노출된다(`GET /v1/portfolios/{id}/pnl/realized`, REQ-040). 매도가 0건이면 보유분 평단을 알 길이 없다. - - portfolio_ledger(TimescaleDB)에 매수/매도 체결이 적재돼 있어 보유 수량·WAC는 산출 가능하나, **"현재 보유 종목별 (수량, 평단)"을 반환하는 GET 엔드포인트가 없다.** - - 결과: trex-core cold-start가 슬롯 평단을 seed할 외부 read가 없어, 브로커 평균가(dcy-032에서 0/None 케이스 확인) 등 불안정한 폴백에 의존해야 한다. -- 기대 상태 - - 포트폴리오의 **현재 보유 종목별 보유수량 + 현재 평단(WAC, cost_basis_per_unit)** 을 반환하는 read 엔드포인트가 존재한다. - - WAC 산출은 dcy-007/REQ-041의 검증된 `running_wac` 로직을 재사용하고, 원장 이력이 불완전하면 dcy-007과 동일하게 `data_completeness_warning`으로 표면화한다. - - 인증/역할은 기존 portfolio read API와 동일 정책이라 daemon 호출자(trex-core)가 소비할 수 있다. -- 이해관계자 - - 사장님 — 교차 서비스 계약·범위 승인자 - - 팀장(Claude) — Discovery/게이트 관리, 교차 의존 추적자 - - portfolio-hub-dev — delivery 담당 - - 소비자: trex-core(중장기 cold-start seed), 운영자(보유 평단 가시성) -- 제약사항 - - portfolio-hub는 **시세(현재가)를 소유하지 않는다**(그건 kis-data-hub). 따라서 본 read는 **평단/취득원가(cost basis)만** 다루고 미실현 손익(현재가 결합)은 범위 밖이다. - - WAC 산출 정본은 원장(portfolio_ledger)이며, 원장 이력 불완전 시 추정 한계가 dcy-007과 동일하게 존재한다. - - 응답 스키마/인증은 기존 v1 portfolio read 계약과 일관해야 한다(프론트/daemon 회귀 방지). - -## 주요 사용자 시나리오 -- trex-core(daemon): 중장기 인스턴스 cold-start에서 보유 종목 onboarding 시, 이 엔드포인트로 종목별 현재 평단을 읽어 슬롯 cost basis를 seed한다. -- 운영자: 매도 전이라도 특정 포트폴리오의 현재 보유 평단/취득원가를 조회해 손익 기준선을 확인한다. - -# 4. 이번에 정의할 변경 -- 변경 1 — 현재 보유 평단 조회 엔드포인트 신설: 포트폴리오의 미청산 보유 종목별 (symbol, 보유수량, WAC, 보유 cost basis 합)을 반환하는 GET 엔드포인트. -- 변경 2 — 현재 보유 WAC 집계 로직: dcy-007/REQ-041의 `running_wac`(매수 누적 WAC, 매도 시 수량 차감) 로직을 재사용해 "매도 시점 PnL"이 아니라 "현재 잔여 보유분의 평단"을 산출. 원장 불완전 시 `data_completeness_warning`. - -## 영향 범위 -- 코드 영향: - - `app/api/v1_portfolios.py` — 신규 GET 핸들러(기존 `/pnl/realized` 핸들러 :710 인증 패턴 재사용). - - `app/domain/pnl.py` — 현재 보유 WAC 산출 함수(기존 `calculate_realized_pnl` running_wac 로직 재사용/추출). - - `app/schemas/v1.py` — 응답 스키마 신설(RealizedPnl* 인근 패턴). - - `app/repositories/postgres.py` — 보유 집계 조회(필요 시). -- 문서 영향: `docs/discovery/index.md` 신규 행, srs/index.md(REQ 생성 시). -- 운영 영향: 신규 read 엔드포인트 1개 추가(쓰기·마이그레이션 없음). 인증 정책 변화 없음. - -# 5. 요구사항 목록 -## 기능 요구사항 -- FR-1: 포트폴리오의 **현재 보유(미청산) 종목별** `symbol`, `quantity`, `cost_basis_per_unit`(WAC)을 반환하는 GET 엔드포인트를 제공한다. -- FR-2: WAC는 매수 누적 가중평균(매도 시 수량 차감, 단가 불변)으로 산출한다(dcy-007/REQ-041 `running_wac` 재사용). 매수만 한 보유분은 WAC=가중평균 매수가. -- FR-3: 원장 이력이 불완전하면 `data_completeness_warning`을 응답에 포함한다(dcy-007 패턴 일관). -- FR-4: 인증/역할은 기존 portfolio read API(REQ-040/041)와 동일 정책으로, daemon scope 호출자가 사용 가능해야 한다. - -## 비기능 요구사항 -- NFR-1: 응답 스키마·인증은 기존 v1 portfolio read 계약과 일관(프론트/daemon 회귀 0). -- NFR-2: 본 엔드포인트는 read-only, 쓰기/스키마 마이그레이션 없음. -- NFR-3: 시세 의존 없음 — 평단/취득원가만(미실현 손익 비포함). - -## 범위 경계 -- In Scope - - 현재 보유 종목별 (수량, WAC, cost basis 합) read 엔드포인트 + 산출 로직 + 테스트 + data_completeness_warning. -- Out of Scope - - 미실현 손익(현재가 결합) — portfolio-hub가 시세 비소유. - - trex-core 슬롯 seed 로직 자체(소비측 — trex-core dcy-034). - - 원장 소급 보정(데이터 정합화 별건). - -# 6. 리스크/가정 목록 -## 리스크 -- R-1 (Low): 현재 보유 WAC 산출이 dcy-007 매도 기준 계산과 미세하게 갈라질 수 있다(잔여 수량 0 처리 등). - - 완화: `running_wac` 로직을 단일 출처로 추출/재사용하고, 매수만/부분매도 후 시나리오 테스트로 dcy-007과 정합 확인. -- R-2 (Low): 응답 스키마가 daemon/프론트 양 소비자 요구와 어긋날 수 있다. - - 완화: 기존 RealizedPnl* 스키마 패턴 답습 + trex-core 소비 형태(symbol→평단 맵)를 design에서 확정. - -## 가정 -- A-1: portfolio_ledger에 cold-start 대상 종목의 매수 이력이 적재돼 있어 WAC 산출이 가능하다(없으면 data_completeness_warning). -- A-2: trex-core는 포트폴리오 단위(portfolio_id)로 평단을 소비한다(슬롯↔portfolio_id 1:1, trex-core dcy-020). -- A-3: dcy-007/REQ-041의 WAC 로직은 현재 보유 평단 산출에 그대로 재사용 가능하다. - -## 오픈 질문 (전부 Answered — confirm-discovery 사장님 승인 2026-06-25) -- OQ-1 (Answered): 집계 단위 → **포트폴리오별** `GET /v1/portfolios/{id}/holdings/cost-basis`. trex-core 슬롯↔portfolio_id 1:1 + 기존 `/v1/portfolios/{id}/` 패턴 일관. (REQ-043 반영.) -- OQ-2 (Answered): 응답 스키마 → 종목별 (symbol, current_quantity, cost_basis_per_unit=WAC, total_cost_basis, cost_basis_from, data_completeness_warning). 보유 cost basis 합계·최초 매수시각 포함. (REQ-043 반영.) -- OQ-3 (Answered, 교차 서비스 — REQ-043 draft-req 제기): service token 소유권 — cold-start가 `managed_by_daemon_id=null` 미배정 포트폴리오 평단을 읽으면 403 blocker 우려. **해소: trex-core cold-start는 `snapshot_scheduler.run_once`에서 whoami→get_portfolios(daemon_id)로 이미 자기 daemon에 배정된 포트폴리오만 가져오므로, 그 포트폴리오들은 `managed_by_daemon_id==daemon`이라 소유권 정책(REQ-029 선례) 성립. null 케이스는 cold-start 경로에 없음(팀장 2026-06-25).** REQ-043은 daemon scope 소유권 정책 유지. - -### 오픈 질문 처리 기준 -- `Open`: 다음 단계 진행에 영향이 있는 미결. 유지. -- `Answered`: 확정. 본문/REQ 반영 후 정리. -- `Deferred`: 다음 단계로 이월(대상·이유 명시). -- `Dropped`: 무효(사유 명시). - -# 7. 초기 성공 기준 -- S-1: 매수만 한 보유분에 대해 엔드포인트가 WAC=가중평균 매수가, 보유수량을 정확히 반환한다. -- S-2: 부분 매도 후 잔여 보유분의 WAC가 매도로 변하지 않고(수량만 감소) 정확히 반환된다. -- S-3: 원장 이력 불완전 시 `data_completeness_warning`이 응답에 포함된다. -- S-4: 인증/역할이 기존 portfolio read와 동일하게 적용된다(무권한 거부, daemon scope 허용). - -## 측정 방식 및 데이터 출처 -- S-1/S-2: 매수·부분매도 픽스처 단위/통합 테스트로 WAC·수량 검증 / 신규 테스트 + portfolio_ledger. -- S-3: 불완전 이력 주입 테스트로 warning 단언 / 응답 페이로드. -- S-4: 인증 테스트(무헤더/무권한/daemon scope) / 기존 auth 테스트 패턴. - -# 8. REQ로 넘기기 전 확인 체크 -- [x] 범위(In/Out) 확정 — 미실현 손익 제외·평단만 제공 합의(사장님 2026-06-25) -- [x] 성공 지표 측정 방식 확정 — S-1~S-4 이견 없음 -- [x] High 리스크 — 현재 없음(R-1/R-2 Low) -- [x] 모든 OQ `Open` 없음 — OQ-1/OQ-2/OQ-3 전부 Answered -- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 — 팀장(Claude) - -# 9. 파일 처리 결과 -- 처리 결과: 생성 -- 생성 경로: docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md -- 참조 문서: - - docs/discovery/dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md (WAC cost basis 산출 직계) - - trex-core docs/discovery/dcy-034_...slot-account-blocking-reconcile-sync-coldstart.md (교차 서비스 발원, FR-7) - - app/domain/pnl.py, app/api/v1_portfolios.py(:710), app/schemas/v1.py (현재 구현 근거) - -# 10. 사용자 결정 필요 항목 요약 -## DECIDE (해소됨 — 사장님 2026-06-25) -- (확정) OQ-1 집계 단위 → 포트폴리오별(`/v1/portfolios/{id}/holdings/cost-basis`). -## CONFIRM (확정) -- 본 read 엔드포인트를 trex-core dcy-034 FR-7의 portfolio-hub측 의존 단위로 채택 — 사장님 2026-06-25. -- 미실현 손익 제외(평단/취득원가만) 범위 — 확정. -## DATA -- 없음(원장 이미 적재, WAC 로직 기존 보유). - -# 11. Discovery Freeze -## 섹션 참조 -- 범위: [# 5 > 범위 경계](#5-요구사항-목록) -- 리스크/오픈 질문: [# 6](#6-리스크가정-목록) -- 성공 기준: [# 7](#7-초기-성공-기준) -- 인계 전 확인: [# 8](#8-req로-넘기기-전-확인-체크) -- 사용자 결정 필요: [# 10](#10-사용자-결정-필요-항목-요약) - -## Freeze 플래그 -- Handoff Decision: REQ Drafting 진행 가능 -- Handoff Rationale: 사장님 승인(2026-06-25). OQ-1(포트폴리오별)·OQ-2(스키마)·OQ-3(소유권 정책) 전부 Answered. REQ-043이 이미 본 Discovery에서 파생 작성됨(confirm-req 대기). 미실현 손익 제외 범위 합의. Open OQ 0건. -- Ready for REQ Drafting: true -- Confirmed By: 팀장(Claude) -- Confirmed At (KST): 2026-06-25 (KST) - -# 12. 작성 가이드 -- 이 문서는 portfolio-hub dcy 템플릿 기반이며 Open Questions는 가정으로 대체하지 않는다. -- confirm-discovery 시 OQ-1/OQ-2 처리와 Freeze 플래그를 갱신한다. diff --git a/docs/discovery/dcy-009_20260627_user-read-asset-catalog-and-daemons.md b/docs/discovery/dcy-009_20260627_user-read-asset-catalog-and-daemons.md deleted file mode 100644 index 0470756..0000000 --- a/docs/discovery/dcy-009_20260627_user-read-asset-catalog-and-daemons.md +++ /dev/null @@ -1,151 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-009_20260627_user-read-asset-catalog-and-daemons -- 이슈명: 사용자용 read 엔드포인트 2종 — asset 카탈로그 조회 권한 완화(require_read) + 사용자 가용 데몬 목록 신규 -- 작성 시각(KST): 2026-06-27 10:07 -- 마지막 갱신 시각(KST): 2026-06-27 10:30 -- 대체됨: 없음 -- 후속 Discovery 참조: 없음 -- 교차 서비스 발원(driver): **trex-web 포트폴리오 관리(예정 dcy-007)** — 사용자가 자신의 포트폴리오를 생성/변경할 때 ① asset 리스트에서 종목을 고르고 ② 자신이 사용 가능한 데몬을 선택해야 한다. 두 선택 UI에 필요한 read API가 portfolio-hub에 없다(asset 목록은 admin 전용, 사용자 데몬 목록은 라우트 미노출). 본 Discovery는 그 의존을 portfolio-hub측 변경 단위로 정식화한다. (trex-web 포트폴리오 관리 UI는 별도 trex-web dcy-007.) -- 생성된 REQ 참조: REQ-044, REQ-045 - -# 1. 계획 상태 요약 -- 연계 문서 현황: - - 관련 점수 파일: 없음 - - 관련 TODO/메모: 워크스페이스 GitHub 보드 #7 trex-web 항목. 발원 = trex-web 포트폴리오 관리. -- 해석: - - trex-web에서 "각 사용자가 자기 포트폴리오를 관리(생성/변경)"하려면 asset 선택·가용 데몬 선택이 필요한데, portfolio-hub의 해당 read 경로가 사용자에게 막혀 있다. - - 사장님 결정(2026-06-27): **GET /v1/assets를 require_read로 완화**, **사용자 가용 데몬 목록 read 엔드포인트 신규**. 백엔드를 먼저 만들고 trex-web UI를 얹는다. - -# 2. 요구사항 판정 결과 -- 판정: 신규 -- 근거 - - asset 카탈로그 조회 권한 완화는 기존 REQ(자산 카탈로그, admin 전용)의 접근 정책 변경 + 신규 사용자 데몬 목록 엔드포인트는 신규 인터페이스다. - - 포트폴리오 관리(생성/변경 시 선택 UI 공급)라는 새 소비 맥락에서 발생한 read 표면 확장이다. - -# 3. 문제점의 요약 -- 현재 상태 - - `GET /v1/assets`(`app/api/v1_assets.py:75` `list_catalog_assets`)는 **`require_admin`** — 일반 사용자가 asset 카탈로그를 읽을 수 없다. (생성/비활성/태그 등 쓰기도 require_admin.) - - **사용자 가용 데몬을 조회하는 HTTP 엔드포인트가 없다.** 도메인 `Daemon`(id·owner_user_id·daemon_slug·display_name·is_active) 존재 + 서비스/리포지토리에 `list_daemons_for_user(user_id)`·`find_candidate_daemons_for_user(user_id)`가 있으나 라우트로 노출되지 않음. - - 포트폴리오 CRUD(`POST/PATCH/DELETE /v1/portfolios`)는 이미 owner-scoped `require_read`로 존재(이번 변경 대상 아님). 포트폴리오는 `managed_by_daemon_id`로 데몬에 바인딩됨(PATCH로 설정 가능). -- 기대 상태 - - 일반 사용자(`require_read`)가 **active asset 카탈로그를 조회**할 수 있다(쓰기 권한은 admin 유지). - - 일반 사용자가 **자신이 사용 가능한 데몬 목록**(자기 소유 active 데몬)을 조회할 수 있다 — 포트폴리오의 `managed_by_daemon_id`로 선택할 후보. -- 이해관계자 - - 사장님 — 접근 정책(카탈로그 공개 범위)·우선순위 결정 - - 팀장(Claude) — Discovery/거버넌스 게이트 - - portfolio-hub-dev — delivery(백엔드) - - trex-web(소비자) / 일반 사용자 -- 제약사항 - - asset 쓰기(생성/비활성/태그 적용)는 **admin 유지**(완화는 읽기에 한정). - - 데몬 목록은 **caller 본인(user_id) 스코프**(타 사용자 데몬 노출 금지). - - 기존 admin asset 관리·서비스 토큰 daemon 바인딩 동작에 회귀 없어야 함. - -## 주요 사용자 시나리오 -- 사용자가 trex-web에서 포트폴리오를 추가할 때, asset 선택 드롭다운/검색이 `GET /v1/assets`(read)로 active 카탈로그를 불러온다. -- 사용자가 포트폴리오의 관리 데몬을 고를 때, "내 가용 데몬" 목록을 신규 엔드포인트로 불러와 선택한다. -- admin은 기존대로 asset 카탈로그를 생성/수정/비활성(쓰기)한다(권한 불변). - -# 4. 이번에 정의할 변경 -- 변경 1 — **GET /v1/assets 권한 완화**: `require_admin` → `require_read`. status 필터(기본 active)·market 필터 동작 유지. 쓰기 엔드포인트(POST/PATCH/DELETE assets, 태그)는 `require_admin` 유지. -- 변경 2 — **사용자 가용 데몬 목록 read 엔드포인트 신규**: caller(user_id) 소유 active 데몬을 반환(id·daemon_slug·display_name·is_active). `require_read`, caller 스코프. 기존 서비스 메서드(`list_daemons_for_user`) 활용. - -## 영향 범위 -- 코드 영향: `app/api/v1_assets.py`(GET 권한 완화), 데몬 목록 라우트 신규(`app/api/`—경로 design), `app/services/auth_service.py`(기존 `list_daemons_for_user` 노출용 wiring), 응답 스키마(DaemonResponse 신규 가능). -- 문서 영향: `docs/srs/`(REQ), `docs/discovery/index.md`. API 계약 문서. -- 운영 영향: asset 카탈로그가 전 인증 사용자에게 read 공개됨(쓰기 불변). 배포 시 계약 변경 고지. - -# 5. 요구사항 목록 -## 기능 요구사항 -- FR-1: `GET /v1/assets`를 `require_read`로 완화해 일반 사용자가 active asset 카탈로그를 조회할 수 있다. 기존 status/market 필터·응답 스키마 유지. asset 쓰기(생성/비활성/태그)는 `require_admin` 유지. -- FR-2: 사용자 가용 데몬 목록 read 엔드포인트를 추가한다 — `require_read`, caller(user_id) 소유 데몬(active) 반환. 응답 필드 id·daemon_slug·display_name·is_active. - -## 비기능 요구사항 -- NFR-1: 기존 admin asset 관리(쓰기)·서비스 토큰 daemon 바인딩 동작에 회귀 0. -- NFR-2: 데몬 목록은 caller 본인 데몬만 반환(타 사용자 데몬 비노출) — 권한/스코프 정확. - -## 범위 경계 -- In Scope - - GET /v1/assets require_read 완화 - - 사용자 가용 데몬 목록 read 엔드포인트 신규 -- Out of Scope - - asset 카탈로그 쓰기(생성/수정/비활성/태그) 권한 변경(admin 유지) - - 데몬 생성/수정/삭제 등 데몬 관리(이번엔 read 목록만) - - 포트폴리오 CRUD 자체(기존 존재) 및 trex-web UI(별도 trex-web dcy-007) - -# 6. 리스크/가정 목록 -## 리스크 -- R-1 (Low): asset 카탈로그를 전 사용자 read 공개 — 민감 정보 노출 우려. - - 완화 방안: 카탈로그는 거래 가능 종목 메타(민감도 낮음). 쓰기는 admin 유지. 필요 시 status=active만 기본 노출. -- R-2 (Low): 데몬 목록 스코프 오류로 타 사용자 데몬 노출. - - 완화 방안: caller user_id 스코프 강제(`list_daemons_for_user(auth.user_id)`), 테스트로 타 사용자 비노출 검증. - -## 가정 -- A-1: 도메인 `Daemon`(owner_user_id)·`list_daemons_for_user`가 사용자 소유 데몬을 정확히 반환한다. -- A-2: asset 카탈로그(거래 종목 메타)는 전 인증 사용자에게 read 공개해도 무방하다(사장님 승인). -- A-3: 포트폴리오의 `managed_by_daemon_id`에 넣을 후보 = 사용자 소유 데몬(이 목록). - -## 오픈 질문 -- OQ-1: GET /v1/assets require_read 완화 — 승인 여부. - - 상태: **Answered (2026-06-27, 사장님)** — require_read로 완화 확정(쓰기는 admin 유지). FR-1 반영. -- OQ-2: 데몬 목록 엔드포인트 경로·형태 — 예: `GET /v1/daemons`(caller 스코프) vs `GET /v1/users/me/daemons`. 응답 스키마(DaemonResponse) 형태. - - 상태: Deferred - - 처리 방안: draft-batch-design에서 기존 라우팅 컨벤션에 맞춰 확정. - - 종료 조건: design에서 경로·스키마 확정 시 Answered. -- OQ-3: "가용 데몬" 정의 — 사용자 소유 active 데몬(`list_daemons_for_user`)으로 충분한지, 아니면 미바인딩 후보(`find_candidate_daemons_for_user`) 의미가 필요한지. - - 상태: Deferred - - 처리 방안: design에서 trex-web 포트폴리오 배정 요구(managed_by_daemon_id 후보)에 맞는 의미 1개 확정. 기본안 = 사용자 소유 active 데몬 전체. - - 종료 조건: design에서 의미 확정 시 Answered. - -### 오픈 질문 처리 기준 -- `Open`/`Answered`/`Deferred`/`Dropped` 표준 처리. - -# 7. 초기 성공 기준 -- S-1: 일반 사용자(read) 세션으로 `GET /v1/assets` 호출 시 200 + active 카탈로그 반환(admin 아님). -- S-2: 일반 사용자가 asset 쓰기(POST/PATCH/DELETE) 시 여전히 403(admin 전용 유지). -- S-3: 사용자 가용 데몬 목록 엔드포인트가 caller 소유 데몬만 반환(타 사용자 데몬 비노출). -- S-4: 기존 admin asset 관리·토큰 daemon 바인딩 회귀 0. - -## 측정 방식 및 데이터 출처 -- S-1/S-2: read·admin·다른권한 토큰별 통합 테스트(권한 매트릭스) / 데이터 출처: pytest. -- S-3: 2명 사용자 데몬 fixture로 스코프 테스트 / pytest. -- S-4: 기존 v1_assets·auth 테스트 회귀 / pytest. - -# 8. REQ로 넘기기 전 확인 체크 -- [x] 범위(In/Out) 확정 — read 2종 In, 쓰기·데몬관리·UI Out (사장님 합의) -- [x] 성공 지표 측정 방식 — 권한 매트릭스·스코프 테스트 -- [x] High 리스크 없음(R-1/R-2 Low) -- [ ] OQ-2/OQ-3(엔드포인트 경로·가용데몬 의미) design 인계 -- [x] Freeze 담당자(CONFIRMED_BY) — 팀장 - -# 9. 파일 처리 결과 -- 처리 결과: 생성 -- 생성 경로: docs/discovery/dcy-009_20260627_user-read-asset-catalog-and-daemons.md -- 참조 문서: - - .github/templates/discovery/discovery.md (템플릿) - - app/api/v1_assets.py(GET /v1/assets — 완화 대상), app/domain/models.py(Daemon), app/services/auth_service.py(list_daemons_for_user) - - 발원: trex-web 포트폴리오 관리(예정 trex-web dcy-007) - -# 10. 사용자 결정 필요 항목 요약 -## DECIDE -- (해소됨, 2026-06-27) GET /v1/assets require_read 완화 — 승인. -## CONFIRM -- (해소됨, 2026-06-27) 백엔드 read 2종(asset 완화 + 사용자 데몬 목록) 단위 채택 — 쓰기·데몬관리·trex-web UI 제외. 백엔드 우선 진행. -## DATA -- (확인됨) Daemon 필드 id·owner_user_id·daemon_slug·display_name·is_active. asset GET 응답 = AssetCatalogListResponse. - -# 11. Discovery Freeze -## 섹션 참조 -- 범위: [# 5 > 범위 경계](#5-요구사항-목록) / 리스크·OQ: [# 6](#6-리스크가정-목록) / 성공: [# 7](#7-초기-성공-기준) / 인계 전 확인: [# 8](#8-req로-넘기기-전-확인-체크) / 결정: [# 10](#10-사용자-결정-필요-항목-요약) -## Freeze 플래그 -- Handoff Decision: REQ Drafting 진행 가능 -- Handoff Rationale: 범위·asset 완화 승인 완료(사장님). OQ-2(엔드포인트 경로)·OQ-3(가용데몬 의미)만 design Deferred — REQ 인계 가능. -- Ready for REQ Drafting: true -- Confirmed By: 팀장 (Claude) -- Confirmed At (KST): 2026-06-27 10:07 -- 비고: 본 Discovery는 사장님이 백엔드 변경(asset 완화 + 데몬 목록 read)을 명시 승인했으므로 작성과 동시에 confirm 처리. 상태는 confirm-discovery 게이트에서 confirmed로 전환. - -# 12. 작성 가이드 -- portfolio-hub .github/templates discovery 기반. trex-web dcy-007(UI)이 이 엔드포인트를 소비한다. design에서 OQ-2/3 확정. diff --git a/docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md b/docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md deleted file mode 100644 index 9c63a81..0000000 --- a/docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md +++ /dev/null @@ -1,194 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-010_20260627_daemon-scoped-broker-credential-provisioning -- 이슈명: daemon-scoped 브로커(KIS) 인증정보 소유·전달 — 다중 인스턴스 trex-core가 daemon별 다른 계좌로 거래 (교차 발원 trex-core 중장기 멀티 인스턴스) -- 작성 시각(KST): 2026-06-27 -- 마지막 갱신 시각(KST): 2026-06-28 -- 대체됨: 없음 -- 후속 Discovery 참조: trex-core 소비자측 day-start fetch + fail-safe 로드 = 별도 trex-core Discovery로 분리 **확정**(2026-06-28). trex-core repo에 신규 Discovery 생성 예정(미생성). -- 생성된 REQ 참조: REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 - -# 1. 계획 상태 요약 -현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. - -- 연계 문서 현황: - - 관련 점수 파일: 미생성 - - 관련 TODO/메모: TREX 워크스페이스 M3 메인 목표(중장기 trex-core 인스턴스 추가). GitHub org 보드 fourblocks2020/trex #7. 교차 발원 = trex-core 멀티 인스턴스(instance_key) + dcy-008(WAC cost-basis seed) -- 해석: - - 사장님이 "다중 인스턴스 trex-core를 완벽히 지원하려면 KIS API 인증정보를 portfolio-hub에서 받아야 하고, 그 인증정보는 universe와 동일하게 daemon_id로 매칭되어야 한다"고 방향을 확정했다. 현재 portfolio-hub는 broker 인증정보를 `user_id` 단위로만 소유해(`broker_credentials` PK=(user_id,broker)) 같은 user의 두 데몬에 서로 다른 계좌를 줄 수 없다. - - 이번 Discovery에서 판단할 핵심: 브로커 인증정보를 daemon_id 차원으로 확장(스키마 PK 변경 + daemon-scoped 전달 엔드포인트)하고, "1 데몬 = 1 계좌" 불변식 하에 응답을 단일 `broker`로 정규화하는 범위·리스크·마이그레이션을 확정한다. - -# 2. 요구사항 판정 결과 -이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. - -- 판정: 신규 -- 근거 - - 기존 `broker_credentials` + `/v1/user_info`(dcy-004 토큰 정비 계열)는 user-scoped 인증정보 소유·전달을 다뤘으나, daemon_id 차원의 스코프·1:1 불변식·daemon 부트스트랩 전달은 정의된 바 없다. - - 변경이 테이블 PK와 전달 엔드포인트 응답 형태(brokers 리스트→단일 broker)를 바꾸는 구조 변경이라 기존 Discovery의 미세 조정이 아닌 독립 주기로 추적한다. - -# 3. 문제점의 요약 -문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. - -- 현재 상태 - - `broker_credentials` 테이블 PK=(`user_id`, `broker`) — 인증정보가 **user 단위**. 컬럼: user_id, broker, account_ref, encrypted_payload(Fernet 암호화 JSON `{app_key, app_secret}`), credential_version, last_rotated_at. (`migrations/versions/20260502_0014_v1_api_schema.py`, `app/domain/models.py:178`) - - 전달: `GET /v1/user_info` — **service token 전용**(token이 `daemon_id`/`runtime_id` 바인딩), 토큰의 `user_id`로 조회 → 서버에서 Fernet 복호화 → `brokers: list[BrokerItem]`(`credential_payload`, `account_ref`, `credential_version`) + `trading_enabled`(user.is_active) 반환. (`app/api/v1_user_info.py`) - - 반면 **universe(포트폴리오)는 이미 daemon-scoped** — `Portfolio.managed_by_daemon_id`, `/v1/portfolios`가 service token의 `auth.daemon_id == managed_by_daemon_id` 일치로 필터·인가. (`app/api/v1_portfolios.py:254,260,554`) - - 결과 비대칭: 토큰은 daemon_id를 들고 universe는 daemon으로 거르는데, **인증정보만 user로 걸러** 같은 user의 두 데몬이 같은 계좌를 공유하게 된다. - - trex-core 측은 현재 KIS를 `TREX_KIS_BROKER_*` env로만 주입(`trex-core/runtime/config.py:257`)하며 portfolio-hub에서 인증정보를 받지 않는다. 멀티 인스턴스 토대(instance_key, longterm.example.toml)는 존재. -- 기대 상태 - - 한 trex-core 데몬 인스턴스가 자기 service token(daemon_id 바인딩)으로 부트스트랩 시 **그 daemon에 매칭된 단일 KIS 계좌 인증정보**를 받는다. - - 같은 user가 단기·중장기 데몬을 동시에 소유해도 **데몬마다 다른 KIS 계좌**로 거래할 수 있다. - - 인증정보는 universe와 동일하게 daemon_id로 인가·스코프되어, 계좌-인스턴스 매칭이 단일 출처(portfolio-hub)에서 결정된다. -- 이해관계자 - - 사장님(제품 방향·계좌/인증 정책 결정·결재) - - 팀장(Discovery·거버넌스·검수) - - portfolio-hub-dev(스키마·엔드포인트·CLI 구현), trex-core-dev(소비자 어댑터 — 별도 Discovery) - - 운영자(계좌 인증정보 등록/회전, 데몬-계좌 바인딩 관리) -- 제약사항 - - KIS app_secret = 시스템 최민감 시크릿(실계좌=실자금). 복호화 평문이 응답 본문으로 네트워크 전송됨 → 내부망/TLS·service-token 게이트 유지 필수. - - portfolio-hub는 M3에서 trex-web 프론트로 노출될 서비스 — 인증정보 경로를 web 노출 표면과 격리 유지(service-token 전용). - - PK 변경은 기존 데이터·제약·인덱스에 영향(마이그레이션 비가역성 주의). - -## 주요 사용자 시나리오 -- 운영자가 단기 전략 데몬 A와 중장기 전략 데몬 B를 같은 user 소유로 등록하고, 각각 **다른 KIS 계좌** 인증정보를 portfolio-hub에 등록한다. A·B는 각자 자기 계좌로 거래한다. -- trex-core 중장기 인스턴스가 그날 시작(아침 스냅샷) 시 service token으로 부트스트랩을 호출해 자기 daemon의 universe + KIS 인증정보를 한 번에 받아 그날 거래를 개시한다. -- 운영자가 데몬 B의 KIS app_secret을 회전(rotate)하면, B만 새 버전을 받고 A는 영향받지 않는다. - -# 4. 이번에 정의할 변경 -이번 Discovery에서 다루는 핵심 변경 항목. - -- (C1) `broker_credentials`에 **`daemon_id` 차원 도입** — 인증정보를 daemon-scope로 소유. PK를 `(user_id, broker)` → daemon 포함 형태로 변경. -- (C2) **daemon-scoped 전달** — service token의 `auth.daemon_id`로 해당 데몬의 인증정보만 복호화·반환. "1 데몬 = 1 계좌" 불변식 하에 응답을 **단일 `broker`**(리스트 아님)로 정규화. -- (C3) **인증정보 관리 경로(CLI/관리 API)에 daemon_id 바인딩 추가** — 등록·회전·삭제 시 데몬-계좌 매칭 지정. - -## 영향 범위 -- 코드 영향: - - `app/domain/models.py`(BrokerCredential), `app/repositories/*`(upsert/list/delete 시그니처 + 쿼리 daemon_id 필터), `migrations/versions/*`(PK·컬럼 마이그레이션) - - `app/api/v1_user_info.py`(기존 `/v1/user_info` daemon-scoped 확장 — 결정 2026-06-28, 신규 엔드포인트 없음), `app/schemas/v1.py`(UserInfoResponse: brokers 리스트→단일 broker) - - `app/cli/commands/credential.py`(`--daemon-id` 추가) - - **교차 서비스(별도 Discovery)**: trex-core — KISConfig를 env-only → portfolio-hub fetch 소스에서 구성하는 소비자 어댑터 + 그날-시작 fail-safe 로드 게이트(`trex-core/runtime/config.py`, daemon 부트스트랩) -- 문서 영향: - - `docs/srs/`(신규 REQ), `docs/discovery/index.md`(본 문서 등록) - - baseline 런타임 흐름에 "daemon 부트스트랩 시 인증정보 전달" 추가 검토(§ runtime-flows 갱신은 NFR/후속에서 판단) -- 운영 영향: - - 운영자 인증정보 등록 절차에 daemon_id 지정 추가. 기존 user-scoped 인증정보 마이그레이션(데몬 매핑) 1회 필요. - - `CREDENTIAL_ENCRYPTION_KEY` 관리·회전 정책(기존 의존 유지). - -# 5. 요구사항 목록 - -## 기능 요구사항 -- FR-1: `broker_credentials`가 `daemon_id`로 인증정보를 스코프한다. 한 (daemon, broker) 조합당 활성 인증정보는 정확히 1건이다. **PK = `(daemon_id, broker)`, daemon-scoped 강제(user-기본 폴백 없음 — 공유계좌 footgun 차단). 결정 2026-06-28.** 마이그레이션은 nullable `daemon_id` 컬럼 추가 → 백필(데몬 매핑) → PK 전환의 단계적 경로. -- FR-2: **기존 `GET /v1/user_info`를 daemon-scoped로 확장**한다(신규 엔드포인트 미신설 — confirm 2026-06-28). service token 호출 시 토큰의 `daemon_id`에 매칭된 **단일 broker 인증정보**(account_ref + 복호화된 credential_payload + credential_version)와 `trading_enabled`를 반환한다. 토큰에 daemon_id가 없으면 거부(403)한다(universe 인가와 동일 규율). 1데몬1계좌 불변식(A-2 confirmed)상 응답은 리스트가 아닌 단일 broker로 정규화한다. 기존 소비자는 trex-core `hub_read.whoami()` 하나뿐이며 `daemon_id`만 읽으므로 broker 필드 reshape는 무영향(안전). -- FR-3: 인증정보 등록/회전/삭제 경로(CLI 및/또는 관리 API)가 `daemon_id`를 받아 데몬-계좌 매칭을 지정한다. -- FR-4: 기존 user-scoped 인증정보를 daemon-scoped로 이전하는 마이그레이션 경로를 제공한다(데이터 손실 없이). - -## 비기능 요구사항 -- NFR-1 (보안): 복호화된 app_secret은 service-token 전용·내부망/TLS 경로로만 전달된다. trex-web 등 user 토큰 경로로는 접근 불가해야 한다. at-rest는 기존 Fernet 암호화 유지. -- NFR-2 (일관성): 인증정보 스코프(daemon_id)가 universe 스코프(`managed_by_daemon_id`)와 동일한 인가 규율을 따른다(불일치/누락 → 403). -- NFR-3 (가용성/degrade): 인증정보 조회·복호화 실패가 부트스트랩 응답의 필수 필드(daemon_id, trading_enabled)를 크래시시키지 않는다(기존 graceful degrade 원칙 유지). 단, 인증정보 부재는 그날 거래 개시 차단 신호로 전달된다. - -## 범위 경계 -- In Scope - - portfolio-hub: daemon_id 차원 인증정보 소유(PK 변경) + daemon-scoped 단일 broker 전달 + 관리 경로 daemon_id 바인딩 + 마이그레이션. - - "1 데몬 = 1 계좌" 불변식 확정 및 응답 단일화. -- Out of Scope - - trex-core 소비자 어댑터(KISConfig fetch) + 그날-시작 fail-safe 로드 게이트 — **별도 trex-core Discovery**로 분리(본 문서는 계약·전달 형태만 확정). - - {{DECIDE_SCOPE_BOOTSTRAP_UNIFY:부트스트랩 응답에 universe까지 한 엔드포인트로 묶을지(단일 부트스트랩) vs 인증정보만 별도 경로로 둘지 — 사장님/팀장 확정 필요}} - - 시크릿 매니저(Vault 등) 외부화 — 현 단계는 portfolio-hub 내부 처리 유지(가정 A-3). - -# 6. 리스크/가정 목록 - -## 리스크 -- R-1 (High): `broker_credentials` PK 변경 마이그레이션이 기존 데이터·제약을 깨거나 롤백이 어려움. - - 완화 방안: nullable `daemon_id` 컬럼 추가 → 백필(데몬 매핑) → 새 PK/유니크 제약 전환의 단계적 마이그레이션. 사전 백업·드라이런·롤백 스크립트. 비가역 단계는 사장님 결재. -- R-2 (High): 최민감 시크릿(app_secret) 평문이 네트워크 전송되며, portfolio-hub가 web 노출 서비스라 표면이 큼. - - 완화 방안: service-token 전용 + daemon_id 인가 + 내부망/TLS 한정. web의 user 토큰 경로와 라우트/권한 분리. 감사 로깅. -- R-3 (Medium): `/v1/user_info` 응답 형태(brokers 리스트→단일)의 파괴적 변경이 기존 소비자를 깸. - - 완화 방안: 기존 소비자 인벤토리 확인 후, 신규 daemon-scoped 경로 신설(기존 user-level 리스트는 admin/UI용으로 유지) 또는 버전드 전환. - -## 가정 -- A-1: portfolio-hub가 universe·전략·인증정보의 단일 진실원천이다. (사장님 확정) -- A-2: "1 데몬(trex-core 인스턴스) = 1 브로커 계좌" 불변식이 성립한다. trex-core `KISConfig`가 현재 단일인 점과 정합. (**사장님 confirmed 2026-06-28** → 단일 `broker` 응답 근거) -- A-3: 현 단계는 portfolio-hub 내부 인증정보 소유(기존 Fernet + `CREDENTIAL_ENCRYPTION_KEY`)를 유지하고 외부 시크릿 매니저로 가지 않는다. -- A-4: 데몬-계좌 매칭은 service token의 daemon_id 바인딩(`AuthToken.daemon_id`)을 통해 결정된다. - -## 오픈 질문 -- OQ-1: PK 전환 형태 + 폴백 정책. - - 상태: **Answered (2026-06-28)** - - 결정: **PK = `(daemon_id, broker)`, daemon-scoped 강제(폴백 없음)**. 마이그레이션 = nullable `daemon_id` 컬럼 추가 → 백필 → PK 전환 단계적. 본문 FR-1·R-1에 반영. - - 반영 위치: §5 FR-1, §6 R-1 완화 방안. -- OQ-2: 전달 형태 — 기존 `/v1/user_info` reshape vs daemon-scoped 신규 엔드포인트. - - 상태: **Answered (2026-06-28)** - - 결정: **기존 `/v1/user_info`를 daemon-scoped로 확장**(신규 엔드포인트 미신설, 사장님). 소비자 인벤토리 조사 결과 유일 소비자=trex-core `adapters/portfolio/hub_read.py::whoami()`이며 `daemon_id`만 읽음(`brokers` 미참조) → 단일 broker reshape 무영향(안전). 본문 FR-2에 반영. - - 반영 위치: §4 영향범위, §5 FR-2, §7 S-1/S-2. -- OQ-3: 부트스트랩 통합 범위 — universe + 인증정보 + trading_enabled를 단일 부트스트랩 응답으로 묶을지. - - 상태: Deferred - - 처리 방안: trex-core 소비자 Discovery와 함께 결정(전달 계약 공동 설계). 본 문서는 인증정보 전달 형태만 확정. - - 종료 조건: trex-core 소비자 Discovery 착수 시점. -- OQ-4: 그날-시작 fail-safe 로드 의미론(진입 게이트 vs 청산 보존, 장중 무중단)의 trex-core측 구현 책임 분배. - - 상태: Deferred - - 처리 방안: 별도 trex-core Discovery로 인계(아래 §10 참조). 본 문서엔 전달측 가정으로만 기록. - - 종료 조건: trex-core 소비자 Discovery 생성. - -# 7. 초기 성공 기준 -- S-1: 같은 user가 소유한 두 데몬이 각각 다른 KIS `account_ref`/`app_key`를 부트스트랩으로 수신함을 검증할 수 있다. -- S-2: service token에 daemon_id가 없거나 인증정보가 daemon에 매칭되지 않으면 전달이 403/차단됨이 테스트로 확인된다. -- S-3: 기존 user-scoped 인증정보가 마이그레이션 후 손실 없이 daemon-scoped로 조회된다. - -## 측정 방식 및 데이터 출처 -- S-1 측정 방식: 두 데몬용 service token으로 부트스트랩 호출 → 응답 account_ref/credential_payload 상이 확인 / 데이터 출처: 통합테스트 + `broker_credentials` 행 -- S-2 측정 방식: daemon_id 누락/불일치 토큰 요청의 403 단위·통합 테스트 / 데이터 출처: 엔드포인트 테스트 -- S-3 측정 방식: 마이그레이션 전후 인증정보 복호화 결과 동등성 비교 / 데이터 출처: 마이그레이션 드라이런 로그 + 테스트 - -# 8. REQ로 넘기기 전 확인 체크 -- [x] 범위(In Scope/Out of Scope) 확정 - - 기준: trex-core 소비자측 분리 확정(별도 Discovery), 부트스트랩 통합(OQ-3) Deferred 처리 -- [x] 성공 지표의 측정 방식/데이터 출처 확정 - - 기준: S-1~S-3 측정 가능성 검토 완료 -- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 - - 기준: R-1(마이그레이션 — 단계적 PK 전환·롤백, portfolio-hub-dev)·R-2(시크릿 표면 — service-token+내부망) 완화안 확정 -- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - - 기준: OQ-1·OQ-2 Answered, OQ-3·OQ-4 Deferred 처리 완료 -- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 - - 기준: 사장님+팀장 confirm-discovery (DECIDE/CONFIRM 전건 해소) - -# 9. 파일 처리 결과 -- 처리 결과: 생성 -- 생성 경로: docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md -- 참조 문서: - - docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md (service token·daemon 바인딩) - - docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md (교차 발원 trex-core 중장기 cold-start) - - app/api/v1_user_info.py / app/domain/models.py(BrokerCredential) / app/api/v1_portfolios.py(daemon-scope 선례) - -# 10. 사용자 결정 필요 항목 요약 - -## DECIDE -- ~~PK 전환 형태 (OQ-1)~~ → **RESOLVED 2026-06-28: PK `(daemon_id, broker)` + daemon-scoped 강제(폴백 없음), 단계적 마이그레이션** -- ~~엔드포인트 형태 (OQ-2)~~ → **RESOLVED 2026-06-28: 기존 `/v1/user_info` daemon-scoped 확장** -- ~~부트스트랩 통합 (OQ-3)~~ → **Deferred: trex-core 소비자 Discovery 공동 결정** - -## CONFIRM -- ~~1데몬1계좌 불변식~~ → **RESOLVED 2026-06-28: 확정(A-2 confirmed) → 단일 broker 응답** -- ~~trex-core 소비자측 분리~~ → **RESOLVED 2026-06-28: 별도 trex-core Discovery로 분리 확정** - -## DATA -- ~~기존 /v1/user_info 소비자 인벤토리~~ → **RESOLVED 2026-06-28: 유일 소비자=trex-core hub_read.whoami(daemon_id만 읽음), brokers 미참조 → reshape 안전** - -# 11. Discovery Freeze -REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. - -## 섹션 참조 -- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) -- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) -- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) -- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) -- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) - -## Freeze 플래그 -- Handoff Decision: REQ Drafting 진행 가능 -- Handoff Rationale: confirm-discovery 완료(2026-06-28). DECIDE 전건 해소 — OQ-1(PK `(daemon_id, broker)`+폴백없음), OQ-2(기존 `/v1/user_info` 확장), OQ-3(Deferred). CONFIRM 전건 — A-2(1데몬1계좌→단일 broker), trex-core 소비자측 별도 Discovery 분리. DATA(소비자 인벤토리) 해소. 잔여 Open 0건 → REQ 초안 인계 가능. -- Ready for REQ Drafting: true -- Confirmed By: 팀장(Claude) -- Confirmed At (KST): 2026-06-28 diff --git a/docs/discovery/dcy-011_20260628_daemon-crud-and-self-service-credential-api.md b/docs/discovery/dcy-011_20260628_daemon-crud-and-self-service-credential-api.md deleted file mode 100644 index 45de1d1..0000000 --- a/docs/discovery/dcy-011_20260628_daemon-crud-and-self-service-credential-api.md +++ /dev/null @@ -1,196 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-011_20260628_daemon-crud-and-self-service-credential-api -- 이슈명: 데몬 admin CRUD API + 사용자 self-service 브로커 인증정보 관리 API — 소유 스코프 write/rotate/메타데이터(복호화 secret 미반환), 데몬 생성·할당·(비)활성화는 admin. trex-web dcy-009 백엔드 (교차 발원) -- 작성 시각(KST): 2026-06-28 -- 마지막 갱신 시각(KST): 2026-06-28 (REQ 초안 생성) -- 대체됨: 없음 -- 후속 Discovery 참조: 없음 -- 생성된 REQ 참조: - - REQ-052: 사용자 소유 스코프 인증정보 관리 HTTP API ([docs/srs/Interface/req-052_user-owned-credential-management-api.md](../srs/Interface/req-052_user-owned-credential-management-api.md)) - - REQ-053: admin 데몬 CRUD HTTP API ([docs/srs/Interface/req-053_admin-daemon-crud-api.md](../srs/Interface/req-053_admin-daemon-crud-api.md)) - - REQ-054: 인증정보 관리 보안 격리 — write-only·secret 미반환·로그 미노출 ([docs/srs/Non-Functional/req-054_credential-management-security-write-only.md](../srs/Non-Functional/req-054_credential-management-security-write-only.md)) - -# 1. 계획 상태 요약 -현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. - -- 연계 문서 현황: - - 관련 점수 파일: 미생성 - - 관련 TODO/메모: GitHub org 보드 fourblocks2020/trex #7 — "portfolio-hub 데몬 레지스트리/관리 API", "서비스 토큰/데몬 발급·관리". **교차 발원 = trex-web `dcy-009`**(사용자 self-service 인증정보 관리 + admin 데몬 provisioning UI). 선행 = `dcy-010`(daemon-scoped 브로커 인증정보 모델/보안), `dcy-004`(토큰·권한 모델) -- 해석: - - trex-web dcy-009가 정의한 기능(사용자가 자기 데몬 KIS 인증정보를 self-service 관리, admin이 데몬 provisioning)을 받쳐줄 portfolio-hub 백엔드 API가 없다. 현재 데몬은 read-only(`/v1/daemons`), 인증정보는 CLI(`pfhub credential`) 전용이며 복호화 read는 service-token `/v1/user_info`뿐이다. - - 핵심 판단: ① **사용자 소유 스코프** 인증정보 관리 API(write/rotate/delete + 메타데이터, **복호화 secret 절대 미반환**), ② **admin 데몬 CRUD** API(생성·할당·(비)활성화)를 dcy-004 권한 모델 위에 어떻게 설계하고, service-token decrypt 경로(REQ-051)와 어떻게 분리·불변 유지할지. - -# 2. 요구사항 판정 결과 -이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. - -- 판정: 신규 -- 근거 - - 기존 `/v1/daemons`는 read 전용이고, 인증정보 관리는 CLI 전용이다. **HTTP 기반 소유 스코프 인증정보 관리 API**와 **admin 데몬 CRUD API**는 정의된 바 없다. - - dcy-010은 인증정보 *모델/저장*(CLI·service-token read)을 다뤘고, 본 건은 *사용자/admin HTTP 관리 표면*을 신설하므로 신규 주기로 추적한다. - -# 3. 문제점의 요약 -문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. - -- 현재 상태 - - `BrokerCredential`(dcy-010): PK `(daemon_id, broker)`, `user_id` 보유, Fernet 암호화 `{app_key, app_secret}`. 관리=CLI(`pfhub credential set/list/remove --daemon-id`). 복호화 read=**service-token 전용 `GET /v1/user_info`**(REQ-051, web/user-token 403). - - `Daemon`(id, owner_user_id, daemon_slug, display_name, is_active): read API `GET /v1/daemons`만. 생성·변경·(비)활성화 HTTP API 없음. - - 권한 모델(dcy-004): user-token(권한 read/write), admin role, service-token(daemon_id 바인딩). 소유 스코프 인가 선례 = dcy-009 trex-web 포트폴리오/REQ-049 `managed_by_daemon_id` 일치. - - 암호화 유틸(`app/core/encryption.py` Fernet, `CREDENTIAL_ENCRYPTION_KEY`) 존재. -- 기대 상태 - - **사용자(소유 스코프)**: HTTP로 자기 소유 데몬의 인증정보를 등록·회전·삭제하고 메타데이터(broker, account_ref, version, last_rotated, 보유 여부)를 조회. **자기 것만**. - - **admin**: HTTP로 데몬을 생성·사용자 할당·변경·(비)활성화. - - **복호화된 app_secret은 user-token/admin 어떤 응답에도 포함되지 않는다.** service-token decrypt 경로(REQ-051)는 불변. -- 이해관계자 - - 사장님(보안·권한·결재), 팀장(거버넌스), portfolio-hub-dev(구현), 일반 사용자(self-service 소비, trex-web 경유), admin/운영자(데몬 provisioning), trex-web 개발(dcy-009 소비측) -- 제약사항 - - **최민감 시크릿(KIS app_secret = 실계좌·실자금)** 복호화 평문이 user-token/admin 응답·로그에 노출 금지. service-token `/v1/user_info`(REQ-051)가 유일 decrypt-return 경로 — 불변. - - **소유 스코프 인가 엄격**: 사용자는 자기 소유 데몬(owner_user_id==요청자)의 인증정보만. 타인 접근 403. - - 데몬은 운영 런타임 식별자 → 생성·(비)활성화 영향 큼. 삭제는 dcy-009 결정대로 제외(비활성화). - - CLI(`pfhub credential`)와 신규 API의 동작 정합(동일 repository·암호화 자산 재사용). - -## 주요 사용자 시나리오 -- admin이 `POST /v1/daemons`(또는 동등)로 데몬 D를 생성하고 owner=사용자 U로 할당한다. -- 사용자 U가 (trex-web 경유, user-token) 자기 데몬 D의 인증정보를 등록(app_key/app_secret) → 서버 Fernet 암호화 저장. 응답엔 secret 미포함, account_ref·version만. -- 사용자 V가 사용자 U의 데몬 D 인증정보 조회/수정 요청 → 403(소유 스코프). -- 데몬 D 인스턴스는 service-token으로 `/v1/user_info`에서 복호화 인증정보 fetch(기존 경로 불변). - -# 4. 이번에 정의할 변경 -이번 Discovery에서 다루는 핵심 변경 항목. - -- (C1) **사용자 소유 스코프 인증정보 관리 API** — 자기 소유 데몬 인증정보 set/rotate/delete + 메타데이터 GET. user-token + ownership 게이트. **복호화 secret 미반환**. 암호화·저장은 dcy-010 자산 재사용. -- (C2) **admin 데몬 CRUD API** — 데몬 생성·owner 할당·변경·(비)활성화. admin role. (삭제 제외 — 비활성화.) -- (C3) **repository 계층 확장** — credential ownership/메타데이터 조회·daemon CRUD 메서드. CLI와 동일 자산 공유. - -## 영향 범위 -- 코드 영향: - - `app/api/` — 신규 인증정보 관리 라우트(소유 스코프) + `v1_daemons.py` write(admin) 확장 - - `app/repositories/*` — credential 메타데이터/ownership 조회, daemon create/update/deactivate - - `app/core/auth.py` — ownership 게이트(요청자==owner) + admin 게이트 재사용 - - `app/schemas/*` — 관리 요청/응답 스키마(**secret 미포함** 메타데이터 응답) - - 암호화: `app/core/encryption.py`(dcy-010) 재사용 -- 문서 영향: - - `docs/srs/`(신규 REQ), `docs/discovery/index.md`(본 문서), `docs/interface-contract.md`(신규 엔드포인트) - - 교차: trex-web dcy-009(소비자 UI) -- 운영 영향: - - 사용자 self-service 등록으로 CLI 부담 감소(CLI는 fallback 유지). admin 데몬 provisioning HTTP 경로. - -# 5. 요구사항 목록 - -## 기능 요구사항 -- FR-1: 사용자가 **자기 소유 데몬**의 인증정보를 등록·회전·삭제한다(user-token). app_key/app_secret 입력 → Fernet 암호화 저장(dcy-010). 회전 시 credential_version++/last_rotated_at 갱신. -- FR-2: 사용자가 자기 인증정보 **메타데이터**(broker, account_ref, credential_version, last_rotated_at, 데몬별 보유 여부)를 조회한다. **복호화된 app_key/app_secret은 응답에 포함되지 않는다.** -- FR-3: 인증정보 관리 경로는 **소유 스코프 인가**를 강제한다 — 요청자 user_id != 대상 데몬 owner_user_id → HTTP 403(타인 데몬·계좌 접근 차단). -- FR-4: admin이 데몬을 생성·owner 할당·변경(display_name 등)·(비)활성화한다(admin role). **하드 삭제는 제공하지 않는다(비활성화로 대체).** - -## 비기능 요구사항 -- NFR-1 (보안 — 최우선): 복호화된 app_secret은 **user-token/admin 어떤 응답·로그에도 포함되지 않는다**(쓰기 전용 관리 + 메타데이터). service-token `GET /v1/user_info`(REQ-051)가 유일 decrypt-return 경로 — 본 건으로 변경하지 않는다. at-rest Fernet 불변. -- NFR-2 (권한): 인증정보 관리 = **소유 스코프**(user-token, 요청자==데몬 owner). 데몬 CRUD = **admin role**. dcy-004 토큰/권한 모델 재사용. -- NFR-3 (일관성): 신규 API·CLI(`pfhub credential`)·service-token read가 동일 `BrokerCredential`/`Daemon` 모델·암호화 자산에서 정합 동작. daemon_id·owner_user_id 바인딩 정합(dcy-010). - -## 범위 경계 -- In Scope - - 소유 스코프 인증정보 관리 API(set/rotate/delete + 메타데이터, secret 미반환). - - admin 데몬 CRUD API(생성·할당·변경·(비)활성화, 삭제 제외). - - repository·스키마·권한 게이트. -- Out of Scope - - service-token 복호화 read 경로(`/v1/user_info`, dcy-010/REQ-051) 변경 — **불변**. - - trex-web UI(= dcy-009). - - 서비스 토큰 발급·폐기(별 Discovery, dcy-009 OQ-3). - - 외부 시크릿 매니저 외부화. - - 데몬 하드 삭제. - -# 6. 리스크/가정 목록 - -## 리스크 -- R-1 (High): 복호화 app_secret이 user-token/admin 관리 응답·로그로 유출(실계좌 탈취). - - 완화 방안: 관리 API는 set/rotate/delete + 메타데이터만, 복호화 payload 절대 미반환. 응답 스키마에 secret 필드 부재. 로그 secret 미포함(REQ-051 패턴). service-token 경로와 라우트·권한 분리. 음성 테스트. -- R-2 (High): ownership 인가 결함 → 사용자가 타인 데몬 인증정보 조회·수정(횡적 권한 상승). - - 완화 방안: 모든 관리 경로 **엄격 ownership 검사**(요청자==owner_user_id, 불일치 403). dcy-009 trex-web 포트폴리오/REQ-049 daemon-scope 인가 재사용. 음성 테스트(타인 자원→403) 필수. -- R-3 (Medium): 데몬 (비)활성화·변경 blast radius — 운영 인스턴스가 daemon_id로 fetch 중. - - 완화 방안: 삭제 미제공(비활성화), 참조 무결성·활성 데몬 보호, 인증정보 보유 데몬 비활성화 경고. -- R-4 (Medium): CLI·신규 API 이중 관리 경로 불일치. - - 완화 방안: 동일 repository 메서드·암호화 자산 재사용, PK `(daemon_id, broker)` 정합. - -## 가정 -- A-1: dcy-010 `BrokerCredential`(PK `(daemon_id, broker)`, user_id, Fernet, `CREDENTIAL_ENCRYPTION_KEY`) 완료. (확인됨, Implemented) -- A-2: `Daemon.owner_user_id` 존재 → 소유 스코프 인가가 데이터상 가능. (확인됨) -- A-3: dcy-004 토큰/권한(user-token, admin role, service-token) 재사용. -- A-4: 교차 발원 trex-web `dcy-009`(confirmed)가 소비자. UI는 dcy-009 범위. -- A-5: service-token `/v1/user_info`(REQ-051) decrypt-return 경로 불변(유일 decrypt 경로). - -## 오픈 질문 -- OQ-1: 인증정보 관리 엔드포인트 경로·형태 — daemon-scoped(`/v1/daemons/{id}/credential`) vs user-credential. - - 상태: **Deferred (2026-06-28)** → draft-batch-design 단계 위임. - - 처리 방안: design에서 REST 형태 확정. 팀장 권고 = daemon-scoped 경로(소유 스코프 자연). dcy-009 UI 흐름·v1 컨벤션 정합. - - 종료 조건: draft-batch-design 전 경로·메서드 확정. -- OQ-2: admin 데몬 CRUD 경로 — `/v1/daemons` 확장 vs `/v1/admin/daemons`. - - 상태: **Deferred (2026-06-28)** → draft-batch-design 단계 위임. - - 처리 방안: design에서 확정. dcy-006 admin 경로 컨벤션 참고. - - 종료 조건: draft-batch-design 전 확정. -- OQ-3: 데몬 owner 재할당 허용 여부 + 재할당 시 인증정보 처리. - - 상태: **Answered (2026-06-28)** - - 결정: **재할당 허용(admin), 단 owner 변경 시 해당 데몬의 기존 인증정보 자동 무효화**(이전 owner의 KIS 계좌 이관 금지 — 새 owner가 재등록). §5 FR-4/범위·R-2 반영. - -# 7. 초기 성공 기준 -- S-1: 사용자가 자기 데몬 인증정보를 API로 등록하면, 그 데몬이 service-token `/v1/user_info`에서 동일 계좌를 fetch함을 검증할 수 있다. -- S-2: 인증정보 관리/메타데이터·데몬 CRUD 어떤 user-token/admin 응답에도 복호화 app_secret/app_key가 포함되지 않음이 테스트로 확인된다. -- S-3: 사용자 A가 사용자 B 소유 데몬의 인증정보 조회·수정 시 403(소유 스코프)이 테스트로 확인된다. -- S-4: admin이 데몬 생성·owner 할당·(비)활성화를 수행하고, 비-admin은 거부(403)됨이 확인된다. - -## 측정 방식 및 데이터 출처 -- S-1 측정 방식: API 등록 → service-token `/v1/user_info` fetch 일치 / 데이터 출처: 통합테스트, broker_credentials 행 -- S-2 측정 방식: 관리·CRUD 응답 스키마/실응답에서 secret 필드 부재 + 로그 미노출 / 데이터 출처: API·로그 테스트 -- S-3 측정 방식: 타 사용자 자원 접근 → 403 음성 테스트 / 데이터 출처: API 테스트 -- S-4 측정 방식: admin/비-admin 권한 경로 테스트 / 데이터 출처: API 테스트 - -# 8. REQ로 넘기기 전 확인 체크 -- [x] 범위(In Scope/Out of Scope) 확정 - - 기준: 데몬 owner 재할당 정책(OQ-3 재할당+인증정보 무효화) 결정 완료 -- [x] 성공 지표의 측정 방식/데이터 출처 확정 - - 기준: S-1~S-4 측정 가능성 검토 완료 -- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 - - 기준: R-1(secret 노출 — 쓰기전용)·R-2(소유 스코프 인가 — 엄격 ownership) 완화 모델 확정. 구현 책임=portfolio-hub-dev -- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - - 기준: OQ-3 Answered, OQ-1/OQ-2 Deferred(design 위임) -- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 - - 기준: 사장님+팀장 confirm-discovery 완료 - -# 9. 파일 처리 결과 -- 처리 결과: 생성 -- 생성 경로: docs/discovery/dcy-011_20260628_daemon-crud-and-self-service-credential-api.md -- 참조 문서: - - trex-web docs/discovery/dcy-009_20260628_daemon-and-broker-credential-admin-management.md (교차 발원 — 소비자 UI/요건) - - docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md (선행 — 인증정보 모델/보안/REQ-051) - - docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md (권한/토큰 모델) - -# 10. 사용자 결정 필요 항목 요약 - -## DECIDE -- ~~데몬 owner 재할당 (OQ-3)~~ → **RESOLVED 2026-06-28: 재할당 허용(admin), owner 변경 시 기존 인증정보 자동 무효화** - -## CONFIRM -- ~~쓰기 전용·secret 미반환·service-token 유일 decrypt 불변~~ → **RESOLVED 2026-06-28: 확정(NFR-1)** -- ~~엄격 소유 스코프 인가 + 데몬 CRUD admin~~ → **RESOLVED 2026-06-28: 확정(NFR-2)** - -## DATA -- {{DATA_V1_CONVENTIONS:기존 v1 API 경로·권한 컨벤션(/v1/daemons, /v1/portfolios, dcy-006 admin) — 엔드포인트 형태(OQ-1/OQ-2 design) 설계 입력}} - -# 11. Discovery Freeze -REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. - -## 섹션 참조 -- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) -- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) -- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) -- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) -- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) - -## Freeze 플래그 -- Handoff Decision: REQ Drafting 진행 가능 -- Handoff Rationale: confirm-discovery 완료(2026-06-28). CONFIRM 2건(쓰기전용·secret 미반환·service-token 유일 decrypt / 엄격 소유 스코프+데몬 CRUD admin) + DECIDE 1건(OQ-3 owner 재할당 허용+인증정보 자동 무효화) 확정. OQ-1/OQ-2(엔드포인트 경로)는 draft-batch-design 위임 Deferred. 잔여 Open 0건 → REQ 인계 가능. trex-web dcy-009가 이 계약 소비. -- Ready for REQ Drafting: true -- Confirmed By: 팀장(Claude) -- Confirmed At (KST): 2026-06-28 diff --git a/docs/discovery/dcy-012_20260714_manual-ledger-write-api-owner-scoped.md b/docs/discovery/dcy-012_20260714_manual-ledger-write-api-owner-scoped.md deleted file mode 100644 index 6325483..0000000 --- a/docs/discovery/dcy-012_20260714_manual-ledger-write-api-owner-scoped.md +++ /dev/null @@ -1,271 +0,0 @@ -# 0. 문서 상태 -이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. - -- 상태: confirmed -- 문서 ID: dcy-012_20260714_manual-ledger-write-api-owner-scoped -- 이슈명: 사용자 수동 장부 입력 API — INVEST/BUFFER/BUY/SELL/WITHDRAW/PROFIT 계약 및 owner-scoped ledger write -- 작성 시각(KST): 2026-07-14 14:01 -- 마지막 갱신 시각(KST): 2026-07-14 16:35 KST (confirm-discovery) -- 대체됨: 없음 -- 교차 발원: trex-web `docs/discovery/dcy-011_20260714_portfolio-manual-ledger-balance-quantity.md` confirmed by Justin Ko -- Project #7 item: `PVTI_lADOBCBKFM4BbUVHzgyvA9A` — `portfolio-hub: 수동 장부 입력 API 계약 — INVEST/BUFFER/BUY/SELL/WITHDRAW/PROFIT` -- 생성된 REQ 참조: `docs/srs/Interface/req-056_v1-portfolios-manual-ledger-api.md` - -# 1. 계획 상태 요약 -- 연계 문서 현황: - - trex-web `dcy-011_20260714_portfolio-manual-ledger-balance-quantity`가 `approved-backend-first`로 confirmed 됨. - - trex-web은 수동 장부 입력/이력화 UI를 원하지만, source of truth인 `portfolio-hub`의 user-token owner-scoped write API 계약이 먼저 필요하다. - - portfolio-hub baseline `docs/data-model.md`는 “Balance-changing commands must update snapshot fields and append a ledger row together” 원칙을 명시한다. - - `dcy-006_20260605_ledger-trade-write-portfolio-state-return` 이후 portfolio_ledger는 broker/service-token trade write의 단일 원장으로 정리되었고, cash/quantity after-snapshot 응답 정책이 확립되었다. -- 현행 구조 확인: - - `app/domain/services.py`: `_INVEST_ACTION_MAP`은 `deposit -> INVEST`, `profit -> PROFIT`, `withdraw -> WITHDRAW`로 buffer 조정을 호출한다. - - **사용자 정정(2026-07-14)**: `PROFIT`과 `WITHDRAW`는 snapshot effect(`profit_buffer -= amount`)는 유사하지만, 수익률 계산 의미가 완전히 다르다. `WITHDRAW`는 투자 원금/입금액 회수로 `INVEST - WITHDRAW` 순입금액 계산에 반영되지만, `PROFIT`은 순수 수익 실현 인출로 순입금액 계산에 포함하지 않는다. 따라서 신규 manual API에도 `PROFIT`은 별도 action으로 반드시 필요하다. - - `app/repositories/postgres.py:adjust_buffer()`는 `INVEST` 시 `profit_buffer += amount`, `WITHDRAW/PROFIT` 계열은 `profit_buffer -= amount`로 처리하고 ledger row를 남긴다. - - `app/repositories/postgres.py:transfer_buffer()`는 `to-buffer`를 `BUFFER_IN`으로 기록하며 `cash_balance -= amount`, `profit_buffer += amount`; `from-buffer`를 `BUFFER_OUT`으로 기록하며 `profit_buffer -= amount`, `cash_balance += amount`로 처리한다. - - `app/domain/services.py:apply_buy()`/`apply_sell()`은 `BUY`/`SELL` ledger row를 남기며 `cash_balance`와 `asset_quantity`를 함께 갱신한다. - - `README.md`에는 CLI 흐름이 이미 존재한다: `pfhub portfolio invest`, `pfhub portfolio transfer`, `pfhub portfolio buy`, `pfhub portfolio sell`. -- 해석: - - portfolio-hub에는 수동 장부에 필요한 도메인 서비스/CLI 흐름이 상당 부분 존재하지만, trex-web이 사용할 **HTTP v1 user-token owner-scoped API 계약**은 아직 정식 제품 계약으로 정리되어 있지 않다. - - 현재 v1 service-token endpoint(`POST /v1/portfolios/{portfolio_id}/ledger/trades`)는 daemon/trade-write 체결 보고용이다. 사용자 수동 장부 입력은 owner-scoped user/session token 기반으로 분리해야 한다. - - **사용자 결정(2026-07-14)**: 기존 `POST /v1/portfolios/{portfolio_id}/ledger/trades`는 trex-core/daemon/service-token broker trade write 전용으로 유지한다. trex-web manual BUY/SELL은 이 endpoint를 재사용하지 않고 신규 manual endpoint를 사용한다. 단, 내부 ledger append + snapshot update 회계 helper는 가능한 한 공유한다. - -# 2. 요구사항 판정 결과 -- 판정: 신규 -- 근거: - - 기존 dcy-006/REQ-037은 daemon/service-token broker trade write의 회계 책임과 응답 정합성을 다뤘다. - - 기존 레거시/CLI `portfolio invest|transfer|buy|sell` 흐름은 사용자 self-service 웹 API 계약이 아니다. - - trex-web dcy-011에서 확정된 UI/제품 요구와 후속 사용자 정정은 `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `BUY`, `SELL`, `WITHDRAW`, `PROFIT`, `REVERSAL`을 owner-scoped manual ledger API로 노출할 필요가 있다. - -# 3. 문제점의 요약 -- 현재 상태: - - portfolio snapshot fields는 `cash_balance`, `asset_quantity`, `profit_buffer`, `price`로 구성된다. - - portfolio_ledger는 after-snapshot 필드(`cash_balance_after`, `asset_quantity_after`, `total_value_after`, `profit_buffer_after`)를 가진다. - - CLI/domain 경로는 수동 장부성 작업을 수행할 수 있지만, trex-web이 직접 호출할 안정적인 v1 API 계약/권한/응답 shape이 없다. - - daemon-managed portfolio에 대한 수동 보정은 운영상 필요하지만, 자동/daemon 체결 기록과 충돌할 수 있다. -- 기대 상태: - - trex-web은 user/session token으로 자기 포트폴리오에 수동 장부 entry를 추가할 수 있다. - - 모든 manual ledger write는 portfolio row lock/transaction 안에서 snapshot update와 ledger append를 함께 수행한다. - - 응답은 변경 후 `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, 필요 시 `total_value_after`를 포함한다. - - action/effect는 trex-web dcy-011에서 확정된 portfolio-hub 구조를 따른다. - - ledger history 조회는 trex-web이 최근 장부 이력과 변경 후 상태를 표시할 수 있을 만큼 충분하다. -- 이해관계자: - - portfolio-hub-dev — API/data model/권한/트랜잭션 구현 책임 - - trex-web/service-dev — API client, 장부 입력 UI, 이력 UI 소비자 - - qc-reviewer — action/effect, owner-scope, transaction, regression 검증 - - lead — StagePilot 게이트와 교차서비스 정합 관리 - - Justin Ko — 제품/운영 정책 승인 -- 제약사항: - - 기존 daemon/service-token ledger trade endpoint의 semantics와 idempotency를 깨지 않아야 한다. - - disabled portfolio write는 현행 정책대로 HTTP 409 또는 동등한 차단을 유지해야 한다. - - owner mismatch는 fail-closed 해야 한다. - - `QUANTITY_ADJUSTMENT`는 1차 범위에서 제외하고 후속 후보로만 남긴다. - -## 주요 사용자 시나리오 -- 사용자가 trex-web에서 투자금을 포트폴리오에 투입한다: 외부자금 --(`INVEST`)--> `profit_buffer`. -- 사용자가 전략 수행 기본자금으로 배정한다: `profit_buffer` --(`BUFFER_OUT`)--> `cash_balance`. -- 사용자가 상승 후 리밸런싱/보관한다: `cash_balance` --(`BUFFER_IN`)--> `profit_buffer`. -- 사용자가 수동 매수 기록을 입력한다: `cash_balance` --(`BUY`)--> `asset_quantity`. -- 사용자가 수동 매도 기록을 입력한다: `asset_quantity` --(`SELL`)--> `cash_balance`. -- 사용자가 투자 원금/입금액을 회수한다: `profit_buffer` --(`WITHDRAW`)--> 외부자금. 이 금액은 수익률 계산의 순입금액(`INVEST - WITHDRAW`)에 반영된다. -- 사용자가 순수 수익을 실현해 꺼낸다: `profit_buffer` --(`PROFIT`)--> 외부자금. snapshot effect는 buffer 감소로 `WITHDRAW`와 유사하지만, 수익률 계산의 순입금액에는 포함하지 않는다. -- 사용자가 오입력 정정을 수행한다: 기존 entry를 수정/삭제하지 않고 반대 효과의 `REVERSAL` entry를 append한다. - -# 4. 이번에 정의할 변경 -- 변경 1 — **manual ledger write API 계약**: user/session token으로 owner-scoped portfolio에 `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `BUY`, `SELL`, `WITHDRAW`, `PROFIT`, `REVERSAL`을 기록하는 HTTP v1 계약을 정의한다. -- 변경 2 — **action/effect 회계 규칙**: 각 action의 snapshot 변화(`cash_balance`, `asset_quantity`, `profit_buffer`)와 수수료/세금/단가/수량 입력 필드를 정의한다. -- 변경 3 — **ledger history/read API 계약**: trex-web이 최근 장부 이력을 표시할 수 있는 response shape, pagination/limit, action filter 필요 여부를 정의한다. -- 변경 4 — **권한/운영 안전 정책**: owner-scoped authorization, disabled portfolio, daemon-managed portfolio warning/confirmation metadata, negative input policy, reversal policy를 정의한다. -- 변경 5 — **기존 domain/CLI와 v1 API 정합화**: 현행 `apply_invest`, `apply_transfer`, `apply_buy`, `apply_sell` 흐름을 재사용/분리할지 결정하고, broker/service-token endpoint와 충돌하지 않도록 source를 분리한다. -- 변경 6 — **기존 broker trade endpoint 보존**: `POST /v1/portfolios/{portfolio_id}/ledger/trades`는 기존 trex-core/daemon 체결 보고 계약으로 유지하고, manual endpoint와 auth/source/idempotency 의미를 분리한다. - -## 영향 범위 -- 코드 영향: - - `app/api/v1_portfolios.py` 또는 신규 v1 router — manual ledger write/read endpoint - - `app/schemas/v1.py` — request/response schemas - - `app/domain/services.py` — manual ledger accounting helpers reuse/extract - - `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — transaction, row lock, ledger append, history read 확장 - - `app/auth/**` 또는 auth dependency — owner-scoped user/session permission guard - - tests — API, repository, auth negative, transaction, regression -- 문서 영향: - - `docs/interface-contract.md` - - `docs/runtime-flows.md` - - `docs/data-model.md` - - 후속 SRS/Batch 문서 -- 운영 영향: - - 사용자가 수동으로 portfolio source-of-truth를 변경할 수 있으므로 audit/권한/정정 정책이 중요하다. - - daemon-managed portfolio는 자동 체결과 수동 보정이 혼재할 수 있어 warning/confirmation evidence가 필요하다. - -# 5. 요구사항 목록 -## 기능 요구사항 -- FR-1: user/session token 사용자는 자기 소유 portfolio에 manual ledger entry를 append할 수 있어야 한다. 타 사용자 portfolio는 HTTP 403 또는 동등한 fail-closed 응답이어야 한다. -- FR-1a: 기존 `POST /v1/portfolios/{portfolio_id}/ledger/trades`는 daemon/service-token broker trade write 전용으로 유지한다. manual user write를 이 endpoint에 추가 허용하지 않는다. -- FR-1b: 신규 manual endpoint를 추가한다. 기본 방향은 `POST /v1/portfolios/{portfolio_id}/ledger/manual` 단일 action-dispatch endpoint이며, request body에 `action` enum을 포함한다. -- FR-2: `INVEST` action은 외부자금에서 `profit_buffer`로 유입된 것으로 기록한다. 효과: `profit_buffer += amount`, `cash_balance`/`asset_quantity` 변화 없음. -- FR-3: `BUFFER_OUT` action은 `profit_buffer -> cash_balance` 이동을 기록한다. 효과: `profit_buffer -= amount`, `cash_balance += amount`. -- FR-4: `BUFFER_IN` action은 `cash_balance -> profit_buffer` 이동을 기록한다. 효과: `cash_balance -= amount`, `profit_buffer += amount`. -- FR-5: `BUY` action은 `cash_balance -> asset_quantity` 이동을 기록한다. 효과: `cash_balance -= quantity*price + fee + tax`, `asset_quantity += quantity`, `profit_buffer` 변화 없음. -- FR-6: `SELL` action은 `asset_quantity -> cash_balance` 이동을 기록한다. 효과: `asset_quantity -= quantity`, `cash_balance += quantity*price - fee - tax`, `profit_buffer` 변화 없음. -- FR-7: `WITHDRAW` action은 투자 원금/입금액 회수 성격의 `profit_buffer -> 외부자금` 이동을 기록한다. 효과: `profit_buffer -= amount`, `cash_balance`/`asset_quantity` 변화 없음. 수익률 계산의 순입금액은 `INVEST - WITHDRAW` 기준이므로 `WITHDRAW`는 순입금액 차감에 반영된다. -- FR-7a: `PROFIT` action은 순수 수익 실현 성격의 `profit_buffer -> 외부자금` 이동을 기록한다. 효과는 `profit_buffer -= amount`, `cash_balance`/`asset_quantity` 변화 없음으로 `WITHDRAW`와 유사하지만, 수익률 계산의 순입금액(`INVEST - WITHDRAW`)에는 포함하지 않는다. -- FR-8: `REVERSAL`은 기존 ledger entry를 직접 수정/삭제하지 않고 target entry의 반대 effect를 새 entry로 append한다. target reference 필드/notes 정책은 REQ에서 확정한다. -- FR-9: `QUANTITY_ADJUSTMENT`는 1차 scope에서 제외한다. split/오입력 수량 보정은 후속 Discovery/REQ 후보로 남긴다. -- FR-10: manual ledger write 응답은 변경 후 snapshot을 포함해야 한다: `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`. `total_value_after`는 ledger entry 기록 시점의 portfolio-hub snapshot 값이며 실시간 mark-to-market 값으로 해석하지 않는다. -- FR-11: manual ledger history 조회 API는 portfolio별 최근 ledger entries를 반환해야 한다. action, amount/quantity/price/fee/tax, note, recorded_at, after-snapshot(`cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`)을 trex-web이 표시할 수 있어야 한다. -- FR-11a: 수익률 응답/표시를 위해 `cumulative_profit_withdrawn`을 별도 지표로 노출한다. 이 값은 `PROFIT`으로 인출한 순수 수익 실현 누적액이며, 순입금액(`sum(INVEST) - sum(WITHDRAW)`) 계산에는 포함하지 않는다. -- FR-12: disabled portfolio write는 현행 state rule과 일관되게 거부한다. -- FR-13: daemon-managed portfolio에 manual ledger write 시 완전 차단하지 않는다. 대신 API-level explicit confirmation을 요구한다. `managed_by_daemon_id IS NOT NULL`이고 confirmation flag가 없으면 HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION`을 반환한다. trex-web은 강한 경고를 표시한 뒤 사용자 명시 확인 시 confirmation flag를 포함해 재요청한다. 허용된 write는 actor, managed_by_daemon_id, confirmation evidence를 ledger/audit metadata에 남긴다. -- FR-14: manual endpoint는 1차 범위에서 새 음수 상태를 만드는 write를 거부한다. `BUY`는 `quantity * price + fee + tax > cash_balance`이면 HTTP 409, `SELL`은 `quantity > asset_quantity`이면 HTTP 409, `BUFFER_OUT`/`WITHDRAW`/`PROFIT`는 `amount > profit_buffer`이면 HTTP 409, `BUFFER_IN`은 `amount > cash_balance`이면 HTTP 409로 거부한다. `INVEST`는 `amount > 0`이면 허용한다. - -## 비기능 요구사항 -- NFR-1: ledger append와 portfolio snapshot update는 단일 DB 트랜잭션으로 처리되어야 한다. -- NFR-2: 같은 portfolio에 대한 동시 manual ledger write는 lost update가 없어야 한다(row lock 또는 동등한 concurrency control). -- NFR-3: action/effect 계산은 기존 portfolio-hub domain/CLI 흐름과 일관되어야 한다. -- NFR-4: manual user-token write와 daemon/service-token broker write는 source/permission/path 의미가 분리되어야 한다. -- NFR-4a: HTTP endpoint는 분리하되, ledger append + snapshot update의 action/effect 계산은 shared domain helper 또는 동일 test vectors로 중복/divergence를 방지해야 한다. -- NFR-5: 모든 write request는 audit 가능한 note/source/requester 정보를 남길 수 있어야 한다. -- NFR-6: 기존 `GET /v1/portfolios`, daemon ledger trade write, CLI portfolio flows에 회귀가 없어야 한다. - -## 범위 경계 -- In Scope: - - owner-scoped manual ledger write API 계약 - - manual ledger history/read API 계약 - - action/effect: `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `BUY`, `SELL`, `WITHDRAW`, `PROFIT`, `REVERSAL` - - after-snapshot response - - owner/disabled/daemon-managed policy - - baseline docs and SRS follow-up -- Out of Scope: - - trex-web UI 구현 - - 실계좌 reconcile - - 브로커 주문 실행 - - strategy_sets 설정 UI - - 복잡한 세금/회계 모델 - - `QUANTITY_ADJUSTMENT` 1차 구현 - - 기존 daemon/service-token broker ledger endpoint의 의미 변경 - - 기존 `POST /v1/portfolios/{portfolio_id}/ledger/trades`에 user/session-token manual write 권한을 추가하는 것 - -# 6. 리스크/가정 목록 -## 리스크 -- R-1 (High): owner-scope 누락으로 타 사용자 portfolio 장부 변경 가능. - - 완화: API guard와 negative tests를 acceptance criteria에 포함한다. -- R-2 (High): ledger row와 snapshot update가 분리되어 장부/상태 불일치 발생. - - 완화: 단일 transaction + row lock + rollback tests. -- R-3 (Mitigated): manual write와 daemon write가 같은 portfolio에서 혼재되어 운영자가 상태를 오해. - - 완화: source 분리, daemon-managed API-level explicit confirmation, history 표시. confirmation 없는 요청은 HTTP 409로 거부한다. -- R-4 (Medium): 기존 CLI/domain 흐름과 신규 API 회계 공식이 divergence. - - 완화: shared domain helper 추출 또는 동일 test vectors로 검증. manual API는 1차에서 부족 cash/quantity/buffer를 거부하므로 기존 CLI/domain validation 정책과 의도적 차이가 있다면 REQ에서 명시한다. -- R-5 (Mitigated): `REVERSAL`이 target entry 참조 없이 notes 기반으로만 남아 audit성이 약해질 수 있음. - - 완화: 사용자 결정(2026-07-14)에 따라 `target_ledger_entry_id`를 필수로 한다. REQ에서는 컬럼/metadata 저장 위치와 중복 reversal 방지 규칙을 구체화한다. -- R-6 (Low): `PROFIT` legacy action과 `BUFFER_IN`/`WITHDRAW` 의미가 사용자 UI에서 혼동. - - 완화: 사용자 정정에 따라 `PROFIT`은 legacy alias가 아니라 신규 user-facing API의 필수 action으로 포함한다. `WITHDRAW`와 snapshot effect는 유사하지만 성과/수익률 계산 classification이 다름을 문서화한다. - -## 가정 -- A-1: `profit_buffer`는 투자 대기/보관 buffer 역할로 유지한다. -- A-2: `cash_balance`는 전략 수행/매수 가능 현금 역할이다. -- A-3: trex-web은 user/session token으로 owner portfolio만 수정한다. -- A-4: `QUANTITY_ADJUSTMENT`는 1차에서 제외한다. -- A-5: portfolio-hub current domain functions can be reused or refactored without changing existing CLI behavior. - -## 오픈 질문 -- OQ-1: 신규 manual write endpoint shape은 action-dispatch 단일 endpoint인가, action별 endpoint인가? - - 상태: Answered - - 결정: 기존 `POST /v1/portfolios/{portfolio_id}/ledger/trades`는 유지하고, 신규 manual endpoint를 추가한다. 기본 shape은 `POST /v1/portfolios/{portfolio_id}/ledger/manual` + `action` enum 단일 action-dispatch endpoint로 한다. - - 근거: `/ledger/trades`는 trex-core/daemon/service-token broker 체결 보고이며, manual write는 user/session-token owner-scoped operation이다. auth/source/idempotency/audit 의미가 달라 HTTP endpoint는 분리한다. 단, 내부 회계 helper는 공유한다. - - 종료 조건: 완료 -- OQ-2: `REVERSAL`은 target ledger entry id를 필수로 둘 것인가? - - 상태: Answered - - 결정: `target_ledger_entry_id` 필수. REVERSAL은 기존 ledger row를 직접 수정/삭제하지 않고, 지정된 target entry의 반대 effect를 새 entry로 append한다. - - 근거: 감사 추적, 중복 reversal 방지, 서버 측 정확한 반대 effect 계산을 위해 target reference가 필요하다. - - REQ 세부화 항목: target이 같은 portfolio 소속인지 검증, 이미 reversed 된 entry의 재 reversal 금지 여부, REVERSAL 자체 reversal 허용 여부, target reference 저장 위치(정식 컬럼 vs metadata)를 확정한다. - - 종료 조건: 완료 -- OQ-3: manual `BUY`/`SELL`에서 잔고/수량 부족을 거부할 것인가, 표시 음수 허용 정책과 맞춰 결과 음수를 허용할 것인가? - - 상태: Answered - - 결정: manual endpoint 1차에서는 부족 cash/quantity/buffer를 거부한다. `BUY`는 required cash(`quantity * price + fee + tax`)가 `cash_balance`를 초과하면 HTTP 409, `SELL`은 requested quantity가 `asset_quantity`를 초과하면 HTTP 409, `BUFFER_OUT`/`WITHDRAW`/`PROFIT`는 amount가 `profit_buffer`를 초과하면 HTTP 409, `BUFFER_IN`은 amount가 `cash_balance`를 초과하면 HTTP 409로 거부한다. `INVEST`는 amount > 0이면 허용한다. - - 사용자 정책 정합: trex-web UI 입력 음수 금지, 기존/결과 표시 음수 허용. 즉 read/display에서는 기존 음수 상태를 숨기지 않지만, manual write가 새 음수 상태를 만드는 것은 1차에서 금지한다. - - 근거: 수동 입력은 사전 검증 가능한 operation이고, `QUANTITY_ADJUSTMENT`를 1차 제외했으므로 부족 수량 SELL 허용은 우회 보정 기능이 될 수 있다. - - 종료 조건: 완료 -- OQ-4: daemon-managed portfolio write 시 API가 `warning_required`/`confirmation_token` 같은 기계적 확인 필드를 요구할 것인가, 아니면 trex-web UI 경고만으로 충분한가? - - 상태: Answered - - 결정: API-level explicit confirmation을 강제한다. daemon-managed portfolio(`managed_by_daemon_id IS NOT NULL`)에 confirmation 없는 manual write가 들어오면 HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION`을 반환한다. 사용자가 trex-web 경고를 확인한 뒤 confirmation flag를 포함해 재요청하면 허용한다. - - audit: 허용된 write는 actor_user_id, managed_by_daemon_id, confirmation reason/evidence를 ledger/audit metadata에 남긴다. - - 근거: UI 경고만으로는 API 직접 호출/우회/버그를 막을 수 없고, 자동/daemon 흐름과 수동 보정이 섞일 때 서버 측 감사 근거가 필요하다. - - REQ 세부화 항목: confirmation flag 이름, 409 error body, confirmation evidence 저장 위치(metadata JSONB vs note/context)를 확정한다. - - 종료 조건: 완료 -- OQ-5: history response에 `total_value_after`를 포함할 것인가? - - 상태: Answered - - 결정: history response와 manual write after-snapshot에 `total_value_after`를 포함한다. `total_value_after`는 ledger entry 기록 시점의 portfolio-hub snapshot 값이며 실시간 mark-to-market 값으로 해석하지 않는다. - - 추가 결정: 수익률 응답/표시에서 `cumulative_profit_withdrawn`을 별도 지표로 노출한다. `cumulative_profit_withdrawn`은 `PROFIT` action 누적액이며 순입금액(`sum(INVEST) - sum(WITHDRAW)`) 계산에는 포함하지 않는다. - - 근거: 장부 이력에서 entry 이후 portfolio 상태를 한 번에 이해할 수 있고, `PROFIT`은 수익률 분모와 분리된 실현 수익 지표로 보여야 한다. - - 종료 조건: 완료 -- OQ-6: legacy `PROFIT` action은 신규 API에서 노출하지 않고 `BUFFER_IN`/`WITHDRAW`/`INVEST`로 대체할 것인가? - - 상태: Answered - - 결정: `PROFIT`은 legacy alias가 아니라 신규 manual API의 필수 user-facing action으로 포함한다. `WITHDRAW`와 `PROFIT`은 둘 다 `profit_buffer -> 외부자금` 방향으로 buffer를 감소시키지만, 의미와 수익률 계산 반영이 다르다. - - 수익률 계산 의미: 입금 기준 금액은 `sum(INVEST) - sum(WITHDRAW)`로 계산한다. `PROFIT`으로 꺼낸 금액은 순수 수익 실현 인출이므로 이 입금 기준 계산에 포함하지 않는다. - - 근거: portfolio 성과 계산에서 원금 회수(`WITHDRAW`)와 수익 실현(`PROFIT`)은 결과 snapshot effect가 유사해도 완전히 다른 회계/성과 분류다. - - 종료 조건: 완료 - -# 7. 초기 성공 기준 -- S-1: Given owner user token, When `INVEST` manual write, Then `profit_buffer_after`가 amount만큼 증가하고 ledger row가 append된다. -- S-2: Given owner user token, When `BUFFER_OUT`/`BUFFER_IN`, Then `profit_buffer`와 `cash_balance`가 기대값으로 이동하고 ledger row가 append된다. -- S-3: Given owner user token, When `BUY`/`SELL`, Then `cash_balance`와 `asset_quantity`가 기대값으로 갱신되고 ledger row가 append된다. -- S-4: Given owner user token, When `WITHDRAW`, Then `profit_buffer_after`가 감소하고 ledger row가 append되며 순입금액 계산에서 `WITHDRAW`가 차감된다. -- S-4a: Given owner user token, When `PROFIT`, Then `profit_buffer_after`가 감소하고 ledger row가 append되며 순입금액 계산에는 반영되지 않는다. -- S-5: Given non-owner user token, When manual write/read, Then 403 또는 동등한 fail-closed 결과. -- S-6: Given disabled portfolio, When manual write, Then 409 또는 현행 state rule과 일관된 거부. -- S-7: Given daemon-managed portfolio, When manual write without/with required confirmation policy, Then REQ에서 정한 경고/확인 contract가 검증된다. -- S-8: Given existing daemon/service-token ledger trade write tests, When full test suite, Then regression 없음. -- S-9: Given ledger history request, When entries exist, Then trex-web 표시 필드(action, amount/quantity/price/fee/tax, note, recorded_at, after-snapshot including `total_value_after`)가 반환된다. -- S-10: Given return/summary response, When `PROFIT` entries exist, Then `cumulative_profit_withdrawn`이 별도 지표로 반환되고 순입금액(`INVEST - WITHDRAW`) 계산에는 포함되지 않는다. - -## 측정 방식 및 데이터 출처 -- portfolio-hub API tests: owner/non-owner/disabled/daemon-managed/action effect/history -- repository tests: transaction, row lock/lost update, after-snapshot consistency -- contract tests or snapshots for trex-web consumption shape -- existing test suite: `uv run pytest` or project-standard test command after REQ/batch -- baseline doc diff: interface-contract/runtime-flows/data-model updates - -# 8. REQ로 넘기기 전 확인 체크 -- [x] API endpoint shape 확정(OQ-1) — 기존 `/ledger/trades` 유지 + 신규 `POST /v1/portfolios/{portfolio_id}/ledger/manual` 추가 -- [x] REVERSAL target/reference policy 확정(OQ-2) — `target_ledger_entry_id` 필수 -- [x] 부족 잔고/수량 validation policy 확정(OQ-3) — manual write가 새 음수 상태를 만드는 것은 1차 거부 -- [x] daemon-managed confirmation contract 확정(OQ-4) — API-level explicit confirmation 강제 -- [x] history response fields 확정(OQ-5) — `total_value_after` 포함, `cumulative_profit_withdrawn` 별도 지표 -- [x] legacy `PROFIT` mapping policy 확정(OQ-6) — `PROFIT`은 신규 manual API 필수 action -- [x] Scope owner: `portfolio-hub-dev` primary, `trex-web/service-dev` consumer, `qc-reviewer` verification -- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 — Justin Ko - -# 9. 파일 처리 결과 -- 처리 결과: 생성 -- 생성 경로: `docs/discovery/dcy-012_20260714_manual-ledger-write-api-owner-scoped.md` -- 참조 문서: - - trex-web `docs/discovery/dcy-011_20260714_portfolio-manual-ledger-balance-quantity.md` - - portfolio-hub `docs/discovery/dcy-006_20260605_ledger-trade-write-portfolio-state-return.md` - - portfolio-hub `docs/data-model.md` - - portfolio-hub `app/domain/services.py` - - portfolio-hub `app/repositories/postgres.py` - - portfolio-hub `app/cli/commands/portfolio.py` - - portfolio-hub `README.md` - -# 10. 사용자 결정 필요 항목 요약 -## DECIDE -- ~~API shape: 단일 action-dispatch endpoint vs action별 endpoint~~ → 기존 `/ledger/trades` 유지, 신규 `POST /v1/portfolios/{portfolio_id}/ledger/manual` + `action` enum 추가 -- ~~REVERSAL target ledger id/reference 필수 여부~~ → `target_ledger_entry_id` 필수 -- ~~부족 cash/quantity 시 validation 거부 vs 결과 음수 허용~~ → manual write는 부족 cash/quantity/buffer 409 거부, read/display 음수 표시는 허용 -- ~~daemon-managed portfolio warning/confirmation을 API-level로 강제할지 UI-level로 둘지~~ → API-level explicit confirmation 강제 -- ~~history response에 `total_value_after` 포함 여부~~ → 포함. `cumulative_profit_withdrawn`도 별도 지표로 노출 -- ~~legacy `PROFIT` action 노출/매핑 정책~~ → `PROFIT`은 신규 manual API 필수 action, 순입금액(`INVEST - WITHDRAW`) 계산에는 미포함 - -## CONFIRM -- 이 Discovery는 trex-web UI 구현이 아니라 portfolio-hub API 계약/권한/트랜잭션 Discovery임을 확인 -- `QUANTITY_ADJUSTMENT`는 1차 제외하고 후속으로 분리한다는 trex-web dcy-011 결정을 유지 - -## DATA -- 현행 auth dependencies 중 session/user-token owner-scoped write에 가장 적합한 guard -- portfolio_ledger에 reversal target reference를 담을 별도 컬럼이 필요한지 여부 — OQ-2는 target id 필수로 결정, 저장 위치는 REQ에서 세부화 -- existing tests coverage for CLI invest/transfer/buy/sell flows - -# 11. Discovery Freeze -- Handoff Decision: approved-for-req -- Handoff Rationale: trex-web dcy-011의 backend-first 후속 Discovery로 생성했다. 현행 portfolio-hub 구조와 action/effect는 확인했고, 기존 `/ledger/trades` 유지 + 신규 `/ledger/manual` 추가 방향을 확정했다. OQ-1~OQ-6은 모두 Answered이며, owner-scoped manual ledger API REQ 초안 작성으로 넘긴다. -- Ready for REQ Drafting: true -- Confirmed By: Justin Ko -- Confirmed At (KST): 2026-07-14 16:35 KST diff --git a/docs/discovery/index.md b/docs/discovery/index.md deleted file mode 100644 index 2133c99..0000000 --- a/docs/discovery/index.md +++ /dev/null @@ -1,20 +0,0 @@ -# Discovery 전체 인덱스 - - - -## Discovery 목록 - -| ID | 날짜 | 이슈명 | 문서 | 상태 | -|---|---|---|---|---| -| dcy-001_20260519_asset-catalog-and-user-portfolio-model | 20260519 | 자산 관리 구조 재설계 — admin 관리 asset catalog와 user portfolio 분리 모델 도입 | [Discovery](./dcy-001_20260519_asset-catalog-and-user-portfolio-model.md) | confirmed | -| dcy-002_20260519_tag-ownership-and-applicability-policy | 20260519 | 태그 소유권 및 적용 대상 정책 — admin/user 구분 태그 모델과 asset·portfolio 적용 정책 도입 | [Discovery](./dcy-002_20260519_tag-ownership-and-applicability-policy.md) | confirmed | -| dcy-003_20260520_user-portfolio-mutation-v1-api-migration | 20260520 | User Portfolio Mutation v1 API 이전 및 path 명명 분리 (/v1/portfolios* 신설) | [Discovery](./dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md) | confirmed | -| dcy-004_20260520_token-issuance-and-lifecycle-overhaul | 20260520 | 토큰 발급 및 관리 전면 정비 — client token 폐지, service-token rotate 정상화, 발급 권한 매트릭스, 만료·회전·자동 revoke 정책 | [Discovery](./dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md) | confirmed | -| dcy-005_20260601_rel-001-feedback-cleanup | 20260601 | REL-001 Feedback 정합화 — ledger_trades.asset_id 명명 / session no_expiry enforcement / health db_enabled | [Discovery](./dcy-005_20260601_rel-001-feedback-cleanup.md) | confirmed | -| dcy-006_20260605_ledger-trade-write-portfolio-state-return | 20260605 | ledger trade write 회계 책임 단일화 — 응답에 portfolio 상태 포함 + portfolio_ledger/ledger_trades 테이블 통합 (issue #8) | [Discovery](./dcy-006_20260605_ledger-trade-write-portfolio-state-return.md) | confirmed | -| dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api | 20260616 | 일일 거래내역 + 실현 PnL 조회 API (운영 리포트/프론트 데이터 소스, issue #11) | [Discovery](./dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md) | confirmed | -| dcy-008_20260625_current-holding-wac-cost-basis-read | 20260625 | 현재 보유 종목별 평단(WAC) 조회 API — trex-core 중장기 cold-start 슬롯 평단 seed 의존(교차 발원 trex-core dcy-034 FR-7). GET /v1/portfolios/{id}/holdings/cost-basis | [Discovery](./dcy-008_20260625_current-holding-wac-cost-basis-read.md) | confirmed | -| dcy-009_20260627_user-read-asset-catalog-and-daemons | 20260627 | 사용자용 read 2종 — GET /v1/assets require_read 완화 + 사용자 가용 데몬 목록 신규(교차 발원 trex-web 포트폴리오 관리 dcy-007) | [Discovery](./dcy-009_20260627_user-read-asset-catalog-and-daemons.md) | confirmed | -| dcy-010_20260627_daemon-scoped-broker-credential-provisioning | 20260627 | daemon-scoped 브로커(KIS) 인증정보 소유·전달 — 다중 인스턴스 trex-core가 daemon별 다른 계좌로 거래 (교차 발원 trex-core 중장기 멀티 인스턴스) | [Discovery](./dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md) | confirmed | -| dcy-011_20260628_daemon-crud-and-self-service-credential-api | 20260628 | 데몬 admin CRUD API + 사용자 self-service 브로커 인증정보 관리 API — 소유 스코프 write/rotate/메타데이터(복호화 secret 미반환), 데몬 생성·할당·(비)활성화는 admin (교차 발원 trex-web dcy-009) | [Discovery](./dcy-011_20260628_daemon-crud-and-self-service-credential-api.md) | confirmed | -| dcy-012_20260714_manual-ledger-write-api-owner-scoped | 20260714 | 사용자 수동 장부 입력 API — INVEST/BUFFER/BUY/SELL/WITHDRAW/PROFIT 계약 및 owner-scoped ledger write | [Discovery](./dcy-012_20260714_manual-ledger-write-api-owner-scoped.md) | confirmed | diff --git a/docs/hotfixes/hotfix-001_20260522_service-token-plaintext-masking.md b/docs/hotfixes/hotfix-001_20260522_service-token-plaintext-masking.md deleted file mode 100644 index 94878fe..0000000 --- a/docs/hotfixes/hotfix-001_20260522_service-token-plaintext-masking.md +++ /dev/null @@ -1,67 +0,0 @@ -# HOTFIX-001: service-token issue/rotate 평문 토큰 마스킹 결함 - -- Status: verified (배포 대기) -- Date: 2026-05-22 -- Severity: High -- Area: CLI / 인증 (`pfhub service-token`) -- Source: portfolio-hub 운영팀 이슈티켓 (대상 버전 0.1.0, commit `58899b1`) -- Path: Hotfix Exception Path (CLAUDE.md — Requirements/Delivery 경량화, 4개 필수 항목 충족) - -## 1. 최소 요구사항 정의 - -### 증상 -- `pfhub service-token issue` / `rotate` 가 새 토큰을 발급하지만 출력 시 평문 토큰을 항상 마스킹(`ySusw9****Wa3w`)한다. -- `--format json` 을 지정해도 마스킹되며, 마스킹 해제 옵션이 없다. -- 결과적으로 새로 발급된 토큰의 평문을 확인할 방법이 전혀 없다. - -### 원인 -- `app/cli/core/output.py` `render_service_token_result()` 가 출력 포맷 분기 이전에 무조건 `_mask_payload()` 로 `token` 필드를 마스킹. -- `_mask_payload()` 는 `token` 키를 가진 응답에만 동작 → `token` 필드를 가진 응답은 `issue`/`rotate` 둘뿐 → 마스킹이 *토큰이 반드시 필요한 두 명령에서만* 작동. -- `--format` 분기보다 마스킹이 먼저 일어나 `table`/`json` 모두 마스킹됨. - -### 영향 -- 신규 토큰 사용 불가: 발급/회전 후 평문을 알 수 없어 클라이언트·데몬에 설정 불가. -- 데이터 소실(복구 불가): 토큰은 `app/core/security.py:45` `hash_token()`(SHA-256) + `token_prefix` 만 DB 저장. 평문은 명령 실행 중 메모리에만 존재. -- 운영 리스크: `rotate` 는 기존 토큰을 폐기하고 새 토큰을 발급 → 이 버그 상황에서 회전 시 *이전 토큰 무효화 + 새 토큰 미상* 상태. -- 명세 정합성: `docs/interface-contract.md` L83/L494 는 "토큰은 발급 시 1회 노출, 이후 마스킹"을 이미 명시 — 현 구현이 baseline 계약을 위반. **명세 결함이 아닌 구현 버그.** - -### 성공 조건 -- 결정된 노출 방식: **기본 1회 노출** (운영팀 제안 채택, 2026-05-22 확정). -- SC-1: `pfhub --format json service-token issue …` → JSON `token` 필드에 평문 토큰이 그대로 포함된다 (마스킹 문자 `*` 없음). -- SC-2: `pfhub --format json service-token rotate …` → 동일하게 평문 `token` 포함. -- SC-3: `table` 출력(`issue`/`rotate`) → 평문 토큰 1회 출력 + "이 토큰은 다시 표시되지 않습니다" 경고 표기. -- SC-4: `masked_token:` 보조 표시는 식별용으로 유지(table 경로). `mask_token_text()` 자체는 보존. -- SC-5: `list`/`rebind`/`revoke` 출력은 회귀 없이 기존 동작 유지(이들 응답에는 `token` 필드 없음). - -## 2. 최소 검증 - -- 재현/회귀 테스트: - - `tests/test_cli.py::test_cli_service_token_issue_supports_json` (확장) — JSON `token` 필드가 평문임을 단언 (`"*" not in token` + `token.startswith(stored.token_prefix)`) (SC-1). ✅ - - `tests/test_cli.py::test_render_service_token_result_does_not_mask_token` (신규 단위) — `render_service_token_result()`가 `token`을 평문으로 렌더링 (json/table 양쪽, 출력에 `*` 부재). rotate 경로는 동일 함수 호출이므로 본 단위 테스트가 SC-2 커버. ✅ - - `tests/test_cli.py::test_cli_service_token_issue_table_shows_reveal_warning` (신규) — table 출력에 token_prefix + "다시 표시되지 않습니다" 경고 문구 (SC-3). ✅ - - 회귀: 전체 pytest suite — `list`/`rebind`/`revoke` 테스트 무변동 (SC-5). ✅ -- 결과: - - `.venv/bin/python -m pytest tests/test_cli.py -k "service_token or render_service_token"` → **15 passed** - - `.venv/bin/python -m pytest` (전체) → **371 passed in 11.60s** (BAT-013 직후 369 baseline 대비 +2 신규, 회귀 0) -- 적용 변경: - - `app/cli/core/output.py` — `_mask_payload()` 함수 제거(발급 경로 외 사용처 없음), `render_service_token_result()`가 마스킹 없이 렌더링. `mask_token_text()`는 보존(`masked_token:` 보조 표시에서 계속 사용). - - `app/cli/commands/service_tokens.py` — `issue`/`rotate` non-json 출력에 "이 토큰은 다시 표시되지 않습니다" 1회 노출 경고 추가. - -## 3. 배포 전 롤백 계획 - -- 변경 범위: `app/cli/core/output.py`, `app/cli/commands/service_tokens.py` 2개 파일. DB 스키마·마이그레이션 변경 없음, HTTP API 변경 없음. -- 롤백 방식: 코드 전용 변경이므로 직전 이미지/커밋(`58899b1`)으로 재배포하면 즉시 원복. 데이터 마이그레이션 없음 → 롤백 시 데이터 리스크 없음. -- 롤백 트리거: 배포 후 `service-token` 계열 명령 비정상 동작, CLI 기동 실패. -- 주의: 롤백은 *버그 동작으로 복귀*를 의미하므로, 롤백 시 `issue`/`rotate` 사용 보류 권고가 재적용된다. - -## 4. 배포 후 Postmortem - -- (배포 후 작성 — 운영팀) -- 환류 항목 후보: - - 발급 명령 출력에 대한 자동 회귀 부재 → 본 hotfix로 추가됨. 다른 secret-노출 경로(login 토큰 등) 동일 패턴 점검 후속. - - `interface-contract.md` baseline 계약과 구현 간 drift 감지 수단 부재 → 후속 Discovery 후보. - - 이미 회전되어 평문을 잃은 운영 `admin` 토큰(`540e005a…`)은 본 hotfix 배포 후 재회전 필요. - -## Change Log -- 2026-05-22: HOTFIX-001 착수. 운영팀 이슈티켓 접수 → 코드 검증 → Hotfix Exception Path 채택 → 노출 방식 "기본 1회 노출" 확정. -- 2026-05-22: 코드 수정 + 회귀 테스트 3건 반영, 전체 371 passed. Status → verified (배포 대기). 배포는 운영팀이 수행하며 Postmortem은 배포 후 작성. diff --git a/docs/issues/20260524_portfolio-hub-ticket-symbol-in-portfolios.md b/docs/issues/20260524_portfolio-hub-ticket-symbol-in-portfolios.md deleted file mode 100644 index bfd5355..0000000 --- a/docs/issues/20260524_portfolio-hub-ticket-symbol-in-portfolios.md +++ /dev/null @@ -1,119 +0,0 @@ -# [REQ] `GET /v1/portfolios` 응답에 종목 코드(`symbol`) 포함 - -## 요약 - -`GET /v1/portfolios` 응답의 `PortfolioItem` 에 종목 코드(`symbol`) 필드를 추가해주세요. 현재는 `asset_id` (UUID) 만 반환되어, 종목 코드가 필요한 클라이언트가 별도 카탈로그 조회로 매핑해야 합니다. - -## 배경 - -trex-orbit 자동매매 데몬은 매 사이클마다 `GET /v1/portfolios` 로 daemon-scoped portfolio 리스트를 받아 운용 대상 종목을 결정합니다. 이후 외부 데이터 서비스 (`kis-data-hub`) 에 시세 / 분봉을 요청하려면 종목 코드 (예: `"005930"`) 가 필요한데, 현재 `PortfolioItem` 에는 `asset_id` (UUID) 만 있고 `symbol` 이 빠져 있어 다음 우회가 필요합니다. - -1. trex-orbit 부팅 시 asset 카탈로그를 별도로 조회해 `asset_id → symbol` 캐시 구성 -2. 사이클 중 `asset_id` 마다 캐시 lookup -3. 캐시 미스 시 portfolio-hub 에 추가 조회 - -이 과정에서 캐시 stale / 신규 asset 등록 후 동기화 지연 / 클라이언트마다 캐시 로직 중복 같은 운영 부담이 발생합니다. - -## 현재 상태 - -**Endpoint**: `GET /v1/portfolios` ([`app/api/v1_portfolios.py:224`](app/api/v1_portfolios.py#L224)) - -**응답 스키마**: [`PortfolioItem`](app/schemas/v1.py#L165) - -```python -class PortfolioItem(BaseModel): - portfolio_id: str - asset_id: str | None = None # UUID - owner_user_id: str | None = None - broker: str - status: str - name: str - cash_balance: float = 0.0 - asset_quantity: float = 0.0 - price: float = 0.0 - profit_buffer: float = 0.0 - tag_ids: list[str] = Field(default_factory=list) - managed_by_daemon_id: str | None = None - created_at: datetime | None = None - updated_at: datetime | None = None - # symbol 없음 -``` - -종목 코드는 별도 `AssetItem` 에만 존재 ([`app/schemas/v1.py:55`](app/schemas/v1.py#L55)): - -```python -class AssetItem(BaseModel): - asset_id: str - symbol: str # ← 여기 있음 - status: str - ... -``` - -## 요청 변경 - -### 1. 스키마 - -`PortfolioItem` 에 `symbol` 필드 추가: - -```python -class PortfolioItem(BaseModel): - portfolio_id: str - asset_id: str | None = None - symbol: str | None = None # ← 추가 요청 - owner_user_id: str | None = None - ... -``` - -### 2. 응답 채움 로직 - -`GET /v1/portfolios` 의 `_portfolio_to_item` 빌더가 `portfolios.asset_id → assets.symbol` join 결과를 활용해 `symbol` 을 채워주세요. 이미 repository 단계에서 join 되어 있다면 변환 로직만 추가하면 됩니다. - -### 3. 단건 / 관련 엔드포인트도 동일 적용 - -다음 엔드포인트의 응답에도 `symbol` 포함: - -- `POST /v1/portfolios` (생성 후 반환) -- `PATCH /v1/portfolios/{portfolio_id}` -- `DELETE /v1/portfolios/{portfolio_id}` (soft delete 응답) -- 기타 `PortfolioItem` 을 반환하는 모든 경로 - -## 정당성 - -1. **클라이언트 단순화**: daemon 클라이언트 (trex-orbit 등) 가 portfolio 정보만 받으면 즉시 외부 시세 API 호출 가능. 추가 카탈로그 조회 / 캐시 관리 불필요. -2. **일관성**: `cash_balance`, `asset_quantity`, `price` 등 자산 관련 필드는 이미 PortfolioItem 에 포함됨. `symbol` 도 자산 식별의 핵심이므로 같이 포함되는 것이 자연스러움. -3. **stale cache 위험 제거**: 클라이언트 캐시 + miss fallback 패턴은 카탈로그 변경 시 일시적 불일치 가능. server 측 join 으로 항상 최신 매핑 제공. -4. **운영 부담 감소**: 캐시 무효화 / 갱신 / 재로드 로직이 클라이언트마다 중복 구현되는 것을 회피. - -## 호환성 - -`symbol` 은 신규 필드이며 `Optional[str]` 로 추가하면 기존 클라이언트에 영향 없음 (additive change). - -## 영향 범위 - -- [`app/api/v1_portfolios.py`](app/api/v1_portfolios.py) — `_portfolio_to_item` 에 symbol 채움 -- [`app/schemas/v1.py`](app/schemas/v1.py#L165) — `PortfolioItem` 에 `symbol` 필드 추가 -- [`app/repositories/`](app/repositories/) — `list_portfolios` / `get_portfolio` 가 asset join 결과를 들고 오도록 보강 (이미 그렇다면 변경 없음) -- [`docs/interface-contract.md`](docs/interface-contract.md#L321) — `GET /v1/portfolios` 응답 예시 갱신 -- 테스트 — `PortfolioItem` 응답 형식 검증 케이스 추가 - -## 검수 기준 - -1. `GET /v1/portfolios` 호출 시 각 item 에 `symbol` 필드가 포함됨 -2. `symbol` 값은 해당 `asset_id` 의 `AssetItem.symbol` 과 일치 (server side join) -3. 단건 조회 / 생성 / 수정 / 삭제 응답도 동일하게 `symbol` 포함 -4. `interface-contract.md` 의 `GET /v1/portfolios` 응답 예시에 `symbol` 명시 -5. 기존 테스트 회귀 없음 + 신규 케이스 추가 - -## 우선순위 - -**Medium** — trex-orbit 사이드에서 캐시 기반 우회가 가능해 즉시 차단 이슈는 아니지만, 운영 본격 시작 전 정리하면 lifecycle 비용을 크게 줄임. - -## 관련 문서 - -- trex-orbit `temp/plan.md` 8.4.1 step 3 (asset_id → symbol resolve) — 본 이슈의 우회 구현 단계 -- trex-orbit `temp/plan.md` 8.6.4.1 전제 시점 — universe 결정 과정에서 symbol resolve 가 필요한 이유 - -## 작성자 / 일자 - -- 요청자: trex-orbit 팀 (genichin@gmail.com) -- 작성일: 2026-05-24 diff --git a/docs/releases/index.md b/docs/releases/index.md deleted file mode 100644 index 2f040a8..0000000 --- a/docs/releases/index.md +++ /dev/null @@ -1,14 +0,0 @@ -# Release Index - -## 목적 -- 이 문서는 release 단위의 전역 인덱스다. -- 각 release는 `docs/releases/rel-XXX_YYYYMMDD_.md`에서 관리한다. - -## Release Register - -| REL ID | Profile | Status | Included Batch | Date | Doc | -| --- | --- | --- | --- | --- | --- | -| REL-001 | app-service | released | BAT-001~016 (전체 통합) | 2026-05-21 | [rel-001](./rel-001_20260521_v1-api-full-surface.md) | -| REL-002 | app-service | feedback-captured | BAT-017 (dcy-007 M1: 거래내역+실현PnL API) | 2026-06-19 | [rel-002](./rel-002_20260619_trade-history-and-pnl-api.md) | -| REL-003 | app-service | released | BAT-018~022 (dcy-008/009/010/011: WAC조회+데몬목록+daemon-scoped 인증정보+관리API) | 2026-06-30 | [rel-003](./rel-003_20260628_daemon-credential-and-admin-api.md) | -| REL-004 | app-service | draft | BAT-024 (REQ-056), BAT-025 (REQ-057): owner-scoped 수동 장부 API + portfolio_ledger metadata Alembic migration | 2026-07-15 | [rel-004](./rel-004_20260715_manual-ledger-api-and-metadata-migration.md) | diff --git a/docs/releases/rel-001_20260521_v1-api-full-surface.md b/docs/releases/rel-001_20260521_v1-api-full-surface.md deleted file mode 100644 index 8691520..0000000 --- a/docs/releases/rel-001_20260521_v1-api-full-surface.md +++ /dev/null @@ -1,155 +0,0 @@ -# REL-001: v1 API full surface + ledger accounting consolidation - -- Status: released -- Profile: app-service -- Included Batch: BAT-001, BAT-002, BAT-003, BAT-004, BAT-005, BAT-006, BAT-007, BAT-008, BAT-009, BAT-010, BAT-011, BAT-012, BAT-013, BAT-014, BAT-015, BAT-016, REQ-028 CHG-20260601-01 (BAT-009 augmentation, no separate batch) - -> **Scope 통합 (2026-06-05)**: 아직 released 되지 않은 모든 batch(BAT-001~016)를 단일 release로 통합. 이로써 dcy-005 OQ-3(BAT-015용 REL-002 분리)와 dcy-006 A-4(BAT-016용 REL-003 분리) 결정은 **superseded** — 별도 REL-002/REL-003은 생성하지 않고 본 REL-001로 흡수한다. 외부 의존성(trex-basic 신 ledger path + trex-core 신 응답 명세)이 단일 배포에 결합됨에 유의(Rollout Plan 참조). - -## Scope -- Included: - - BAT-001 (REQ-009, REQ-011, REQ-014): assets 테이블 스키마 + portfolios.asset_id FK + data migration - - BAT-002 (REQ-010, REQ-012, REQ-013, REQ-015): admin asset catalog CRUD API + portfolio 생성 CLI catalog 검증 + baseline 갱신 - - BAT-003 (REQ-001, REQ-002, REQ-003, REQ-006): tags 마스터 테이블 + assets/portfolios.tag_ids 컬럼 + data migration - - BAT-004 (REQ-004, REQ-005): 태그 HTTP API (POST/GET /v1/tags, PATCH /v1/tags/{id}/deactivate, POST/DELETE /v1/assets|portfolios/{id}/tags) - - BAT-005 (REQ-007, REQ-008): pfhub tag CLI + baseline 문서 갱신 - - BAT-006 (REQ-016, REQ-017, REQ-018): client token 발급 폐지 + service-token rotate unique index 보강 + POST /auth/service-tokens daemon_id forward - - BAT-007 (REQ-022, REQ-023): daemon deactivation auto-revoke + NULL daemon migration CLI - - BAT-008 (REQ-019, REQ-020, REQ-021, REQ-024): 토큰 surface baseline 문서 갱신 (AuthContext, 권한 매트릭스, TTL 정책, env admin token SOP) - - BAT-009 (REQ-025, REQ-026, REQ-027, REQ-028): POST/PATCH/DELETE/GET /v1/portfolios CRUD 신설 - - BAT-010 (REQ-030, REQ-031, REQ-032): GET/POST/DELETE /v1/portfolios/{id}/tags + POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments + baseline 갱신 - - BAT-011 (REQ-029): POST /v1/portfolios/{id}/ledger/trades 신설 + 구 path POST /v1/assets/{id}/ledger/trades 제거 - - BAT-012 (REQ-002 재정의, REQ-010 augmentation): assets.tag_ids UUID[] 컬럼 + idx_assets_tag_ids_gin + GET /v1/assets admin catalog 재정의(token_type 분기 제거) + AssetCatalogResponse tag_ids/archived 합성. 기존 smoke의 GET /v1/assets 403 불일치를 해소. - - BAT-013 (REQ-005 CHG-20260521-02): `POST/DELETE /v1/assets/{asset_id}/tags` 라우트 SQL 대상 정합화 — `append_asset_tag_id`/`remove_asset_tag_id` 메서드 신규 + 라우트 호출 교체. BAT-012로 도입된 `assets.tag_ids` 컬럼이 비로소 정상 채워지도록 apply/remove 경로 정합화. HTTP contract·DB 스키마 무변. - - REQ-028 CHG-20260601-01 (BAT-009 augmentation): GET /v1/portfolios 응답 schema에 `display_name` 필드 추가. `Portfolio` dataclass + `PortfolioItem` schema + `_SELECT_PORTFOLIO` JOIN `a.display_name` 보강. AC-13 신설, 368 passed 회귀, 로컬 smoke로 응답 노출 확인. 별도 BAT 미할당(BAT-009 verification 재실행 누적). - - BAT-014 (REQ-025/026/027/028/032 재검증): `PortfolioItem.symbol` 응답 계약 명문화 + `GET /v1/portfolios`의 asset join 기반 응답(`portfolios JOIN assets` → `assets.symbol`). trex-orbit 팀 이슈 후속. - - BAT-015 (REQ-033/034/035, dcy-005): `ledger_trades.asset_id` → `portfolio_id` 컬럼명 정합화(마이그레이션 `20260601_0024`) + `LoginRequest` extra="forbid"(session no_expiry 거부) + `GET /health` 응답에 `db_enabled` 필드 추가 + runtime-flows baseline 갱신. - - BAT-016 (REQ-036/037/038, dcy-006): ledger 회계 책임 단일화 — `POST /v1/portfolios/{id}/ledger/trades`가 portfolio cash/quantity를 트랜잭션 갱신하고 응답에 `cash_balance_after`/`asset_quantity_after` 반환. `portfolio_ledger`/`ledger_trades` 단일 원장 통합(마이그레이션 `20260605_0025`, ledger_trades DROP) + 레거시 buy/sell deprecated 표기. idempotency는 hypertable 제약으로 application-level(SELECT-before-insert under FOR UPDATE)로 구현(REQ-036/037 CHG-20260605-03). 실 DB 통합 테스트 포함 376 passed. - - Hotfix (issue #9, 2026-06-06): `GET /v1/user_info` 500 수정 — `broker_credentials` 테이블 부재 시 `list_broker_credentials`가 `UndefinedTable`을 던져 핸들러가 500. (1) 핸들러를 broker credential 조회 실패에 graceful degrade(`brokers: []`, daemon_id/user_id/permission 정상 반환)하도록 보강, (2) `ensure_schema()`가 `broker_credentials` 테이블도 생성(alembic 0014 정합)하도록 추가. trex-core whoami(daemon_id) 도출 복구. 재현 회귀 테스트 추가, 전체 377 passed. 별도 BAT 미할당(운영 hotfix). -- Excluded: - - REQ-013 (Deprecated): GET /v1/assets 토큰 타입별 분기 재정의 — dcy-003 옵션 B 결정으로 무효화 - - trex-basic 내부 코드 변경 (별도 레포 — 본 릴리즈와 동시 배포 필요: 신 ledger path) - - trex-core(slot-core) 내부 코드 변경 (별도 레포 — BAT-016 신 응답 명세 채택 필요. issue #8 comment로 명세 공유됨) - - 레거시 `POST /portfolios/{id}/buy`·`/sell` 실제 제거 (BAT-016은 deprecated 표기만, 코드 제거는 후속 트랙) - -## Rollout Plan -1. **사전 확인**: trex-basic 신 path(`POST /v1/portfolios/{id}/ledger/trades`) 빌드 완료 + **trex-core(slot-core) 신 응답 명세(`cash_balance_after`/`asset_quantity_after`) 채택 빌드 완료** 확인 -2. **DB 마이그레이션**: `alembic upgrade head` — 전 revision 순차 적용 (head = `20260605_0025`) - - BAT-001/003/006/007/012: assets 테이블, portfolios.asset_id FK, tags 마스터, portfolios/assets.tag_ids 컬럼 + GIN, auth_tokens.runtime_id/daemon_id, daemons, ledger_trades - - BAT-015: `20260601_0024` — ledger_trades.asset_id → portfolio_id rename - - **BAT-016: `20260605_0025` — portfolio_ledger 통합(broker 컬럼 order_id/execution_id NULL + side + executed_at + ledger_entry_id Identity, DOUBLE→NUMERIC(20,8), action→side) + `ledger_trades` DROP**. ⚠ `portfolio_ledger`는 TimescaleDB hypertable이라 partial unique index/PK 불가 — idempotency는 application layer. ⚠ ensure_schema가 이미 실행된 환경은 alembic 경로와 충돌 가능(개발 DB 한정, prod fresh-DB는 정상). prod 적용 전 staging dry-run 필수. -3. **portfolio-hub 서비스 배포**: 신규 이미지 rolling restart - - 신 API surface: `/v1/portfolios/*`, `/v1/tags`, `/v1/assets` (catalog), `/v1/portfolios/{id}/ledger/trades`(회계 동반) - - 구 path `/v1/assets/{id}/ledger/trades` → HTTP 404 (BAT-011) - - `GET /health` 응답에 `db_enabled` 노출 (BAT-015 REQ-035) - - 레거시 `POST /portfolios/{id}/buy`·`/sell` → `Deprecation: true` 헤더 + OpenAPI deprecated (BAT-016 REQ-038, 동작 보존) -4. **trex-basic 배포 + trex-core 배포**: 신 path/신 응답 명세로 전환된 클라이언트 배포 - - **trex-basic**: portfolio-hub 배포 후 즉시 — 구 path 호출 불가 - - **trex-core**: ledger write 응답으로 스냅샷 갱신(drift 방지) — 신 응답 명세 채택 버전. 미채택 시 write 직후 read 우회로 동작(degraded, 하지만 호환은 유지 — 비파괴 응답 확장) -5. **배포 후 smoke 검증**: health(db_enabled) + 신 path 회계 동작(cash_balance_after) + 레거시 deprecated 헤더 확인 - -## Rollback Plan -- **트리거 조건**: portfolio-hub 배포 후 health 실패, 신 API 500/401 대규모 발생, trex-basic 배포 실패 -- **BAT-011 포함 전체 롤백**: - 1. portfolio-hub → 이전 이미지로 즉시 롤백 (`alembic downgrade` 또는 이전 이미지 재기동) - 2. trex-basic → 구 path 사용 버전으로 동반 롤백 (portfolio-hub 롤백 완료 후) - - **BAT-011 단독 부분 롤백 불가** — 신/구 path 동시 서빙은 설계상 없음 -- **DB 마이그레이션 롤백**: 데이터 손실 리스크를 평가한 뒤 DBA 판단 하에 수행. tag_ids 컬럼(portfolios/assets 양쪽)·ledger_trades·daemons 테이블은 마이그레이션 downgrade 시 데이터 소실 가능성 존재 — 사전 DB 스냅샷 필수. assets.tag_ids(20260521_0023) downgrade는 컬럼·인덱스 drop으로 안전. -- **BAT-016 마이그레이션(20260605_0025) 롤백 주의**: downgrade는 `ledger_trades` 테이블 재생성 + portfolio_ledger broker 컬럼 drop + action 컬럼 복원이나, DROP된 `ledger_trades` 데이터는 복구 불가하고 NUMERIC→DOUBLE 정밀도 변환·side→action 복원에 손실 가능. 본 통합 release의 ledger 부분 롤백은 DB 스냅샷 복원으로 처리 권장. trex-core도 구 응답 의존 버전으로 동반 롤백. - -## Verification Checklist -- **배포 전**: - - [x] trex-basic 신 path 빌드 완료 확인 - - [x] DB 스냅샷(백업) 생성 완료 - - [x] `alembic upgrade head` dry-run 또는 revision 목록 점검 - - [x] 전체 테스트 suite pass 확인 (BAT-001~011: 340 passed @ main 4bf6b55; BAT-012: 362; BAT-013: 369; BAT-015: 372; BAT-016: 376 @ `2a19b7f`; **Hotfix #9: 377 passed @ 2026-06-06 `6b812b6`** — user_info graceful degrade 회귀 1건 추가) - - [pending] **trex-core 신 응답 명세 채택 빌드 완료 확인** (BAT-016 — 별도 레포) — **cross-repo 확인 대기**: dev-a/trex-core REL-008과 대조 예정 (trex-team-lead 연결). 결과 확인 전까지 pending 유지. - - [~] **BAT-016 마이그레이션 staging dry-run** — **moot**: prod 2026-06-14 적용 후 2일+ 무중단 가동으로 사전 dry-run 불요. "prod 적용 성공" 사실로 대체. -- **배포 직후 (portfolio-hub)**: - - [x] `GET /health` → HTTP 200 + `db_enabled: true` (DB 연결 정상) — **로컬 smoke (2026-05-21 17:08 KST)**: HTTP 200 응답, 단 `HealthResponse` 모델([app/api/health.py:17-21](app/api/health.py#L17-L21))에 `db_enabled` 필드 미노출. DB 연동은 `PORTFOLIOHUB_DB_ENABLED=true` + `alembic upgrade head` head revision 적용 + 본 smoke의 다른 항목들이 DB 의존 동작으로 통과한 것으로 우회 확인. **재확인 (2026-06-06, 127.0.0.1:18000)**: `{"status":"ok","service":"portfolio-hub","version":"0.1.0","checked_at":"2026-06-06T00:08:11+09:00","db_enabled":true}` HTTP 200 — `db_enabled` 필드가 응답 본문에 정상 노출됨(BAT-015 REQ-035 반영). 기존 [~] 미노출 우려 해소. - - [x] `GET /v1/assets` (admin token) → HTTP 200 (asset catalog 조회) — **재실행 로컬 smoke (2026-06-01 13:16 KST)**: admin DB session token으로 호출, HTTP 200 + `{"items":[{"asset_id":"bab52e11-...","symbol":"005930","display_name":"삼성전자","status":"active","tag_ids":[],...}],"total":1}` catalog 응답 확인. REQ-010 CHG-20260521-02 augmentation + BAT-012/013 verification 정합. portfolio 관련 필드 미포함, AC-9 충족. (이전 2026-05-21 smoke에서 HTTP 403 발생했던 불일치는 해소.) - - [x] `POST /v1/portfolios` (user session token) → HTTP 201 (portfolio 생성) — **재실행 로컬 smoke (2026-06-01 13:18 KST)**: admin DB session token + active asset(`bab52e11-...`)으로 호출, HTTP 201 + `portfolio_id=1cec5061-8c26-4116-be3a-1ff2357d02f4` 생성. **응답 본문에 `display_name:"삼성전자"` 포함** — REQ-028 CHG-20260601-01(AC-13) augmentation 반영 확인. `_require_write_token` 게이트([app/api/v1_portfolios.py:45-50](app/api/v1_portfolios.py#L45-L50))에서 session token 정상 허용. - - [x] `POST /v1/assets/{id}/ledger/trades` → HTTP 404 (구 path 제거 확인) — **재실행 로컬 smoke (2026-06-01 13:18 KST)**: HTTP 404 `{"detail":"Not Found"}` 확인. BAT-011 신/구 path 동시 서빙 없음 정책 충족. - - [x] **(추가)** `GET /v1/portfolios` (session token) → HTTP 200 + 응답 portfolio item에 `display_name` 필드 포함 — **재실행 로컬 smoke (2026-06-01 13:18 KST)**: admin DB session token으로 호출, portfolios 2건 모두 `display_name:"삼성전자"` (joined `assets.display_name`)로 응답. REQ-028 CHG-20260601-01 AC-13 충족. - - [blocked] `POST /v1/assets/{asset_id}/tags` / `DELETE /v1/assets/{asset_id}/tags/{tag_id}` (BAT-013 정합화 검증) — **라이브 스모크 BLOCKED (trex-op 확인, 2026-06-16)**: `GET /v1/tags` → HTTP 403 `{"detail":"DB token required"}`. 원인: `app/api/v1_tags.py` 전 엔드포인트에 `token_source != "db"` 가드 적용 — 현재 env 기반 정적 토큰(`token_source="env"`)은 통과 불가. DB 사용자(`PORTFOLIOHUB_BOOTSTRAP_USERNAME`) 부트스트랩이 선행되어야 DB session token 발급 가능. **→ post-release feedback Discovery Input 이관**: DB 사용자 초기화 + DB 토큰 발급 경로 운영 환경 정비 후 재시도. - - [x] **(BAT-015)** `GET /health` → 응답 본문에 `db_enabled: true` 포함 (REQ-035 — 응답 모델 확장 확인; 위 db_enabled 미노출 [~] 항목 해소) — **로컬 확인 (2026-06-06, 127.0.0.1:18000)**: 응답 본문에 `"db_enabled":true` 노출 확인. `HealthResponse` 모델 확장 반영됨. - - [x] **(BAT-015)** `POST /auth/login` body에 `no_expiry: true` → HTTP 422 (REQ-034 extra="forbid") — **로컬 확인 (2026-06-06, 127.0.0.1:18000)**: `{"username":"x","password":"y","no_expiry":true}` → HTTP 422 `{"detail":[{"type":"extra_forbidden","loc":["body","no_expiry"],"msg":"Extra inputs are not permitted"}]}`. extra="forbid" enforcement 동작 확인. - - [x] **(BAT-016)** 레거시 `POST /portfolios/{id}/buy` → 응답 헤더 `Deprecation: true` (REQ-038) — **라이브 스모크 성공 (trex-op, 2026-06-16)**: `POST /portfolios/435b8e89-afc1-4a97-a658-f0e69f46d247/buy` (admin-token-789, asset `25927752-cb3a-49ab-90cd-3b4c269de4f7`, qty:1, price:1.0) → HTTP 200 + 응답 헤더 `deprecation: true` ✅ + body `{"cash_balance":588499.0,"asset_quantity":6,...}`. REQ-038 충족. - - [x] **(Hotfix #9)** `GET /v1/user_info` (service token) → HTTP 200 + `daemon_id` 포함 (500 회귀 없음) — **로컬 smoke (2026-06-06, 127.0.0.1:18000)**: service token으로 호출, HTTP 200 + `{"daemon_id":"b5084542-...","user_id":"...","trading_enabled":true,"brokers":[],...}`. 수정 전 동일 호출은 500(`UndefinedTable: broker_credentials`)이었음. `broker_credentials` 테이블 ensure_schema 생성 확인. 자동 회귀 `tests/test_v1.py::test_v1_user_info_degrades_when_broker_credentials_lookup_fails` 통과. -- **trex-basic / trex-core 배포 직후**: - - [x] `POST /v1/portfolios/{id}/ledger/trades` + **(BAT-016)** `cash_balance_after`/`asset_quantity_after` 확인 (REQ-037) — **라이브 스모크 성공 (trex-op, 2026-06-16)**: `POST /v1/portfolios/bd6f0112-63f0-40df-bb14-64b83de4e86c/ledger/trades` (trex-core service token, order_id="smoke-ledger-001", exec-001, side=buy, qty:1, price:50000) → HTTP 201 + `{"ledger_entry_id":42,"status":"new","cash_balance_after":-63296.1,"asset_quantity_after":1.0}` ✅. 비고: 정적 TRADE_WRITE env 토큰 불가 — trex-core service token(DB 발급) 필요. - - [x] **(BAT-016)** 같은 (portfolio_id, order_id, execution_id) 재호출 → idempotency duplicate (REQ-036) — **라이브 스모크 성공 (trex-op, 2026-06-16)**: 동일 order_id="smoke-ledger-001", execution_id="exec-001" 재호출 → HTTP 200 + `{"ledger_entry_id":42,"status":"duplicate","cash_balance_after":-63296.09999999998,"asset_quantity_after":1.0}` ✅. 기존 ledger_entry_id 42 그대로 반환, row 추가 없음 확인. (cash_balance_after 소수점 표현 차이는 float 정밀도 — NUMERIC(20,8) 내 동일 값.) - - [x] **(BAT-016)** `ledger_trades` 테이블 부재 + portfolio_ledger 단일 원장 확인 — **로컬 DB 확인 (2026-06-06, portfolio_hub@localhost:5432, psycopg)**: `to_regclass('public.ledger_trades')` → `None`(부재), `portfolio_ledger` 존재. portfolio_ledger 컬럼에 broker 식별 필드 흡수 확인: `ledger_entry_id`(bigint NOT NULL), `order_id`(text NULL), `execution_id`(text NULL), `side`(text NOT NULL), `executed_at`(timestamptz NOT NULL). 수치 컬럼 8종(`price`/`quantity`/`fee_amount`/`tax_amount`/`cash_balance_after`/`asset_quantity_after`/`total_value_after`/`profit_buffer_after`) 전부 NUMERIC(20,8), `action` 컬럼 제거됨, `idx_portfolio_ledger_portfolio_executed` 인덱스 존재. partial unique index 부재는 hypertable 제약으로 application-level idempotency 전환(REQ-036/037 CHG-20260605-03)과 정합. (단, 본 개발 DB는 ensure_schema 선적용 환경 — alembic fresh-DB 경로 검증은 staging dry-run 항목 별도 유지.) - - [ ] daemon assignment 인가 3단계 정상 동작 확인 - -## Release Execution Evidence -- Release Status Transition Target: released - -### Deployment Facts -- Executed At: - - 2026-06-14 13:09 KST (trex-op 확인) -- Completed At: - - 2026-06-14 배포 완료; 2026-06-16 기준 2일+ 무중단 가동 중 -- Environment: - - portfolio-hub 운영 인스턴스 (port 8002) -- Operator: - - trex-op -- Revision or Artifact: - - 코드: `6b812b6` (Hotfix issue #9 = REL-001 스코프 최종 코드 커밋) - - 문서: `50beba3` (= origin/main HEAD, REL-001 scope fold 문서 커밋) - - evidence: `/root/release/docs/portfolio-hub/deploy-20260614-130913.md` (trex-op 소급 작성) -- Commands or Procedure: - - `alembic upgrade head`(head `20260605_0025`) → portfolio-hub rolling restart → trex-basic + trex-core 배포 - - **Alembic 마이그레이션 로그 미보존** — `GET /health` db_enabled:true + `/v1/user_info`·`/v1/portfolios` HTTP 200 OK로 사실상 적용 확인. - -### Profile-Specific Checks -- app-service: - - Health Endpoint: - - 로컬 smoke (2026-05-21 17:08 KST, 127.0.0.1:18000): `{"status":"ok","service":"portfolio-hub","version":"0.1.0","checked_at":"2026-05-21T17:08:45.608024+09:00"}` HTTP 200. `db_enabled` 필드는 응답 모델에 미정의 — 별도 후속 항목으로 등록. - - Startup Log Fields: - - 배포 로그 미보존 — 2일+ 무중단 가동으로 재기동 없이 캡처 불가. post-release feedback Discovery Input으로 이관. - - Request Log Fields: - - 배포 로그 미보존 — post-release feedback Discovery Input으로 이관. - - Smoke or Manual Verification: - - **재실행 로컬 smoke (2026-06-01 13:16~18 KST)** — Verification Checklist 배포 직후 5개 항목 참조: - - 통과 5건: GET /health (200), GET /v1/assets (200 + catalog 응답, REQ-010 augmentation 검증), POST /v1/portfolios (201 + display_name 포함, REQ-028 AC-13 검증), GET /v1/portfolios (200 + 모든 item에 display_name 포함), POST /v1/assets/{id}/ledger/trades (404) - - 부분 통과 1건: GET /health 응답 본문에 `db_enabled` 필드 미노출(응답 모델 확장은 후속) — 실 DB 의존 항목들이 모두 통과한 것으로 우회 검증 - - pytest 전체 회귀: **368 passed** (5월 21일 base 340 → +28: REQ-010 BAT-012/013 회귀 + 신규 docs_root_path + display_name AC-13 등 누적). - - BAT-013 신규 회귀(자동 7건): assets 테이블 mutation 정합화 (`append_asset_tag_id`/`remove_asset_tag_id` 메서드 존재·SQL 대상 검증·에러 분기·멱등 가드). 실 PostgreSQL `assets.tag_ids` mutation 확인은 배포 직후 smoke 2건(POST/DELETE /v1/assets/{id}/tags)으로 수집 예정. - - 실제 배포 환경(prod/staging) smoke는 본 릴리즈 시 별도 수집. - -### Rollback Outcome -- Rollback Required: - - (배포 후 기록) -- Rollback Trigger: - - none -- Rollback Action: - - none - -### Release Decision Evidence -- Released Decision: - - released (정합 기준 — 코드 커밋 `6b812b6`·문서 `50beba3`, 2026-06-14 13:09 KST 배포, 2일+ 무중단 운영 확인) -- Rationale: - - `confirm-release` 절차 미실행으로 draft 상태였으나, trex-op 확인으로 배포 사실(커밋 `6b812b6` = REL-001 스코프 BAT-001~016 + Hotfix #9 일치)이 검증됨. 미완료 검증 6건은 각 비고(① cross-repo pending, ② moot, ③④⑤⑥ 라이브 스모크 절차 정의)로 처리. CEO 승인 절차(trex-team-lead 주관) 예정이며, 머지는 trex-team-lead가 CEO 승인 후 수행. -- Recorded By: - - trex-dev-b (trex-team-lead 지시 #107, 2026-06-16) -- Recorded At: - - 2026-06-16 KST - -## Feedback Handoff -- Observation Summary: - - (운영 관찰 후 기록) -- Discovery Input: - - ~~ledger_trades.asset_id 컬럼명 혼용~~: **Resolved by BAT-015** (REQ-033, dcy-005) — `portfolio_id`로 rename. 본 통합 release에 포함. - - ~~session token no_expiry 코드 enforcement 미구현~~: **Resolved by BAT-015** (REQ-034, dcy-005) — `LoginRequest` extra="forbid". 본 통합 release에 포함. - - ~~**GET /v1/assets token_type 게이트와 admin catalog 재정의 미정합**~~: **Resolved by BAT-012** (2026-05-21). REQ-010 CHG-20260521-02 augmentation(AC-9~12). 자동 회귀 9건. - - ~~**/health 응답 모델에 `db_enabled` 필드 부재**~~: **Resolved by BAT-015** (REQ-035, dcy-005) — `HealthResponse`에 `db_enabled` 추가 + runtime-flows 명문화. 본 통합 release에 포함. - - **ledger_trades.asset_id 컬럼이 회계 잔고를 추적하지 않던 audit-only 결함**: **Resolved by BAT-016** (REQ-036/037, dcy-006) — portfolio_ledger 단일 회계 원장 통합 + 신 path 회계 책임. 본 통합 release에 포함. - - **잔여**: 레거시 `POST /portfolios/{id}/buy`·`/sell` 실제 제거(BAT-016은 표기만) + `pfhub tag apply/remove` CLI mismatch — 후속 Discovery 후보. - - ~~**`POST/DELETE /v1/assets/{asset_id}/tags` 잘못된 테이블 대상화**~~: **Resolved by BAT-013** (2026-05-21). REQ-005 CHG-20260521-02 (AC-1/AC-5 명문화) + CHG-20260521-03 (Implemented 전환). `PostgresPortfolioRepository.append_asset_tag_id`/`remove_asset_tag_id` 메서드 신규 + 라우트([app/api/v1_assets.py:175,200](../../app/api/v1_assets.py#L175))의 호출 교체. 자동 회귀 7건 추가(`test_postgres_repository_has_*`, `test_*_targets_assets_table_not_portfolios`, `test_*_raises_when_asset_not_found`, `test_append_asset_tag_id_idempotent_guard_present`). 실 PostgreSQL `assets.tag_ids` mutation 검증은 본 release 배포 직후 smoke 항목으로 추가됨. - - **`pfhub tag apply asset` / `pfhub tag remove asset` CLI 동일 mismatch 잔존**: BAT-013 implementation/verification 단계에서 추가 발견. CLI 측도 `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id`를 호출하여 portfolios 테이블을 대상화 ([docs/runtime-flows.md:67-77](../runtime-flows.md#L67-L77)). REQ-005 본 batch scope는 HTTP API에 한정. **REQ-007/008 augmentation 또는 신규 REQ 후보**. - - **태그 mutation 라이브 스모크 BLOCKED (2026-06-16)**: `v1_tags.py` 전 엔드포인트에 `token_source != "db"` 가드 적용 — env 기반 정적 토큰 진입 불가. `PORTFOLIOHUB_BOOTSTRAP_USERNAME` 미설정으로 DB 사용자·DB 토큰 발급 불가. **Discovery Input**: 운영 환경 DB 사용자 초기화(bootstrap) + DB session token 발급 경로 정비. -- REQ Input: - - (운영 관찰 후 기록) -- Change Request Input: - - (운영 관찰 후 기록) diff --git a/docs/releases/rel-002_20260619_trade-history-and-pnl-api.md b/docs/releases/rel-002_20260619_trade-history-and-pnl-api.md deleted file mode 100644 index e5055ba..0000000 --- a/docs/releases/rel-002_20260619_trade-history-and-pnl-api.md +++ /dev/null @@ -1,176 +0,0 @@ -# REL-002: 일일 거래내역 + 실현 PnL 조회 API (dcy-007 M1) - -- Status: feedback-captured -- Profile: app-service -- Included Batch: BAT-017 - -## Scope - -- Included: - - BAT-017 (REQ-039, REQ-040, REQ-041, REQ-042): dcy-007 M1 fast-track — 일일 거래내역 + 실현 PnL 조회 API 신설 - - REQ-039: GET /v1/portfolios/{id}/trades (date 단일/from+to 범위 필터, read_token 이상 인증, 403/404/422) - - REQ-040: GET /v1/portfolios/{id}/pnl/realized (from+to 필수, symbol 선택 필터, WAC 기반 실현 PnL, data_completeness_warning, cost_basis_from) - - REQ-041: WAC cost_basis 산출 로직 — app/domain/pnl.py (순수 Python, DB 무의존, R-1 완화 경고 로직) - - REQ-042: 테스트 커버리지 — test_wac_calculation.py(20건), test_v1_portfolios_trades.py(12건), test_v1_portfolios_pnl.py(18건) - - Freeze 커밋: `bd34450` (main HEAD, 2026-06-18) -- Excluded: - - 미실현 PnL (현재가 연동) — dcy-007 OQ-2 Out of Scope - - 전체 portfolio 집계 PnL 뷰 — dcy-007 OQ-2 Out of Scope - - DB 마이그레이션 없음 (기존 portfolio_ledger 스키마로 조회 가능) - - 페이지네이션 — 이번 범위 외 이연 - - 성능 테스트 (p95 500ms) — TimescaleDB 통합/성능 환경에서 별도 검증 필요 - - BAT-001~016 (REL-001 포함 완료) - -## Rollout Plan - -1. **사전 확인**: 코드 freeze 커밋 `bd34450` main HEAD 일치 확인 -2. **DB 마이그레이션 없음**: REL-002는 기존 `portfolio_ledger` 스키마(alembic head = `20260605_0025`, REL-001 적용 완료)를 그대로 사용. 추가 마이그레이션 불필요. -3. **portfolio-hub 서비스 배포**: 신규 이미지(freeze 커밋 `bd34450`) rolling restart - - 신규 엔드포인트: `GET /v1/portfolios/{id}/trades` (REQ-039) - - 신규 엔드포인트: `GET /v1/portfolios/{id}/pnl/realized` (REQ-040) - - WAC domain 로직: `app/domain/pnl.py` 신규 (REQ-041) - - 기존 엔드포인트 영향 없음 (additive 변경) -4. **배포 후 smoke 검증**: 하기 Verification Checklist 배포 직후 항목 실행 - -## Rollback Plan - -- **트리거 조건**: portfolio-hub 배포 후 `GET /health` 실패, 신규 API 500 대규모 발생, 기존 API 회귀 발생 -- **절차**: - 1. portfolio-hub → 이전 이미지(REL-001 freeze `6b812b6`) rolling restart - 2. DB 마이그레이션 없으므로 DB 롤백 불필요 - 3. 기존 API (`/v1/portfolios`, `/v1/assets`, `/v1/tags`, `/v1/portfolios/{id}/ledger/trades` 등) 정상 동작 확인 -- **롤백 영향**: 신규 엔드포인트 2개(`/trades`, `/pnl/realized`)만 제거됨. 기존 운영 기능 영향 없음. additive 변경이라 롤백 리스크 낮음. - -## Verification Checklist - -- **배포 전**: - - [ ] freeze 커밋 `bd34450`이 배포 대상 이미지에 포함됨 확인 - - [ ] pytest 전체 (BAT-017 포함) 427 passed 확인: 50 (BAT-017 신규) + 377 (기존 회귀 0) — 2026-06-18 기준 - - [ ] DB 마이그레이션 불필요 확인 (alembic head `20260605_0025` 그대로) - - [ ] REL-001 운영 인스턴스 health 정상 확인 (배포 베이스라인) -- **배포 직후 (portfolio-hub)**: - - [x] `GET /health` → HTTP 200 + `db_enabled: true` 확인 ✅ (2026-06-19 17:27 KST, trex-op) - - [x] `GET /v1/portfolios/{id}/trades?date=YYYY-MM-DD` (read_token 이상) → HTTP 200 + 항목 필드(`trade_id`, `side`, `quantity`, `price`, `executed_at`, `fee_amount`, `tax_amount`) 확인 (REQ-039 AC-1) ✅ (체결 2건 확인) - - [x] `GET /v1/portfolios/{id}/trades` (날짜 파라미터 누락) → HTTP 422 확인 (REQ-039 AC-3) ✅ - - [x] `GET /v1/portfolios/{id}/pnl/realized?from=YYYY-MM-DD&to=YYYY-MM-DD` (read_token 이상) → HTTP 200 + `total_realized_pnl`, `cost_basis_from`, `items`, `data_completeness_warning` 필드 확인 (REQ-040 AC-1) ✅ (`total_realized_pnl=-9306.585` 확인) - - [x] `GET /v1/portfolios/{id}/pnl/realized` (from/to 누락) → HTTP 422 확인 (REQ-040 AC-3) ✅ - - [x] 비소유자 토큰으로 `/trades` 또는 `/pnl/realized` 호출 → HTTP 403 확인 (REQ-039 AC-6, REQ-040 AC-7) ✅ - - [x] 존재하지 않는 portfolio_id로 호출 → HTTP 404 확인 (REQ-039 AC-7, REQ-040 AC-8) ✅ - - [ ] OpenAPI `/docs` 에서 신규 엔드포인트 2개 노출 확인 (REQ-039 AC-8, REQ-040 AC-9) — [비차단] 수동 확인 미완, 운영 관찰 중 - - [x] 기존 `GET /v1/portfolios`, `POST /v1/portfolios/{id}/ledger/trades`, `GET /health` 정상 동작 회귀 없음 확인 ✅ - -## Known Issues (QC PASS-WITH-NOTES 비차단 잔여) - -1. **symbol 필터 edge 테스트 공백**: `GET /v1/portfolios/{id}/pnl/realized?symbol=X` 호출 시 `symbol_param != portfolio_symbol` 케이스(다른 symbol 필터 요청)에 대한 자동 테스트가 불완전. AC-2는 충족이나 완전 커버 아님. 별도 cleanup batch에서 처리 권장. -2. **HTTP_422_UNPROCESSABLE_ENTITY deprecated**: FastAPI 최신 버전에서 `HTTP_422_UNPROCESSABLE_ENTITY` → `HTTP_422_UNPROCESSABLE_CONTENT` 경고. 기존 코드 패턴 유지(breaking change 없음). 별도 cleanup batch에서 처리 권장. -3. **성능 AC-9/AC-10 미검증**: `GET /v1/portfolios/{id}/trades` 및 `/pnl/realized` p95 500ms — TimescaleDB 인덱스 환경에서 별도 실행 필요. 환경 제약으로 이번 릴리즈 검증 범위 외(코드 결함 아님). -4. **symbol 컬럼 부재**: `portfolio_ledger`에 symbol 컬럼 없음. 현재 portfolio 단위(1 portfolio = 1 symbol) 가정으로 동작. 멀티 symbol portfolio 지원 시 별도 설계 필요. - -## Release Execution Evidence - -- Release Status Transition Target: released - -### Deployment Facts - -- Executed At: - - 2026-06-19 17:27 KST -- Completed At: - - 2026-06-19 17:27 KST -- Environment: - - portfolio-hub 운영 인스턴스 (port 8002) -- Operator: - - trex-op (운영팀) -- Revision or Artifact: - - 배포 커밋: `d89f137` (main HEAD, push 완료) - - 이전 커밋: `50beba3` → 배포 커밋: `d89f137` - - 비고: Freeze pin `bd34450`과 배포 커밋 `d89f137`의 차이는 REL 문서(docs-only) 변경으로 런타임 동일 -- Commands or Procedure: - - `bash /root/release/scripts/deploy-portfolio-hub.sh` - - DB 마이그레이션 없음 (alembic head `20260605_0025` 유지, REL-001 시점과 동일) - - 프로세스: PID 461546 (port 8002), RUNNING (2026-06-19 17:33 KST 확인) - -### Profile-Specific Checks - -- app-service: - - Health Endpoint: - - `GET /health` → HTTP 200, `db_enabled: true` (2026-06-19 배포 후 확인) ✅ - - Startup Log Fields: - - PID 461546 (port 8002), RUNNING 상태 확인 (2026-06-19 17:33 KST) ✅ - - Request Log Fields: - - (운영 관찰 계속 중) - - Smoke or Manual Verification: - - **배포 전 자동 테스트 evidence (2026-06-18)**: - - BAT-017 신규 테스트: 50 passed (test_wac_calculation.py 20건 + test_v1_portfolios_trades.py 12건 + test_v1_portfolios_pnl.py 18건) — pytest 실행 출력: `50 passed, 8 warnings in 3.43s` - - 기존 회귀 테스트: 377 passed, 0 failed — 회귀 없음 - - 합계: 427 passed, 0 failed - - 독립 QC (`qc-reviewer`, 2026-06-18): **PASS-WITH-NOTES** — 50 passed 독립 재현 + 전체 427 passed 회귀 없음, WAC=11,000/realized=199,300 단위+e2e 검증, R-1 경고필드(buy없음·oversell·완전이력 null) 정상, NFR-3 격리(domain DB import 0) 확인, 스코프·DB 마이그레이션 무, 위생 이상 없음. - - 운영팀 주간 테스트: 4개 서비스 전수 통과 (2026-06-19 사장님 확인) - - **배포 후 smoke (2026-06-19 17:27 KST, trex-op)**: - - `GET /health` → HTTP 200, `db_enabled: true` ✅ - - `GET /v1/portfolios/{id}/trades?date=2026-06-19` (read_token 이상) → HTTP 200, 체결 2건, 필드 전체 확인 (`trade_id`, `side`, `quantity`, `price`, `executed_at`, `fee_amount`, `tax_amount`) ✅ (REQ-039 AC-1) - - `GET /v1/portfolios/{id}/trades` (날짜 파라미터 누락) → HTTP 422 ✅ (REQ-039 AC-3) - - `GET /v1/portfolios/{id}/pnl/realized?from=2026-06-01&to=2026-06-19` (read_token 이상) → HTTP 200, `total_realized_pnl=-9306.585`, 필드 전체 확인 (`total_realized_pnl`, `cost_basis_from`, `items`, `data_completeness_warning`) ✅ (REQ-040 AC-1) - - `GET /v1/portfolios/{id}/pnl/realized` (from/to 누락) → HTTP 422 ✅ (REQ-040 AC-3) - - 비소유자 토큰으로 신규 엔드포인트 호출 → HTTP 403 ✅ (REQ-039 AC-6, REQ-040 AC-7) - - 존재하지 않는 portfolio_id로 호출 → HTTP 404 ✅ (REQ-039 AC-7, REQ-040 AC-8) - - 기존 `GET /v1/portfolios` 회귀 없음 ✅ - - [비차단] OpenAPI `/docs` 신규 엔드포인트 2개 노출 — 수동 확인 미완 (운영 관찰 중, 차단 아님) - -### Rollback Outcome - -- Rollback Required: - - No -- Rollback Trigger: - - none (트리거 조건 발생 없음) -- Rollback Action: - - none (롤백 수행 없음) - -### Release Decision Evidence - -- Released Decision: - - released 전환 승인. -- Rationale: - - app-service profile released DoD 전항목 충족 판정 근거: - (1) 배포 완료: 2026-06-19 17:27 KST, trex-op 수행, 커밋 d89f137 (freeze bd34450 대비 docs-only 차이, 런타임 동일) ✅ - (2) 프로세스 기동 확인: PID 461546 (port 8002) RUNNING (17:33 KST 확인) ✅ - (3) health endpoint: `GET /health` → HTTP 200, `db_enabled: true` ✅ - (4) DB 마이그레이션 없음: alembic head `20260605_0025` 유지 (REL-001과 동일) ✅ - (5) 신규 엔드포인트 smoke 전항목 통과: trades 200/422, pnl/realized 200/422, 403/404 정상 ✅ - (6) 기존 API 회귀 없음 ✅ - (7) 롤백 불필요 (트리거 없음) ✅ - (8) 배포 전 QC: BAT-017 50 passed + 전체 427 passed, qc-reviewer PASS-WITH-NOTES (2026-06-18) ✅ - - 비차단 잔여 (released 전환을 막지 않음): - - OpenAPI `/docs` 신규 엔드포인트 노출 수동 확인 미완 — 기능 결함 아님, 운영 관찰 중 - - Known Issues 4건 (symbol 필터 edge 테스트, HTTP_422 deprecated, 성능 미검증, symbol 컬럼 부재) — QC PASS-WITH-NOTES 비차단으로 이미 기록됨, cleanup/차기 Discovery 후보 -- Recorded By: - - 팀장 (portfolio-hub-dev evidence 기입 + released 전환 판정, 2026-06-19) -- Recorded At: - - 2026-06-19 KST - -## Feedback Handoff - -- Observation Summary: - - 출처: 2026-06-22 운영팀(trex-op) 스모크 레포트 (배포 2026-06-19 17:27 KST, 커밋 `d89f137`, PID 461546, port 8002) - - 스모크 결과: 9개 항목 중 8개 PASS, 1개 비차단 잔여 - - PASS (8/9): - - GET /health → 200, db_enabled=true - - GET /v1/portfolios/{id}/trades?date=2026-06-19 → 200, 체결 2건 - - GET /v1/portfolios/{id}/trades (날짜 누락) → 422 - - GET /v1/portfolios/{id}/pnl/realized?from=..&to=.. → 200, 필드 전체 - - GET /v1/portfolios/{id}/pnl/realized (from/to 누락) → 422 - - 비소유자 토큰 → 403 - - 존재하지 않는 portfolio_id → 404 - - 기존 GET /v1/portfolios 회귀 없음 - - 비차단 잔여 (1/9): OpenAPI /docs 신규 엔드포인트 2개 노출 — 브라우저 수동 확인 미완 (기능 결함 아님) - - 운영 의의: 이 API(GET /v1/portfolios/{id}/trades, GET /v1/portfolios/{id}/pnl/realized)는 M2 첫 거래일 tsl 매도 확인의 데이터 원(source)으로 사용된다. - - Captured At: 2026-06-22 -- Discovery Input: - - symbol 필터 edge (다른 symbol 필터 요청 케이스 `symbol_param != portfolio_symbol`) 자동 테스트 보강 — cleanup batch 후보 - - HTTP_422_UNPROCESSABLE_ENTITY deprecated → HTTP_422_UNPROCESSABLE_CONTENT 정리 — cleanup batch 후보 - - 성능 테스트 (p95 500ms, GET /v1/portfolios/{id}/trades 및 /pnl/realized) — TimescaleDB 인덱스 환경 구성 후 별도 검증 Discovery 후보 - - 멀티 symbol portfolio 지원 (portfolio_ledger에 symbol 컬럼 추가) — 별도 Discovery 후보 - - OpenAPI /docs 신규 엔드포인트 2개 브라우저 수동 노출 확인 — 다음 운영 점검 시 완료 권장 -- REQ Input: - - 없음 (기존 REQ 변경 불필요) -- Change Request Input: - - 없음 (현 릴리즈 범위 내 변경 요청 없음) diff --git a/docs/releases/rel-003_20260628_daemon-credential-and-admin-api.md b/docs/releases/rel-003_20260628_daemon-credential-and-admin-api.md deleted file mode 100644 index f6500fe..0000000 --- a/docs/releases/rel-003_20260628_daemon-credential-and-admin-api.md +++ /dev/null @@ -1,261 +0,0 @@ -# REL-003: daemon-scoped 브로커 인증정보 + 관리 API (dcy-010 + dcy-011) - -- Status: released -- Profile: app-service -- Included Batch: BAT-018 (REQ-043), BAT-019 (REQ-044, REQ-045), BAT-020 (REQ-046~051), BAT-021 (REQ-047 Step2 통합 및 런북), BAT-022 (REQ-052~054) - ---- - -## Scope - -### Included - -- **BAT-018** (dcy-008, batch-lite, release-candidate) — REQ-043: `GET /v1/portfolios/{id}/holdings/cost-basis` 엔드포인트 신설 (WAC/평단 조회) -- **BAT-019** (dcy-009, standard, confirmed) — REQ-044/045: `GET /v1/assets` 권한 완화(require_read) + `GET /v1/daemons` 신설 -- **BAT-020** (dcy-010, standard, release-candidate) — REQ-046~051: BrokerCredential daemon_id 도메인 모델 + Step1 마이그레이션 + backfill CLI + `/v1/user_info` daemon-scoped 확장 + CLI `--daemon-id` 옵션 + 보안 격리 -- **BAT-021** (dcy-010, standard, release-candidate) — REQ-047: Step2 동반 코드 수정(ensure_schema/upsert ON CONFLICT) + 실 PostgreSQL 통합검증 + Step2 런북 (`docs/runbooks/bat-021-req047-step2-migration-runbook.md`) -- **BAT-022** (dcy-011, standard, release-candidate) — REQ-052~054: 사용자 소유 스코프 인증정보 관리 API + admin 데몬 CRUD API + 인증정보 보안 격리(쓰기전용·secret 미반환·로그 미노출) -- **(동반 배포) dcy-035 WAC #15** (`3a6adc9`, REQ-041 change-req CHG-20260626-01) — `/pnl/realized`·holdings cost-basis의 **매수 수수료를 WAC cost basis에 편입**. 이미 origin/main 머지됨 → 이번 portfolio-hub 코드 배포(main)에 함께 실린다. trex-core 운영리포트(dcy-035, REL-010)와 짝을 이루는 멀티데이 WAC 정확값의 portfolio-hub 측. (별도 배포·재시작 불필요 — 동일 배포에 포함) - -### Excluded - -- REQ-047 Step2(PK 전환) 운영 DB 실제 적용 — **이번 릴리즈의 코드 배포 이전** ops 윈도우에서 별도 수행 (런북 `docs/runbooks/bat-021-req047-step2-migration-runbook.md` 엄수, 사장님 결재 게이트) -- trex-core 소비자 어댑터 (KISConfig fetch 경로) — 별도 trex-core Discovery (dcy-010 out-of-scope) -- trex-web UI (dcy-009) — trex-web repo 별도 진행 -- 서비스 토큰 발급·폐기 (dcy-011 out-of-scope) - ---- - -## 전제 조건 (배포 시작 전 필수 확인) - -1. **`CREDENTIAL_ENCRYPTION_KEY`** — 운영 환경 env에 Fernet key 값이 설정돼 있어야 한다. 미설정 시 인증정보 암·복호화 전체가 동작하지 않는다. - - 확인: 서비스 실행 환경에서 `echo $CREDENTIAL_ENCRYPTION_KEY | head -c 10` (공백이면 미설정) - - 최초 설정: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 결과를 `CREDENTIAL_ENCRYPTION_KEY`에 등록 - - **주의**: 기존 운영 인스턴스에 이미 설정돼 있으면 변경 금지 (기존 암호화 payload를 복호화할 수 없게 됨) - -2. **Step2 마이그레이션 완료 확인** — 코드 배포 전에 REQ-047 Step2(alembic revision `20260628_0027`) 적용이 완료되어야 한다. Step2 미적용 상태로 BAT-021 코드를 배포하면 `upsert_broker_credential`의 `ON CONFLICT (daemon_id, broker)` 절이 구 스키마(PK=`(user_id, broker)`)와 맞지 않아 인증정보 등록/회전 실패. - - 검증 쿼리: `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'broker_credentials_pkey';` - - 기대값: `PRIMARY KEY (daemon_id, broker)` - -3. **alembic 상태 사전 확인** — 현행 운영 DB의 alembic head가 `20260628_0027` (Step2 완료 후) 인지 확인: - ```bash - alembic current - # 기대: 20260628_0027 (head) - ``` - -4. **DB 백업** — Step2 마이그레이션 적용 전 운영 DB 전체 스냅샷 백업 (런북 1단계 참조). - -5. **pytest 통과 확인** — main HEAD (`9fe6f3b`) 기준으로 `pytest` 전체가 통과해야 한다. - - BAT-022 기준 565 passed + 핫픽스 커밋 포함 최신 스위트 통과 확인 필요. - ---- - -## Rollout Plan - -> **배포 순서 엄수. 특히 Step2 미적용 상태에서 코드 배포하면 upsert 실패가 발생한다.** - -``` -[ops 윈도우] DB 백업 → Step1 upgrade → backfill CLI → NULL 0건 확인 - → Step2 upgrade (사장님 결재 필수) → Step2 스키마 검증 -[코드 배포] → portfolio-hub 서비스 rolling restart -[smoke] → 헬스체크 → 스모크 테스트 체크리스트 실행 -``` - -### 단계별 상세 - -**[ops 윈도우 전용 — 코드 배포 전 수행]** - -1. **DB 백업**: 운영 DB 전체 스냅샷 (`pg_dump`) — 런북 1단계 참조 -2. **Step1 upgrade** (`alembic upgrade 20260628_0026`): `broker_credentials`에 `daemon_id TEXT NULL` 컬럼 추가. 기존 PK `(user_id, broker)` 유지. -3. **backfill CLI**: `pfhub credential backfill-daemon-id --user --daemon-id ` — 기존 user-scoped 인증정보 행에 daemon_id 설정. 사용자별 반복. `--dry-run` 선 확인 권장. -4. **NULL 0건 검증**: `SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL;` → 반드시 0 확인. 1건이라도 있으면 Step2 진행 금지 (upgrade가 RuntimeError로 자동 거부함). -5. **Step2 upgrade** (`alembic upgrade 20260628_0027`, **사장님 결재 필수**): PK를 `(user_id, broker)` → `(daemon_id, broker)`로 전환. `daemon_id NOT NULL` 제약 추가. **비가역 작업.** -6. **Step2 스키마 검증**: PK 전환·daemon_id NOT NULL·user_id 유지를 SQL 쿼리로 확인 — 런북 4단계 검증 SQL 참조. - -**[코드 배포]** - -7. **portfolio-hub 서비스 rolling restart**: freeze 커밋 `9fe6f3b` (main HEAD) 이미지로 교체. - - 포함 변경 모듈: `app/repositories/postgres.py` (ensure_schema·upsert ON CONFLICT), `app/api/v1_daemons.py` (admin CRUD·daemon 목록), `app/api/v1_credentials.py` (소유 스코프 관리 API), `app/api/v1_portfolios.py` (holdings/cost-basis), `app/api/v1_user_info.py` (daemon-scoped broker 전달), `app/cli/commands/credential.py` (--daemon-id·backfill), `app/domain/pnl.py` (calculate_current_wac) - -**[배포 후 smoke]** - -8. 아래 Verification Checklist 항목 순서대로 실행. - ---- - -## Rollback Plan - -### 트리거 조건 -- `GET /health` HTTP 200 실패 -- `GET /v1/user_info` (service token) HTTP 500 또는 broker 필드 누락 -- 인증정보 PUT/GET 연속 500 발생 -- 기존 API(`/v1/portfolios`, `/v1/assets`, `/v1/daemons`) 회귀 발생 - -### 코드 롤백 (1순위) - -BAT-022/021/020은 기존 기능의 additive 추가 및 REQ-049 동작 변경(brokers→broker 단일화)이 포함된다. 코드 롤백 시 REL-002 freeze 이미지(커밋 `d89f137`)로 복원하면 된다. DB는 Step2 이후 상태 그대로 둬도 무방 (코드가 구 스키마를 참조하지 않는 방향으로 하위호환 불가하여 DB 롤백 필요 여부를 판단). - -``` -portfolio-hub → REL-002 이미지(커밋 d89f137)로 rolling restart -``` - -### DB 롤백 (Step2 롤백 — 조건부) - -Step2 downgrade는 같은 user의 여러 daemon이 동일 broker를 쓰는 경우 RuntimeError로 자동 거부된다. 단일 daemon 1계좌 환경이면 downgrade 가능: - -```bash -alembic downgrade 20260628_0026 # Step2 → Step1 상태 -alembic downgrade 20260605_0025 # Step1 → 이전 상태 (daemon_id 컬럼 제거) -``` - -**경고**: Step2 downgrade는 조건부 가역이다. 백업 복원이 최후 수단. - -```bash -pg_restore -h -U -d -c backup_pre_bat021_.dump -``` - -### 롤백 영향 분석 -- REQ-049 (`/v1/user_info` daemon-scoped): brokers 리스트→단일 broker 변경. trex-core 소비자는 `daemon_id`만 읽으므로 무영향(소비자 인벤토리 확인 완료). -- REQ-052/053: HTTP API 추가분 — additive. 롤백 시 엔드포인트 제거만 발생. 기존 운영 영향 없음. -- REQ-043/044/045 (BAT-018/019): additive 추가·권한 완화. 롤백 시 신규 엔드포인트 제거 + GET /v1/assets가 require_admin으로 복원. - ---- - -## Verification Checklist - -### 배포 전 - -- [ ] Step2 완료 확인: `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'broker_credentials_pkey';` → `PRIMARY KEY (daemon_id, broker)` -- [ ] alembic head 확인: `alembic current` → `20260628_0027 (head)` -- [ ] NULL 잔존 0건: `SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL;` → `0` -- [ ] `CREDENTIAL_ENCRYPTION_KEY` 운영 환경 설정 확인 -- [ ] pytest 전체 통과 확인 (freeze 커밋 `9fe6f3b` 기준) -- [ ] DB 백업 완료 확인 (백업 파일 경로/사이즈 기록) - -### 배포 직후 - -#### 기본 헬스 - -- [ ] `GET /health` → HTTP 200, `db_enabled: true` - -#### admin 데몬 CRUD (REQ-053 — BAT-022) - -- [ ] `POST /v1/admin/daemons` (admin-token) — 데몬 생성 → HTTP 201 + `DaemonAdminResponse` (id, owner_user_id, daemon_slug, is_active) -- [ ] `GET /v1/admin/daemons` (admin-token) — 데몬 목록 → HTTP 200 + 데몬 목록 반환 -- [ ] `GET /v1/admin/daemons/{daemon_id}` (admin-token) → HTTP 200 -- [ ] `PATCH /v1/admin/daemons/{daemon_id}` (admin-token) — display_name 변경 → HTTP 200 -- [ ] `PATCH /v1/admin/daemons/{daemon_id}` (admin-token, is_active=false) — 비활성화 → HTTP 200 + is_active=false -- [ ] `DELETE /v1/admin/daemons/{daemon_id}` (admin-token) → HTTP 405 (하드 삭제 금지) -- [ ] 비admin user-token으로 admin CRUD 시도 → HTTP 403 - -#### 인증정보 관리 API (REQ-052 — BAT-022) - -- [ ] `PUT /v1/daemons/{daemon_id}/credential` (user-token, 소유 데몬) → HTTP 200 + 메타데이터(account_ref, credential_version, last_rotated_at, broker) 응답; **app_key/app_secret 미포함 확인 (필드 부재)** -- [ ] `GET /v1/daemons/{daemon_id}/credential` (user-token, 소유 데몬) → HTTP 200 + 메타데이터; **secret 미포함 확인** -- [ ] `DELETE /v1/daemons/{daemon_id}/credential` (user-token, 소유 데몬) → HTTP 204 -- [ ] 타 사용자 user-token으로 소유 스코프 외 데몬 인증정보 조회/수정 → HTTP 403 - -#### service-token `/v1/user_info` daemon-scoped (REQ-049 — BAT-020) - -- [ ] service-token(daemon_id 바인딩)으로 `GET /v1/user_info` → `broker` 단일 객체(account_ref, credential_payload 포함), `brokers` 리스트 키 없음 확인 -- [ ] `CREDENTIAL_ENCRYPTION_KEY`와 일치하는 키로 인증정보를 등록한 경우 `credential_payload` 복호화 값(app_key 포함) 반환 확인 -- [ ] daemon_id 없는 service-token으로 `/v1/user_info` → HTTP 403 -- [ ] user-token으로 `/v1/user_info` → HTTP 403 (REQ-051 보안 격리 회귀) - -#### WAC 조회 엔드포인트 (REQ-043 — BAT-018) - -- [ ] `GET /v1/portfolios/{id}/holdings/cost-basis` (read-token, 소유 portfolio) → HTTP 200 + `items`, `cost_basis_from` 포함 - -#### 자산/데몬 목록 (REQ-044/045 — BAT-019) - -- [ ] `GET /v1/assets` (read-token, non-admin) → HTTP 200 (권한 완화 확인) -- [ ] `GET /v1/daemons` (user-token) → HTTP 200 + 소유 데몬 목록 - -#### 기존 API 회귀 - -- [ ] `GET /v1/portfolios` (read-token 이상) → HTTP 200 -- [ ] `POST /v1/portfolios/{id}/ledger/trades` (trade-write-token) → 기존 동작 유지 -- [ ] `GET /v1/portfolios/{id}/trades` (read-token 이상) → HTTP 200 -- [ ] `GET /v1/portfolios/{id}/pnl/realized` (read-token 이상) → HTTP 200 - ---- - -## Known Issues (릴리즈 전 인지 사항) - -1. **BAT-022 AC-3 통합 수준**: REQ-052 AC-3(CLI·API·service-token 3경로 정합)이 unit-level 검증. 실 DB 통합 환경에서 재확인 권장(비가역 아님, 선택적). -2. ~~owner 재할당 원자성~~ **(해소됨 — 오기 정정)**: `update_daemon_owner()`는 **단일 `conn.transaction()` 트랜잭션**으로 credential DELETE(선행) → owner UPDATE를 원자 처리한다(BAT-022 QC Major fix, 커밋 83e6dde). 어느 한쪽 실패 시 전체 롤백되어 "owner만 바뀌고 구 인증정보 잔존"하는 leak 상태가 발생하지 않는다. 라이브 통합검증(2026-06-28)에서 owner 재할당 → 인증정보 무효화 PASS. **리스크 아님.** -3. **DELETE credential broker 고정**: `DELETE /v1/daemons/{daemon_id}/credential`은 broker="kis" 고정. 다중 broker 지원 시 별도 설계 필요(현재 단일 broker 가정 설계). -4. **REL-002 Known Issues 이월**: symbol 필터 edge 테스트, HTTP_422 deprecated 경고, 성능 미검증 — 여전히 비차단 잔여. - ---- - -## Release Execution Evidence - -- Release Status Transition Target: released - -### Deployment Facts - -- Executed At: - - 2026-06-30 (M3 배포 윈도우) -- Completed At: - - 2026-06-30 -- Environment: - - portfolio-hub 운영 인스턴스 -- Operator: - - genichin (ops 윈도우 수행) -- Revision or Artifact: - - 배포 커밋: `9d98d47` (origin/main code freeze; 마이그레이션 0026/0027 + 크레덴셜 백엔드 + WAC #15 포함) - - alembic head (배포 후 확인): `20260628_0027 (head)` — Step1→backfill→Step2 적용 완료, broker_credentials PK `(daemon_id, broker)` 전환, daemon_id NOT NULL, row 보존 -- Commands or Procedure: - - 상세는 위 Rollout Plan 참조 - - DB 마이그레이션: `docs/runbooks/bat-021-req047-step2-migration-runbook.md` 엄수 - - 사전 백업: `/root/release/docs/backup_pre_bat021_20260630123909.dump` - - backfill 적용: `pfhub credential backfill-daemon-id --user genichin --daemon-id trex-genichin-longterm` (중장기 데몬 신설) - - 코드 배포: portfolio-hub 서비스 rolling restart (freeze 커밋 `9d98d47`) - -### Profile-Specific Checks - -- app-service: - - Health Endpoint: - - `GET /health` → HTTP 200, `db_enabled: true` PASS - - Startup Log Fields: - - 정상 기동 확인 - - Request Log Fields: - - 정상 응답 확인 - - Smoke or Manual Verification: - - `GET /health` → HTTP 200 (db_enabled=true) PASS - - `pfhub daemon list --user genichin` → exit 0 PASS - - `pfhub credential list --user genichin --daemon-id trex-genichin-longterm` → exit 0 PASS - -### Rollback Outcome - -- Rollback Required: - - 없음 -- Rollback Trigger: - - 해당 없음 -- Rollback Action: - - 해당 없음 - -### Release Decision Evidence - -- Released Decision: - - released -- Rationale: - - 배포 리비전 `9d98d47` 기준 alembic head `20260628_0027` 적용 완료. 스모크 체크(/health, daemon list, credential list) 전항목 PASS. 롤백 트리거 없음. -- Recorded By: - - 팀장 (M3 배포 완료 운영팀 확인 기반) -- Recorded At: - - 2026-06-30 - ---- - -## Feedback Handoff - -- Observation Summary: - - (배포 후 운영 관찰 기입) -- Discovery Input: - - trex-core 소비자 어댑터 (KISConfig fetch, dcy-010 out-of-scope) — 별도 trex-core Discovery (dcy-036/BAT-054로 진행, PR #35) -- REQ Input: - - (배포 후 기입) -- Change Request Input: - - (배포 후 기입) diff --git a/docs/releases/rel-004_20260715_manual-ledger-api-and-metadata-migration.md b/docs/releases/rel-004_20260715_manual-ledger-api-and-metadata-migration.md deleted file mode 100644 index 88a4676..0000000 --- a/docs/releases/rel-004_20260715_manual-ledger-api-and-metadata-migration.md +++ /dev/null @@ -1,118 +0,0 @@ -# REL-004: owner-scoped 수동 장부 API + portfolio_ledger metadata migration (M5) - -- Status: **draft** -- Profile: app-service -- Included Batch: BAT-024 (REQ-056), BAT-025 (REQ-057) -- Release baseline: `origin/main@914b3dc6e6b9e2cbd7068fb30435c83f0581eab5` -- Deployment runbook: [REL-004 운영 배포 런북](../runbooks/rel-004-m5-manual-ledger-deployment-runbook.md) -- Release approver: Justin Ko (pending) -- Operator: trex-op (pending) - ---- - -## 1. Release intent and scope - -### Included - -- **BAT-024 / REQ-056** — owner-scoped manual ledger API: - - `POST /v1/portfolios/{portfolio_id}/ledger/manual` - - `GET /v1/portfolios/{portfolio_id}/ledger/manual` - - user/session-token owner scope, service-token fail-closed, daemon-managed confirmation, reversal deduplication, transaction/row-lock semantics. -- **BAT-025 / REQ-057** — explicit Alembic revision `20260715_0028`: - - `portfolio_ledger.source TEXT NOT NULL DEFAULT 'broker'` - - `target_ledger_entry_id BIGINT NULL` - - `confirmation_evidence TEXT NULL` - - `actor_user_id TEXT NULL` - - existing rows/data preservation and runtime-precreated schema compatibility. - -### Excluded - -- Production DB migration, service restart, API activation, and any live manual-ledger write. These require the release approval and runbook preflight evidence below. -- `QUANTITY_ADJUSTMENT`, NXT/strategy service changes, and all M5-excluded cards. -- Any `alembic downgrade` or production column deletion. Recovery is forward-only: compatible code rollback while preserving the additive schema and revision. - ---- - -## 2. Release basis - -| Basis | Evidence | Result | -| --- | --- | --- | -| REQ-056 API delivery | BAT-024 implementation `b3447f6`; developer verification `594 passed, 12 warnings`; independent QC `deleg_f2337a39` | PASS | -| Draft baseline regression re-run | 2026-07-15 clean `origin/main@914b3dc` worktree: `594 passed, 3 skipped, 12 warnings in 39.10s`; skipped tests are PostgreSQL-dependent migration integration cases | PASS | -| REQ-057 migration delivery | PR #21, merged into `main` as `914b3dc`; implementation commits `1909caa`, `d48279f`, `2f82251`, `bd51330`, `aac08eb` | PASS | -| Migration/data compatibility | isolated loopback PostgreSQL suite: `3 passed in 6.10s`; fresh, existing-row, and runtime-precreated schema cases | PASS | -| Full regression at BAT-025 verification | `597 passed, 11 warnings in 41.41s`; Ruff, compileall, diff hygiene pass | PASS | -| Independent release-chain QC | PR #21 committed-diff QC `deleg_ae0c6f27`; patch-id mapping, migration semantics, loopback PostgreSQL, Ruff, clean worktree | PASS | -| Operational database evidence | backup, locks, current revision, row counts, schema/current postcheck, service smoke | **PENDING — must be captured by trex-op** | - -The test evidence is intentionally limited to isolated/local PostgreSQL. It is not evidence that an operating database has been backed up, is lock-free, or has safely completed the upgrade. - ---- - -## 3. Mandatory release gates - -The operator must stop before code deployment or restart when any gate fails. - -1. **Approval gate** — Justin Ko records explicit approval for this REL-004 execution window. -2. **Baseline gate** — artifact is built from `914b3dc` or a reviewed descendant; record immutable image/package digest. -3. **Backup gate** — restorable DB backup is complete and path, timestamp, size, and restore owner are recorded. -4. **Database gate** — `alembic heads`, `alembic current`, active-query/lock check, metadata schema snapshot, and `portfolio_ledger` row count are captured before upgrade. -5. **Migration gate** — `alembic upgrade 20260715_0028` succeeds; `current`, all four columns, `source` default/not-null, `source IS NULL = 0`, and unchanged row count are evidenced. -6. **Activation gate** — only after Gate 5, deploy the immutable artifact and perform the service's normal rolling restart procedure. -7. **Smoke gate** — health response and approved owner-token/API smoke pass. A production mutation smoke must use an explicitly designated test portfolio and authorization; it is not implicit approval to alter a customer or live trading portfolio. - ---- - -## 4. Deployment sequence - -```text -Approval → immutable artifact selection → DB backup/preflight - → Alembic 0028 upgrade → schema/current/data verification - → portfolio-hub rolling restart using the approved artifact - → health and minimum API smoke → observation window → released decision -``` - -The detailed commands, expected results, stop conditions, evidence template, and forward-only recovery procedure are in the linked runbook. - ---- - -## 5. Rollback and recovery - -### Triggers - -- migration command fails or postcheck differs from expected schema/current/row-count values; -- `GET /health` fails or returns `db_enabled != true` after activation; -- new manual API returns unexpected 5xx or authorization fail-open behavior; -- existing `/ledger/trades` or portfolio read flows show regression. - -### Policy - -- **Before migration succeeds:** stop; do not restart into the new code. Preserve logs and consult the DB owner using the backup and error evidence. -- **After migration succeeds:** do **not** run `alembic downgrade` and do not drop the four additive columns. Roll back only to a reviewed compatible code artifact, retaining revision `20260715_0028` and the schema. -- DB restore is an exceptional, separately approved recovery operation; it is not an automatic release action. - ---- - -## 6. Release execution evidence (to be completed by trex-op) - -| Field | Evidence | -| --- | --- | -| Approval time / approver | Pending | -| Maintenance window | Pending | -| Artifact ref + immutable digest | Pending | -| Operator | Pending | -| Backup path / timestamp / size / restore verification owner | Pending | -| Preflight `alembic heads/current` | Pending | -| Pre/post `portfolio_ledger` row counts | Pending | -| Lock/active-query result | Pending | -| `0028` upgrade command and exit/result | Pending | -| Post-migration schema/default/null/current evidence | Pending | -| Rolling restart evidence | Pending | -| Health and approved API smoke evidence | Pending | -| Observation window / alerts | Pending | -| Rollback needed | Pending | - -## 7. Release decision - -- Current decision: **draft — not authorized for production execution yet**. -- Transition to `released` requires all mandatory gates and the evidence table to be completed, then a release approver decision recorded in this document. diff --git a/docs/srs/Data/req-001_tags-master-table.md b/docs/srs/Data/req-001_tags-master-table.md deleted file mode 100644 index 3f4a703..0000000 --- a/docs/srs/Data/req-001_tags-master-table.md +++ /dev/null @@ -1,130 +0,0 @@ -# REQ-001: tags 마스터 테이블 도입 - -- Status: Implemented -- Type: Data -- Priority: High -- Owner: Justin Ko - -## Intent -- 태그를 자유 문자열이 아닌 정규화된 마스터 테이블로 관리하여 소유권(created_by_user_id), admin 구분(is_admin_tag), 생명주기(is_active)를 추적할 수 있는 기반을 마련한다. -- is_admin_tag는 태그 생성 시점의 권한을 스냅샷으로 기록하며 이후 권한 변경과 무관하게 고정된다. -- 태그 삭제는 is_active = FALSE soft delete로만 수행하여 기존 참조가 archived 상태임을 확인할 수 있게 한다. - -## Requirement -- 시스템은 다음 스키마로 `tags` 테이블을 생성해야 한다: `(tag_id UUID PK DEFAULT gen_random_uuid(), name TEXT NOT NULL, created_by_user_id TEXT NOT NULL REFERENCES users(id), is_admin_tag BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()), UNIQUE(name)` -- 태그 생성 시 요청자가 admin 권한(Permission.ADMIN)이면 is_admin_tag = TRUE로 기록하며 이후 이 값을 변경하지 않는다. -- 태그 비활성화는 is_active = FALSE로만 수행하며 hard DELETE는 사용하지 않는다. - -## Acceptance Criteria -1. Given Alembic 마이그레이션이 적용된 DB, When `tags` 테이블 스키마를 조회, Then tag_id (UUID NOT NULL PK), name (TEXT NOT NULL UNIQUE), created_by_user_id (TEXT NOT NULL REFERENCES users.id), is_admin_tag (BOOLEAN NOT NULL DEFAULT FALSE), is_active (BOOLEAN NOT NULL DEFAULT TRUE), created_at (TIMESTAMPTZ NOT NULL DEFAULT NOW()) 컬럼이 모두 존재한다. -2. Given admin 권한 토큰으로 태그 생성 요청, When 태그 행이 INSERT, Then is_admin_tag = TRUE이고 이후 권한이 변경되어도 tags.is_admin_tag는 변경되지 않는다. -3. Given 일반(non-admin) 사용자 토큰으로 태그 생성 요청, When 태그 행이 INSERT, Then is_admin_tag = FALSE이다. -4. Given 활성화된 태그, When 비활성화 요청, Then is_active = FALSE로 설정되며 해당 행은 DB에서 삭제되지 않는다. -5. Given is_active = FALSE인 태그, When 태그를 조회, Then 행이 반환되며 비활성 상태를 확인할 수 있다. - -## Impacted Area -- Docs: docs/data-model.md (tags 테이블 추가) -- Modules: - - migrations/versions/ — tags 테이블 생성 Alembic revision - - app/domain/models.py — Tag 도메인 모델 추가 - - app/repositories/portfolio_repository.py — tags CRUD 메서드 프로토콜 추가 - - app/repositories/postgres.py — tags INSERT/SELECT/UPDATE SQL 구현 -- Tests: tags 테이블 스키마 검증, is_admin_tag 스냅샷 동작 검증 - -## Notes -- created_by_user_id는 REFERENCES users(id)로 외래키를 가진다. users 테이블은 기존에 존재한다 (migration 0004). -- is_admin_tag 판단은 런타임 JOIN 없이 tags 테이블 컬럼만으로 수행된다 (dcy-002 A-1). -- name UNIQUE 제약은 TEXT 수준에서 적용되며, 소문자 정규화는 애플리케이션 레이어(REQ-004)에서 수행한다. -- Source: dcy-002 FR-1, NFR-1(부분), OQ-4 - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-003 confirm-batch-verification 완료. tags 마스터 테이블 스키마, is_admin_tag 스냅샷 동작 모든 AC 검증 통과. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-003 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-002 confirm-discovery 후 REQ 초안 작성 - -#### Intent -- dcy-002 FR-1을 구현 가능한 REQ 단위로 정규화 - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/data-model.md — tags 테이블 추가 -- Modules: - - migrations/versions/, app/domain/models.py, app/repositories/ — 최초 지정 -- Tests: - - 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입 diff --git a/docs/srs/Data/req-002_assets-tag-ids-column.md b/docs/srs/Data/req-002_assets-tag-ids-column.md deleted file mode 100644 index 4d98167..0000000 --- a/docs/srs/Data/req-002_assets-tag-ids-column.md +++ /dev/null @@ -1,222 +0,0 @@ -# REQ-002: assets.tag_ids UUID[] 컬럼 추가 - -- Status: Implemented -- Type: Data -- Priority: High -- Owner: Justin Ko - -## Intent -- assets 카탈로그 테이블에 UUID[] 배열 컬럼을 추가하여 junction 테이블 없이 tags 마스터 테이블 기반 태그 참조를 저장한다. -- admin이 카탈로그 종목에 분류 태그(예: "코스피200")를 부여할 수 있는 데이터 토대를 마련한다 (dcy-002 주요 시나리오). -- GIN 인덱스로 배열 조회 성능을 확보하고, 비활성화된 태그는 조용히 제거하지 않고 archived 상태로 반환하여 이력 추적을 가능하게 한다. -- 태그 적용 정책 검증(is_admin_tag=TRUE AND is_active=TRUE 확인, 403 반환)은 REQ-005에서 구현한다. - -## Requirement -- assets 테이블에 `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼을 추가해야 한다. -- assets.tag_ids에 GIN 인덱스(`idx_assets_tag_ids_gin`)를 생성해야 한다. -- tag_ids 배열에 is_active = FALSE인 tag_id가 포함된 경우, 해당 항목을 조용히 제거하지 않고 archived 상태임을 명시하여 반환해야 한다. - -## Acceptance Criteria -1. Given Alembic 마이그레이션이 적용된 DB, When assets 테이블 스키마를 조회, Then `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼이 존재한다. -2. Given 마이그레이션이 적용된 DB, When assets.tag_ids 인덱스를 조회, Then `idx_assets_tag_ids_gin` GIN 인덱스가 존재한다. -3. Given asset의 tag_ids 배열에 is_active = FALSE인 tag_id가 포함, When 해당 asset의 태그 목록을 조회, Then 비활성 태그가 archived 표시와 함께 반환되며 배열에서 자동 제거되지 않는다. -4. Given tag_ids = '{}' 기본값인 asset, When 태그가 적용되지 않은 상태로 조회, Then 빈 배열이 반환된다. - -## Impacted Area -- Docs: docs/data-model.md (assets 스키마에 tag_ids 컬럼 추가) -- Modules: - - migrations/versions/ — assets.tag_ids 컬럼 추가 및 GIN 인덱스 생성 Alembic revision (신규) - - app/domain/models.py — Asset 도메인 모델에 tag_ids 필드 추가 - - app/repositories/postgres.py — assets 조회 시 tag_ids 배열 archived 표시 로직 - - app/schemas/v1.py — admin asset catalog 응답 스키마에 tag_ids 필드 추가 (REQ-010 augmentation 범위와 정합) -- Tests: assets 테이블 tag_ids 스키마 검증, GIN 인덱스 존재 확인, archived 표시 동작 확인 - -## Notes -- REQ-009(assets 테이블 스키마 도입) 이후 이 REQ가 구현되어야 한다. REQ-009 Impacted Area에 본 REQ가 cross-reference된다. -- 배열 참조 무결성은 애플리케이션 레이어에서 보장한다 (dcy-002 NFR-2). PostgreSQL ARRAY FK는 미사용. -- portfolios.tag_ids 컬럼은 REQ-003에서 독립적으로 관리한다 (dcy-002 FR-3). -- Source: dcy-002 FR-2, NFR-2 - -## Change Log - -### CHG-20260521-03 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented (CHG-20260521-02로 재정의된 assets.tag_ids 범위가 BAT-012 verification에서 evidence 확보) - -#### Intent -- BAT-012 confirm-batch-verification 완료. assets.tag_ids 컬럼·GIN 인덱스·archived 표시·빈 배열 기본값 4개 AC 모두 evidence 연결됨. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-012 (release-candidate) — primary trace for assets.tag_ids 범위 - - BAT-003 trace는 CHG-20260521-02에서 무효 처리됨 (구 portfolios.tag_ids 범위, REQ-003에서 실질 커버) -- Releases: - - 신규 release 대상 (REL-002 등) — BAT-012 포함 release 발의 시 trace - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인 - -#### Validation Plan -- tests/test_asset_catalog.py::test_migration_assets_tag_ids_adds_column_and_gin_index (AC-1, AC-2) -- tests/test_asset_catalog.py::test_get_v1_assets_archived_flag_for_inactive_tag (AC-3 통합) + test_build_tag_refs_archived_for_inactive_tags (AC-3 단위) -- tests/test_asset_catalog.py::test_asset_model_tag_ids_defaults_to_empty_list + test_asset_catalog_response_schema_has_tag_ids_field (AC-4) -- 전체 회귀: 362 passed in 13.06s - -### CHG-20260521-02 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Implemented → Approved -- Requirement scope 재정의: portfolios.tag_ids → assets.tag_ids (파일명·dcy-002 FR-2 의도와 정합성 회복) - -#### Intent -- REQ-002 파일명(`req-002_assets-tag-ids-column.md`)과 dcy-002 FR-2(`assets.tag_ids UUID[]` 컬럼 추가, admin 태그만 적용 가능)에 본문을 정합화한다. -- 기존 본문은 dcy-001 분리 결정 이전 `portfolios == "asset"` 등치 시절에 작성된 흔적으로, 실질적으로 REQ-003(portfolios.tag_ids)과 동일 범위였다. -- assets.tag_ids 컬럼은 현재 마이그레이션(`migrations/versions/20260519_0017_create_assets_table.py`)에 부재하며, 본 REQ는 그 추가를 요구한다. - -#### Acceptance Criteria Delta -- Removed (구 portfolios.tag_ids 범위 — REQ-003에서 동일 컬럼 커버됨): - - AC-1 (구) portfolios.tag_ids 컬럼 존재 확인 - - AC-2 (구) portfolios.tag_ids GIN 인덱스 확인 - - AC-3 (구) portfolio archived 표시 동작 - - AC-4 (구) portfolio 빈 배열 기본값 -- Added (신 assets.tag_ids 범위): - - AC-1 assets 테이블에 `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼 존재 - - AC-2 `idx_assets_tag_ids_gin` GIN 인덱스 존재 - - AC-3 asset archived 태그 표시 동작 - - AC-4 asset 빈 배열 기본값 - -#### Impacted Area Delta -- Docs: - - Removed: docs/data-model.md (portfolios.tag_ids — REQ-003 영역) - - Added: docs/data-model.md (assets 스키마에 tag_ids 컬럼 추가) -- Modules: - - Removed: app/domain/models.py Portfolio 필드 / portfolios 배열 archived 로직 / AssetItem(v1) 스키마 — 전부 REQ-003 영역으로 이관 - - Added: - - migrations/versions/ — assets.tag_ids 컬럼 + `idx_assets_tag_ids_gin` 추가 신규 Alembic revision - - app/domain/models.py — Asset 도메인 모델에 tag_ids 필드 - - app/repositories/postgres.py — assets 조회 시 archived 표시 로직 - - app/schemas/v1.py — admin asset catalog 응답 스키마에 tag_ids 필드 -- Tests: - - Removed: portfolios 컬럼 검증 (REQ-003 영역) - - Added: assets.tag_ids 스키마·GIN 인덱스·archived 표시 검증 - -#### Delivery Trace Delta -- Batches: - - Invalidated for REQ-002 scope: BAT-003 (구 portfolios.tag_ids 범위 검증 — 신규 assets.tag_ids 범위에 대한 evidence 없음). BAT-003 verification 자체는 REQ-001/REQ-003/REQ-006 측면에서 유효. - - 신규 batch 편입 필요: assets.tag_ids 마이그레이션 + 도메인/리포지터리/스키마 반영 (TBD). -- Releases: - - REL-001: 구 REQ-002 trace는 portfolios.tag_ids 변경(REQ-003에서 실질 커버)으로 reroute. 신규 assets.tag_ids 변경분은 후속 release 대상. - -#### Revalidation Impact -- Reverification Needed: yes -- Existing Implementation Invalidated: yes — assets.tag_ids 컬럼/인덱스/도메인/스키마 모두 미구현. BAT-003 verification은 portfolios.tag_ids만 커버. -- Status Recommendation: revert-to-approved -- Follow-up Action: 신규 batch에서 assets.tag_ids 도입(마이그레이션 + 도메인 모델 + 리포지터리 + 스키마 + 테스트). REQ-009 Impacted Area에 본 REQ cross-reference 추가. - -#### Validation Plan -- Alembic 마이그레이션 적용 후 SQLAlchemy Inspector로 assets.tag_ids 컬럼 및 `idx_assets_tag_ids_gin` 존재 확인. -- pytest로 archived 표시 동작 및 빈 배열 기본값 검증. - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-003 confirm-batch-verification 완료. portfolios.tag_ids UUID[] 컬럼, GIN 인덱스, archived 표시 동작 모든 AC 검증 통과. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-003 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-002 confirm-discovery 후 REQ 초안 작성 - -#### Intent -- dcy-002 FR-2의 스키마 변경 부분을 독립 구현 단위로 분리 - -#### Acceptance Criteria Delta -- Added: - - AC-1~4 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/data-model.md — tag_ids 컬럼 추가 -- Modules: - - migrations/versions/, app/domain/models.py, app/repositories/postgres.py, app/schemas/v1.py — 최초 지정 -- Tests: - - 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입 diff --git a/docs/srs/Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md b/docs/srs/Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md deleted file mode 100644 index 316c5e7..0000000 --- a/docs/srs/Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md +++ /dev/null @@ -1,129 +0,0 @@ -# REQ-003: portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거 - -- Status: Implemented -- Type: Data -- Priority: High -- Owner: Justin Ko - -## Intent -- portfolios 테이블에 UUID[] 배열 컬럼을 추가하고 기존 자유 문자열 기반 portfolio_tags 테이블을 제거하여 tags 마스터 테이블 기반 태그 참조 구조로 전환한다. -- 이 REQ는 스키마 변경에만 집중하며, 기존 portfolio_tags 데이터 이전 절차는 REQ-006에서 다룬다. -- GIN 인덱스로 배열 조회 성능을 확보하고, 비활성 태그는 archived로 표시한다. - -## Requirement -- portfolios 테이블에 `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼을 추가해야 한다. -- portfolios.tag_ids에 GIN 인덱스를 생성해야 한다. -- REQ-006 데이터 마이그레이션 완료 후 portfolio_tags 테이블을 DROP해야 한다. -- portfolios.tag_ids에 is_active = FALSE인 tag_id가 포함된 경우, archived 상태임을 명시하여 반환해야 하며 자동 제거하지 않는다. - -## Acceptance Criteria -1. Given Alembic 마이그레이션(REQ-006 포함)이 적용된 DB, When portfolios 테이블 스키마를 조회, Then tag_ids UUID[] NOT NULL DEFAULT '{}' 컬럼이 존재한다. -2. Given 마이그레이션이 적용된 DB, When portfolios.tag_ids 인덱스를 조회, Then GIN 인덱스가 존재한다. -3. Given 마이그레이션이 적용된 DB, When DB 스키마를 조회, Then portfolio_tags 테이블이 존재하지 않는다. -4. Given portfolio의 tag_ids 배열에 is_active = FALSE인 tag_id가 포함, When 해당 portfolio의 태그 목록을 조회, Then 비활성 태그가 archived 표시와 함께 반환되며 배열에서 자동 제거되지 않는다. - -## Impacted Area -- Docs: docs/data-model.md (portfolios 스키마에 tag_ids 추가, portfolio_tags 테이블 제거 반영) -- Modules: - - migrations/versions/ — portfolios.tag_ids 컬럼 추가, GIN 인덱스 생성, portfolio_tags DROP Alembic revision - - app/domain/models.py — Portfolio 도메인 모델에 tag_ids 필드 추가 - - app/repositories/postgres.py — portfolio tag_ids 배열 조작 메서드, archived 표시 로직 - - app/schemas/ — PortfolioItem에 tag_ids 필드 추가 (해당 스키마 존재 시) -- Tests: portfolios 테이블 스키마 검증, portfolio_tags 테이블 부재 확인, GIN 인덱스 존재 확인, archived 표시 동작 - -## Notes -- REQ-006(portfolio_tags 데이터 마이그레이션)이 선행되어야 한다. portfolio_tags DROP은 데이터 마이그레이션 완료 후 수행. -- 기존 `pfhub portfolio tag add/list/remove` 명령어는 portfolio_tags 테이블에 의존하므로 이 REQ와 함께 비활성화/제거한다 (REQ-007 참조). -- Source: dcy-002 FR-3, NFR-2 - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-003 confirm-batch-verification 완료. portfolios.tag_ids, GIN 인덱스, portfolio_tags 테이블 제거, archived 표시 모든 AC 검증 통과. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-003 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-002 confirm-discovery 후 REQ 초안 작성 - -#### Intent -- dcy-002 FR-3의 스키마 변경을 독립 REQ 단위로 분리 - -#### Acceptance Criteria Delta -- Added: - - AC-1~4 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/data-model.md — portfolio_tags 제거, tag_ids 추가 -- Modules: - - migrations/versions/, app/domain/models.py, app/repositories/postgres.py, app/schemas/ — 최초 지정 -- Tests: - - 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-006 선행 의존성 확인 diff --git a/docs/srs/Data/req-009_assets-table-schema.md b/docs/srs/Data/req-009_assets-table-schema.md deleted file mode 100644 index 5bf2582..0000000 --- a/docs/srs/Data/req-009_assets-table-schema.md +++ /dev/null @@ -1,176 +0,0 @@ -# REQ-009: assets 테이블 스키마 도입 - -- Status: Implemented -- Type: Data -- Priority: High -- Owner: Justin Ko - -## Intent -- admin이 거래 가능한 종목을 중앙 관리할 수 있도록 `assets` catalog 테이블을 신규 도입한다. -- 기존 `portfolios` 테이블에 혼합된 asset 정의(symbol, market)를 독립 엔티티로 분리하여 "자산"과 "포지션" 개념을 명확히 구분한다. - -## Requirement -- `assets` 테이블을 신규 생성한다: `asset_id UUID PK`, `symbol TEXT NOT NULL UNIQUE`, `markets TEXT[] NOT NULL DEFAULT '{}'`, `display_name TEXT`, `status TEXT NOT NULL DEFAULT 'active'`, `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`, `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`. -- `assets.markets` 컬럼에 GIN 인덱스(`idx_assets_markets_gin`)를 생성한다. -- 시장 코드 허용 값을 Python Enum `MarketCode`(KRX, NXT, SOR, NASDAQ, NYSE, UPBIT, BITTHUMB)로 `app/domain/models.py`에 정의한다. API 수신 시 대문자 정규화 후 Enum 검증을 수행한다. DB CHECK constraint는 사용하지 않는다. -- `app/domain/models.py`에 `Asset` 도메인 모델(asset_id, symbol, markets, display_name, status, created_at, updated_at)을 추가한다. -- `app/repositories/portfolio_repository.py` Protocol에 asset CRUD 메서드(`get_asset_by_id`, `get_asset_by_symbol`, `list_assets`, `insert_asset`, `update_asset`)를 선언한다. -- `app/repositories/postgres.py`에 위 메서드의 Postgres 구현을 추가한다. - -## Acceptance Criteria -1. Given Alembic migration 적용 후, When `assets` 테이블 스키마를 확인, Then `asset_id UUID PK`, `symbol TEXT NOT NULL UNIQUE`, `markets TEXT[] NOT NULL DEFAULT '{}'`, `display_name TEXT`, `status TEXT NOT NULL DEFAULT 'active'`, `created_at`, `updated_at` 컬럼이 존재한다. -2. Given `assets` 테이블 존재, When 동일 `symbol`로 두 번 insert를 시도, Then UNIQUE constraint violation이 발생한다. -3. Given `assets` 테이블 존재, When 인덱스 목록을 확인, Then `idx_assets_markets_gin` GIN 인덱스가 존재한다. -4. Given `MarketCode` Enum 검증 로직, When 허용되지 않은 코드(예: 'UNKNOWN')를 전달, Then Enum 검증 오류가 반환된다. -5. Given `MarketCode` Enum 검증 로직, When 소문자 'krx'를 전달, Then 대문자 정규화 후 `MarketCode.KRX`로 검증이 통과한다. - -## Impacted Area -- Docs: - - docs/data-model.md — `assets` 엔티티 추가 (REQ-015에서 처리) -- Modules: - - `migrations/versions/` — `assets` 테이블 생성 Alembic revision 신규 추가 - - `app/domain/models.py` — `Asset` 도메인 모델, `MarketCode` Enum 추가 - - `app/repositories/portfolio_repository.py` — asset CRUD Protocol 선언 추가 - - `app/repositories/postgres.py` — asset CRUD Postgres 구현 추가 -- Tests: - - `tests/test_asset_catalog.py` (신규) — schema 존재 확인, UNIQUE constraint, GIN 인덱스, MarketCode Enum 검증 -- Cross-reference: - - REQ-002 — assets.tag_ids UUID[] 컬럼은 본 REQ의 후속 augmentation으로 별도 마이그레이션에서 추가된다 (dcy-002 FR-2 영역). - -## Notes -- REQ-010(admin CRUD), REQ-011(portfolios 재설계), REQ-013(GET /v1/assets), REQ-014(migration) 모두 이 REQ 선행 구현을 필요로 한다. -- NFR-2(성능): `assets JOIN portfolios` 쿼리 성능은 이 REQ의 GIN 인덱스와 REQ-011의 FK 인덱스로 커버한다. 구체적인 응답 시간 목표는 batch design 단계에서 정의한다. -- `assets` 테이블은 Alembic으로만 관리한다(ensure_schema() 대상 아님). runtime-flows.md Alembic/ensure_schema 경계 규칙 준수. -- assets 카탈로그에 대한 태깅(`assets.tag_ids UUID[]`)은 본 REQ의 직접 범위가 아니며 REQ-002가 별도로 다룬다. -- Source: dcy-001 FR-1 - -## Change Log - -### CHG-20260521-02 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Impacted Area cross-reference 추가: REQ-002(assets.tag_ids UUID[] 컬럼) 후속 augmentation 명시 -- Notes 보강: 카탈로그 태깅은 본 REQ 범위 밖이며 REQ-002 영역임을 명시 - -#### Intent -- REQ-002 본문 재정의(portfolios.tag_ids → assets.tag_ids) 후속으로, 본 REQ(assets 스키마 도입)와 REQ-002(assets.tag_ids 추가) 사이 trace를 명시한다. -- 향후 dcy-002 FR-2 구현 시 본 REQ 산출물(assets 테이블)이 선행 의존임을 문서 트레이스로 남긴다. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- Added: - - Cross-reference: REQ-002 (assets.tag_ids UUID[] 컬럼 후속 augmentation) -- Notes: - - 카탈로그 태깅(assets.tag_ids)이 본 REQ 직접 범위가 아님을 명시 - -#### Delivery Trace Delta -- Batches: - - No change (BAT-001 verification 영향 없음) -- Releases: - - No change (REL-001 trace 영향 없음) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no — 본 REQ의 컬럼·인덱스·도메인 모델 정의는 그대로 유효 -- Status Recommendation: keep-current (Implemented) -- Follow-up Action: REQ-002 후속 batch 편입 시 본 REQ를 선행 dependency로 표기 - -#### Validation Plan -- 문서 cross-reference 변경이므로 추가 코드/DB 검증 불요 - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-001 confirm-batch-verification 완료. assets 테이블 스키마·GIN 인덱스·MarketCode Enum 검증 통과. DB-integration ACs는 실 DB 의존 — CI에서 migration 파일 구조 검증으로 대체, release 배포 시 최종 확인 예정. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-001 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL-001 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인 - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행 - -#### Intent -- dcy-001 FR-1을 Data REQ로 분리; assets 테이블 스키마, 도메인 모델, MarketCode Enum 정의 - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/data-model.md — 최초 지정 -- Modules: - - migrations/versions/, app/domain/models.py, app/repositories/portfolio_repository.py, app/repositories/postgres.py — 최초 지정 -- Tests: - - tests/test_asset_catalog.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입 - -#### Validation Plan -- pytest로 AC-1~5 검증; DB 스키마 검사는 SQLAlchemy Inspector 또는 PostgreSQL psql로 확인 diff --git a/docs/srs/Data/req-011_portfolios-asset-id-fk-and-column-removal.md b/docs/srs/Data/req-011_portfolios-asset-id-fk-and-column-removal.md deleted file mode 100644 index 588e044..0000000 --- a/docs/srs/Data/req-011_portfolios-asset-id-fk-and-column-removal.md +++ /dev/null @@ -1,144 +0,0 @@ -# REQ-011: portfolios 테이블 asset_id FK 추가 및 symbol/market 제거 - -- Status: Implemented -- Type: Data -- Priority: High -- Owner: Justin Ko - -## Intent -- `portfolios` 테이블이 `assets` catalog를 참조하도록 재설계하여 "포지션"이 항상 검증된 catalog 항목 위에서만 존재하도록 한다. -- symbol/market 컬럼을 portfolios에서 제거하고 assets JOIN으로 대체한다. - -## Requirement -- `portfolios` 테이블에 `asset_id UUID NOT NULL` 컬럼을 추가하고 `FOREIGN KEY (asset_id) REFERENCES assets(asset_id) ON DELETE RESTRICT` DB-level FK constraint를 적용한다. -- `portfolios.symbol` 컬럼과 `portfolios.market` 컬럼을 제거한다. -- `portfolios.broker` 컬럼은 그대로 유지한다. -- `(owner_user_id, asset_id)` UNIQUE constraint는 추가하지 않는다(동일 user가 동일 asset에 복수 portfolio 허용). -- `portfolios.managed_by_daemon_id`는 기존과 동일하게 nullable soft reference로 유지한다. -- `app/domain/models.py`의 `Portfolio` 모델에서 `symbol`, `market` 필드를 제거하고 `asset_id` 참조를 추가한다. -- `app/repositories/postgres.py`의 portfolios 쿼리를 `assets JOIN portfolios` 형태로 변경한다. -- `symbol`/`market` 컬럼을 직접 참조하는 모든 쿼리, API response, CLI 출력을 수정한다(R-2 리스크 대응). - -## Acceptance Criteria -1. Given Alembic migration 적용 후, When `portfolios` 테이블 스키마를 확인, Then `asset_id UUID NOT NULL`과 `FOREIGN KEY (asset_id) REFERENCES assets(asset_id) ON DELETE RESTRICT`가 존재하고 `symbol`, `market` 컬럼이 없다. -2. Given 존재하지 않는 `asset_id`로 portfolio insert 시도, When DB에 반영, Then FK constraint violation이 발생한다. -3. Given `status='inactive'`인 asset을 참조하는 portfolio가 존재, When 해당 asset 행을 DELETE 시도, Then ON DELETE RESTRICT로 인해 거부된다. -4. Given `portfolios` 테이블 재설계 후, When 기존 portfolio 조회 API/CLI 호출, Then symbol, market 필드 참조 오류 없이 정상 응답이 반환된다. -5. Given 동일 user, 동일 asset_id, When 두 번째 portfolio를 생성, Then 성공적으로 생성된다(UNIQUE constraint 없음). - -## Impacted Area -- Docs: - - docs/data-model.md — `Portfolio` 엔티티 재정의 (REQ-015에서 처리) -- Modules: - - `migrations/versions/` — `portfolios.asset_id FK` 추가, `symbol`/`market` 컬럼 제거 Alembic revision - - `app/domain/models.py` — `Portfolio` 모델 symbol/market 필드 제거, asset_id 추가 - - `app/repositories/postgres.py` — portfolios 쿼리 assets JOIN 반영, symbol/market 참조 제거 - - `app/api/portfolios.py` — portfolio 생성/조회 response에서 symbol/market 제거 - - `app/api/v1_assets.py` — response에서 symbol/market 제거 (asset 참조로 대체) - - `app/cli/commands/portfolio.py` — portfolio 출력에서 symbol/market 제거 - - `app/schemas/` — `PortfolioListItem`, `PortfolioDetailResponse` 등 스키마 변경 -- Tests: - - 기존 테스트 suite — symbol/market 참조 테스트 갱신 - - `tests/test_asset_catalog.py` (신규) — FK constraint, RESTRICT 동작 검증 - -## Notes -- R-2(High): migration 전 `grep -rn "\.symbol\|\.market" app/`으로 참조 범위를 전수 조사해야 한다. broker는 portfolios에 유지되므로 별도 조사 불필요. -- NFR-2(성능): portfolios.asset_id FK 인덱스는 DB가 자동 생성한다. assets JOIN portfolios 쿼리 성능을 batch verification에서 확인한다. -- REQ-009 선행 필요(assets 테이블이 먼저 존재해야 FK 적용 가능). -- REQ-014(data migration)가 이 REQ의 schema 변경과 연계되어야 한다. schema 변경과 데이터 이전을 같은 Alembic revision 또는 순서 보장된 revision 시퀀스로 처리한다. -- Source: dcy-001 FR-3, R-2 - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-001 confirm-batch-verification 완료. portfolios.asset_id FK, symbol/market 컬럼 제거, 기존 조회 API 회귀 없음 확인. DB-integration ACs는 실 DB 의존 — release 배포 시 최종 확인 예정. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-001 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL-001 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인 - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행 - -#### Intent -- dcy-001 FR-3을 Data REQ로 분리; portfolios 테이블 asset_id FK 추가, symbol/market 제거 - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/data-model.md — 최초 지정 -- Modules: - - migrations/versions/, app/domain/models.py, app/repositories/postgres.py, app/api/portfolios.py, app/api/v1_assets.py, app/cli/commands/portfolio.py, app/schemas/ — 최초 지정 -- Tests: - - 기존 테스트 suite, tests/test_asset_catalog.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-009 선행 의존성 확인 - -#### Validation Plan -- pytest로 AC-1~5 검증; grep으로 symbol/market 참조 잔존 여부 확인 diff --git a/docs/srs/Data/req-046_broker-credential-daemon-id-field.md b/docs/srs/Data/req-046_broker-credential-daemon-id-field.md deleted file mode 100644 index 1e3b98e..0000000 --- a/docs/srs/Data/req-046_broker-credential-daemon-id-field.md +++ /dev/null @@ -1,118 +0,0 @@ -# REQ-046: BrokerCredential 도메인 모델 daemon_id 필드 추가 - -- Status: Implemented -- Type: Data -- Priority: High -- Owner: Justin Ko - -## Intent -- `BrokerCredential` 도메인 엔티티가 현재 `user_id` 기반으로만 인증정보를 식별한다. daemon-scoped 소유로 전환하기 위해 `daemon_id` 필드를 모델에 추가하고, repository 조회/행 변환 로직이 daemon_id 차원을 인식하도록 한다. -- 이 REQ는 도메인 모델·repository 계층의 데이터 구조 변경을 담당하며, 마이그레이션(REQ-047/REQ-048) 및 API 전달(REQ-049)·CLI(REQ-050)의 선행 조건이다. - -## Requirement -- `app/domain/models.py`의 `BrokerCredential` dataclass에 `daemon_id: str | None = None` 필드를 추가한다. -- `app/repositories/postgres.py`의 `_row_to_broker_credential` 정적 메서드가 DB 행에서 `daemon_id`를 읽어 `BrokerCredential` 인스턴스를 구성해야 한다. -- `upsert_broker_credential` 메서드 시그니처에 `daemon_id: str | None = None` 파라미터를 추가하고, INSERT/ON CONFLICT 쿼리에 `daemon_id` 컬럼 처리를 반영한다. 단, 실제 테이블 컬럼은 REQ-047 마이그레이션이 선행 적용된 후 활성화되므로 이 REQ 단독으로는 DB 컬럼이 존재하지 않는 환경에서도 하위 호환을 고려한다. -- `list_broker_credentials` 메서드에 `daemon_id: str | None = None` 파라미터를 추가하여, 값이 제공되면 daemon_id로 필터링한 결과를 반환하도록 한다. -- `delete_broker_credential` 메서드 시그니처에 `daemon_id: str | None = None` 파라미터를 추가한다. - -## Acceptance Criteria -1. Given `BrokerCredential` 인스턴스를 생성할 때 `daemon_id=None`이 기본값이면, When 인스턴스를 생성, Then `daemon_id` 필드가 `None`으로 존재한다. -2. Given `BrokerCredential` 인스턴스를 생성할 때 `daemon_id="daemon-abc"` 값을 주면, When 인스턴스를 생성, Then `daemon_id` 필드가 `"daemon-abc"`로 존재한다. -3. Given `_row_to_broker_credential` 메서드가 6컬럼 행(daemon_id 포함)을 받으면, When 변환, Then 반환 `BrokerCredential.daemon_id`가 해당 값으로 세팅된다. -4. Given `_row_to_broker_credential` 메서드가 기존 6컬럼 행(daemon_id 없음)을 받으면, When 변환, Then `BrokerCredential.daemon_id`가 `None`으로 세팅된다(하위 호환). -5. Given `list_broker_credentials(user_id="u1", daemon_id="d1")`를 호출하면, When DB에 `daemon_id="d1"` 행과 `daemon_id="d2"` 행이 있으면, Then `daemon_id="d1"` 행만 반환된다. -6. Given `list_broker_credentials(user_id="u1")` (daemon_id 미지정)를 호출하면, When DB에 여러 행이 있으면, Then 모든 행을 반환한다(기존 동작 유지, 하위 호환). - -## Impacted Area -- Docs: - - `docs/data-model.md` — `BrokerCredential` 엔티티에 `daemon_id` 필드 추가, PK 변경 예고(`REQ-047 선행 마이그레이션 후 PK=(daemon_id, broker)`) 명시. -- Modules: - - `app/domain/models.py` — `BrokerCredential` dataclass에 `daemon_id: str | None = None` 필드 추가. - - `app/repositories/postgres.py` — `_row_to_broker_credential`, `upsert_broker_credential`, `list_broker_credentials`, `delete_broker_credential` 시그니처 및 쿼리 갱신. -- Tests: - - `BrokerCredential` dataclass 필드 단위 테스트. - - `_row_to_broker_credential` 행 변환 테스트(daemon_id 포함/미포함 두 케이스). - - `list_broker_credentials` daemon_id 필터 테스트(mock 또는 메모리 repo 기반). - -## Notes -- REQ-047(마이그레이션) 선행 없이는 실제 DB 컬럼이 없으므로, 이 REQ의 구현은 DB 컬럼 유무에 관계없이 모델/메서드 시그니처만 정합화하는 것을 목표로 한다. 실제 DB 컬럼 처리는 REQ-047 이후 통합 시점에 완성된다. -- `upsert_broker_credential` 쿼리의 ON CONFLICT 절 수정은 PK 변경(REQ-047) 완료 이후 정합화한다. 따라서 이 REQ에서 upsert 쿼리의 ON CONFLICT 처리는 설계 위임(REQ-047 delivery 연계). -- `list_broker_credentials` daemon_id 필터는 `WHERE daemon_id = %s` 조건을 추가하는 방식으로 구현하며, NULL 처리는 `WHERE daemon_id IS NULL` 또는 파라미터 없음(전체 조회)으로 구분한다. -- `data-model.md`의 `BrokerCredential` 엔티티 갱신(daemon_id 필드, PK 변경 예고)은 이 REQ 구현 시 함께 반영한다. -- Source: dcy-010 FR-1, §4 C1. - -## Change Log - -### CHG-20260628-01 -- Date: 2026-06-28 -- Author: Justin Ko - -#### Change Summary -- 신규 생성 (Proposed) — dcy-010 FR-1 산출물. - -#### Intent -- dcy-010 confirmed Discovery의 FR-1(daemon_id 차원 도입)을 도메인 모델·repository 계층 변경으로 분리하여 REQ로 정규화한다. - -#### Acceptance Criteria Delta -- Added: - - AC-1 ~ AC-6 전체 (신규 초안) -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: - - `docs/srs/Data/req-046_broker-credential-daemon-id-field.md` 신규 생성 - - `docs/data-model.md` 갱신 예고 -- Modules: - - `app/domain/models.py`, `app/repositories/postgres.py` 최초 지정 -- Tests: - - `BrokerCredential` 단위·행 변환·필터 테스트 신규 - -#### Delivery Trace Delta -- Batches: 미정 (suggest-batch-reqs 대기) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 배정. REQ-047 마이그레이션과 delivery 연계. - -#### Validation Plan -- pytest로 AC-1~6 각 케이스 확인. `_row_to_broker_credential` 하위호환(AC-4) 포함. - -### CHG-20260628-02 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req: `Proposed` → `Approved`. dcy-010 FR-1 추적 확인, AC testable, 하위호환(AC-4) 적절. REQ-047 선행 의존 명시됨. blocker 없음. - -### CHG-20260628-03 -- Date: 2026-06-28 -- Author: 팀장(Claude) - -#### Change Summary -- confirm-req-implemented: `Approved` → `Implemented`. PR #17 머지(mergeCommit d4acfd4), BAT-020, QC PASS-WITH-NOTES(blocker 0), 테스트 통과. - -#### Intent -- BAT-020 delivery(PR #17 머지)에서 AC-1~6 전 항목 구현 및 테스트 통과가 확인되어 Implemented로 전환한다. - -#### Acceptance Criteria Delta -- Added: 없음 -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- 없음 (상태 전환만) - -#### Delivery Trace Delta -- Batches: BAT-020 (PR #17, mergeCommit d4acfd4) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented - -#### Validation Plan -- BAT-020 verification 문서 및 QC PASS-WITH-NOTES 결과(blocker 0)로 AC-1~6 근거 확보됨. diff --git a/docs/srs/Documentation/req-008_tag-baseline-docs-update.md b/docs/srs/Documentation/req-008_tag-baseline-docs-update.md deleted file mode 100644 index 7672307..0000000 --- a/docs/srs/Documentation/req-008_tag-baseline-docs-update.md +++ /dev/null @@ -1,129 +0,0 @@ -# REQ-008: 태그 모델 도입 baseline 문서 갱신 - -- Status: Implemented -- Type: Documentation -- Priority: Low -- Owner: Justin Ko - -## Intent -- dcy-002에서 정의한 tags 마스터 테이블, UUID[] 배열 방식, HTTP API, CLI 변경 내용을 project-structure, runtime-flows, data-model, interface-contract 문서에 반영하여 baseline을 최신 상태로 유지한다. -- 구현 완료 후 문서가 실제 코드와 일치하지 않으면 다음 Discovery/REQ 작성 시 잘못된 baseline 참조로 이어질 수 있으므로 구현 완료 시점에 함께 갱신한다. - -## Requirement -- docs/data-model.md에 tags 테이블 스키마, assets(portfolios).tag_ids UUID[] 컬럼, portfolio_tags 테이블 제거를 반영해야 한다. -- docs/interface-contract.md에 /v1/tags (POST/GET/PATCH), /v1/assets/{asset_id}/tags (POST/DELETE), /v1/portfolios/{portfolio_id}/tags (POST/DELETE) 엔드포인트 명세를 추가해야 한다. -- docs/project-structure.md에 pfhub tag 커맨드 그룹 추가, app/api/ 태그 모듈 추가, pfhub portfolio tag 제거를 반영해야 한다. -- docs/runtime-flows.md에 tag 관련 CLI 흐름(create, list, deactivate, apply, remove)과 HTTP 흐름을 추가해야 한다. - -## Acceptance Criteria -1. Given 구현 완료 후, When docs/data-model.md를 검토, Then tags 테이블 스키마(tag_id, name, created_by_user_id, is_admin_tag, is_active, created_at), portfolios.tag_ids UUID[] 컬럼, portfolio_tags 테이블 제거가 반영되어 있다. -2. Given 구현 완료 후, When docs/interface-contract.md를 검토, Then /v1/tags (POST/GET/PATCH) 및 /v1/assets/{id}/tags, /v1/portfolios/{id}/tags (POST/DELETE) 엔드포인트 명세가 포함되어 있다. -3. Given 구현 완료 후, When docs/project-structure.md를 검토, Then pfhub tag 커맨드 그룹이 CLI Command groups에 포함되고 app/api/ 섹션에 태그 API 모듈이 반영되어 있다. -4. Given 구현 완료 후, When docs/runtime-flows.md를 검토, Then pfhub tag 커맨드 실행 흐름과 /v1/tags 관련 HTTP 흐름이 문서화되어 있다. - -## Impacted Area -- Docs: - - docs/data-model.md - - docs/interface-contract.md - - docs/project-structure.md - - docs/runtime-flows.md -- Modules: 없음 (문서 전용) -- Tests: 없음 (문서 검토로 확인) - -## Notes -- 이 REQ는 REQ-001~007 구현 완료 후 마지막으로 수행한다. -- data-model.md와 interface-contract.md는 dcy-002 영향 범위에 명시된 문서이다. -- project-structure.md와 runtime-flows.md는 baseline 참조 문서로, 신규 CLI 커맨드 그룹과 API 흐름이 추가되면 함께 갱신해야 한다 (docs/project-structure.md Update Triggers: "새 top-level runtime 또는 domain 모듈이 추가되었을 때"). -- Source: dcy-002 영향 범위, skill 규칙 0(baseline 문서 갱신 REQ) - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-005 confirm-batch-verification 완료. data-model/interface-contract/project-structure/runtime-flows 4개 문서 tags 모델 반영 확인. 모든 AC 검증 통과. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-005 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-002 confirm-discovery 후 REQ 초안 작성 - -#### Intent -- dcy-002 영향 범위의 문서 갱신 요구를 Documentation REQ로 분리; baseline 문서 갱신 의무 명시 - -#### Acceptance Criteria Delta -- Added: - - AC-1~4 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/data-model.md, docs/interface-contract.md, docs/project-structure.md, docs/runtime-flows.md — 최초 지정 -- Modules: - - 없음 -- Tests: - - 없음 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: REQ-001~007 구현 완료 후 일괄 반영 diff --git a/docs/srs/Documentation/req-015_asset-catalog-baseline-docs-update.md b/docs/srs/Documentation/req-015_asset-catalog-baseline-docs-update.md deleted file mode 100644 index 0c71b5a..0000000 --- a/docs/srs/Documentation/req-015_asset-catalog-baseline-docs-update.md +++ /dev/null @@ -1,131 +0,0 @@ -# REQ-015: asset catalog 도입 baseline 문서 갱신 - -- Status: Implemented -- Type: Documentation -- Priority: Low -- Owner: Justin Ko - -## Intent -- dcy-001에서 정의한 assets 테이블, portfolios 재설계, admin CRUD API/CLI, GET /v1/assets 토큰 분기 변경 내용을 baseline 문서에 반영하여 다음 Discovery/REQ 작성 시 잘못된 baseline 참조를 방지한다. - -## Requirement -- `docs/data-model.md`에 `assets` 엔티티(asset_id, symbol, markets, display_name, status, created_at, updated_at)를 추가하고 `portfolios` 엔티티에서 symbol/market 제거 및 asset_id FK 참조를 반영한다. -- `docs/interface-contract.md`에 `POST /v1/assets`, `PATCH /v1/assets/{asset_id}`, `PATCH /v1/assets/{asset_id}/deactivate` 엔드포인트 명세를 추가하고 `GET /v1/assets`의 토큰 타입별 분기 동작을 갱신한다. -- `docs/runtime-flows.md`의 "Flow: v1 API — GET /v1/assets" 섹션을 service-token-only에서 토큰 타입 분기 구조로 교체하고, admin asset CRUD HTTP 흐름을 신규 추가한다. -- `docs/project-structure.md`의 `app/cli/` Command groups 목록에 `asset` 커맨드 그룹을 추가하고, `app/api/` v1 route surfaces에 admin asset CRUD 엔드포인트를 반영한다. - -## Acceptance Criteria -1. Given 구현 완료 후, When `docs/data-model.md`를 검토, Then `assets` 테이블 스키마(asset_id, symbol UNIQUE, markets TEXT[], display_name, status)와 `portfolios`의 asset_id FK 참조, symbol/market 제거가 반영되어 있다. -2. Given 구현 완료 후, When `docs/interface-contract.md`를 검토, Then `POST/PATCH /v1/assets` 및 `PATCH /v1/assets/{id}/deactivate` 엔드포인트 명세, GET /v1/assets 토큰 타입별 분기 동작이 포함되어 있다. -3. Given 구현 완료 후, When `docs/runtime-flows.md`를 검토, Then "GET /v1/assets" 플로우가 토큰 타입 분기 구조(service/user/admin)로 갱신되어 있고 admin asset CRUD 흐름이 추가되어 있다. -4. Given 구현 완료 후, When `docs/project-structure.md`를 검토, Then `app/cli/` Command groups에 `asset`이 포함되고 `app/api/` v1 route surfaces에 admin asset CRUD 엔드포인트가 반영되어 있다. - -## Impacted Area -- Docs: - - `docs/data-model.md` - - `docs/interface-contract.md` - - `docs/runtime-flows.md` - - `docs/project-structure.md` -- Modules: 없음 (문서 전용) -- Tests: 없음 (문서 검토로 확인) - -## Notes -- 이 REQ는 REQ-009~014 구현 완료 후 마지막으로 수행한다. -- `docs/runtime-flows.md` Update Triggers: "새 top-level runtime 또는 domain 모듈이 추가되었을 때" — asset CLI 그룹 추가, GET /v1/assets 토큰 분기 재정의 모두 해당. -- `docs/project-structure.md` Update Triggers: "새 top-level runtime 또는 domain 모듈이 추가되었을 때" — `app/cli/commands/asset.py` 신규 추가 해당. -- Source: dcy-001 In Scope(baseline 문서 갱신) - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-002 confirm-batch-verification 완료. data-model/interface-contract/runtime-flows/project-structure 4개 문서 asset catalog 반영 확인. 모든 AC 검증 통과. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-002 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행 - -#### Intent -- dcy-001 구현 범위의 baseline 문서 갱신 요구를 Documentation REQ로 분리 - -#### Acceptance Criteria Delta -- Added: - - AC-1~4 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/data-model.md, docs/interface-contract.md, docs/runtime-flows.md, docs/project-structure.md — 최초 지정 -- Modules: - - 없음 -- Tests: - - 없음 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: REQ-009~014 구현 완료 후 일괄 반영 - -#### Validation Plan -- 각 대상 문서를 직접 검토하여 AC-1~4 기술 내용 포함 여부 확인 diff --git a/docs/srs/Documentation/req-019_token-surface-baseline-docs-consistency.md b/docs/srs/Documentation/req-019_token-surface-baseline-docs-consistency.md deleted file mode 100644 index f8ef2cc..0000000 --- a/docs/srs/Documentation/req-019_token-surface-baseline-docs-consistency.md +++ /dev/null @@ -1,136 +0,0 @@ -# REQ-019: 토큰 surface baseline 문서 정합성 회복 - -- Status: Implemented -- Type: Documentation -- Priority: Medium -- Owner: Justin Ko - -## Intent -- 현행 baseline 문서가 토큰 surface 결함(rebind 명세 누락, AuthContext daemon_id 필드 누락) + 본 batch 변경 사항(client token 폐지, rotate index 보강, HTTP daemon_id forward, auth_tokens 인덱스 변경)을 모두 반영해 후속 REQ·batch가 단일 source of truth로 사용할 수 있도록 정합성을 회복한다. -- dcy-004 FR-4와 FR-10을 단일 REQ로 통합 처리한다(둘 다 baseline 갱신 작업). - -## Requirement -- `docs/interface-contract.md` 갱신: - - `pfhub service-token rebind` CLI 명세 추가([app/cli/commands/service_tokens.py:119-141](app/cli/commands/service_tokens.py#L119-L141) 구현 반영) - - `POST /auth/tokens`(client token 발급) 명세 제거(REQ-016 처리 반영) - - `POST /auth/service-tokens` body `daemon_id` forward 동작 명시(REQ-018 처리 반영) -- `docs/runtime-flows.md` 갱신: - - `AuthContext` 필드 목록에 `daemon_id` 추가([docs/runtime-flows.md:76](docs/runtime-flows.md#L76) 보강) - - 토큰 surface 3종(session/service/env)으로 축소 반영 - - rotate runtime_id 갱신 + daemon_id invariant 보존 흐름 명시(REQ-017 처리 반영) -- `docs/data-model.md` 갱신: - - `auth_tokens` 인덱스 변경(`idx_auth_tokens_service_name_unique`에 `AND revoked_at IS NULL` 조건 추가) 반영 - - 토큰 타입 surface 정리(client 제거) -- `docs/project-structure.md` 갱신: - - 신규 마이그레이션 스크립트 또는 CLI 서브커맨드(REQ-022 자동 revoke, REQ-023 NULL daemon 마이그레이션) 추가에 따른 패키지 책임 갱신 — 필요 시 -- 갱신은 baseline 정합성 회복에 한정한다. 새 정책 정의(매트릭스, TTL 정책, SOP)는 REQ-020/021/024가 별도로 다룬다. - -## Acceptance Criteria -1. Given `grep -n "service-token rebind" docs/interface-contract.md`, When 본 REQ 구현 후, Then 명세 entry가 1건 이상 발견된다. -2. Given `grep -n "POST /auth/tokens" docs/interface-contract.md`, When 본 REQ 구현 후, Then client token 발급 명세가 제거되어 0건이거나 deprecated 표시로만 잔존한다. -3. Given `grep -n "AuthContext.*daemon_id\|daemon_id.*AuthContext" docs/runtime-flows.md`, When 본 REQ 구현 후, Then `AuthContext` 필드 목록에 `daemon_id`가 명시된다. -4. Given `grep -n "revoked_at IS NULL\|active service token" docs/data-model.md`, When 본 REQ 구현 후, Then `idx_auth_tokens_service_name_unique` 변경 사항이 반영되어 발견된다. -5. Given 사람 리뷰, When baseline 4종 문서를 점검, Then client token 잔재(예: `token_type='client'` 발급 경로 언급)가 모두 제거되거나 deprecated 표시되어 있다. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` - - `docs/runtime-flows.md` - - `docs/data-model.md` - - `docs/project-structure.md` (필요 시) -- Modules: 없음 (문서 전용) -- Tests: 없음 (문서 전용. AC-1~5는 grep + 사람 리뷰) - -## Notes -- 본 REQ는 REQ-016/017/018/022/023 코드 변경 후 또는 동일 batch에서 함께 처리하는 것이 적절하다. baseline 문서가 코드 변경 사항을 반영하지 않으면 후속 REQ/batch가 잘못된 source of truth를 참조한다. -- 본 REQ는 surface 정합성 회복만 다루고 신규 정책 명세는 다음 REQ에서 처리: - - REQ-020: 토큰 발급 권한 매트릭스 - - REQ-021: 토큰 만료·회전 정책 - - REQ-024: env admin token 운영 SOP -- Source: dcy-004 FR-4, dcy-004 FR-10. - -## 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-4 + FR-10 통합 산출물. - -#### Intent -- 토큰 surface baseline 4종 문서의 정합성 회복(rebind 명세 누락, AuthContext daemon_id 누락, client token 잔재 제거, auth_tokens 인덱스 변경 반영). - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md, docs/runtime-flows.md, docs/data-model.md, docs/project-structure.md — 최초 지정 -- Modules: 없음 -- Tests: 없음 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-016/017/018/022/023 코드 변경 후 또는 동일 batch에서 처리 - -#### Validation Plan -- grep 명령 + 사람 리뷰로 AC-1~5 검증 diff --git a/docs/srs/Documentation/req-020_token-issuance-authorization-matrix.md b/docs/srs/Documentation/req-020_token-issuance-authorization-matrix.md deleted file mode 100644 index 569ad74..0000000 --- a/docs/srs/Documentation/req-020_token-issuance-authorization-matrix.md +++ /dev/null @@ -1,119 +0,0 @@ -# REQ-020: 토큰 발급 권한 매트릭스 정의 - -- Status: Implemented -- Type: Documentation -- Priority: Medium -- Owner: Justin Ko - -## Intent -- 누가 어떤 token type을 발급/회전/폐기할 수 있는가가 현재 코드에 흩어진 dependency(require_admin/require_read)로만 표현되고 baseline 문서에 없어 운영 일관성과 정책 추적이 어렵다. 토큰 발급 권한 매트릭스를 baseline에 명시해 정책 source of truth를 확보한다. - -## Requirement -- `docs/interface-contract.md`에 "토큰 발급 권한 매트릭스" 섹션을 추가한다. 매트릭스 차원: token type(session / service / env) × 발급 동사(issue / rotate / rebind / revoke / list) × 발급자 권한(admin / trade-write / read). -- client token은 REQ-016에서 폐지되므로 표에서 제거한다. -- admin 자기 발급 정책 명시: admin이 본인 user_id 대상으로 daemon-bound 또는 unbound service token 발급 가능(dcy-004 OQ-4 결정). 매트릭스 주석에 "관행상 최소 권한 발급 권장" 명시. -- 매트릭스 위반 호출은 HTTP 403 fail-closed로 통일한다(dcy-004 NFR-4). -- 매트릭스 정의는 현행 코드 dependency(require_admin/require_read, AuthService 발급 메서드의 분기 로직)와 1:1 매핑되어야 한다. 불일치 시 본 REQ가 우선이며 코드 보정은 REQ-018 또는 별도 후속에서 처리. - -## Acceptance Criteria -1. Given `docs/interface-contract.md` 검토, When 본 REQ 구현 후, Then "토큰 발급 권한 매트릭스" 섹션이 존재하고 token type 3종(session/service/env) × 발급 동사 5종(issue/rotate/rebind/revoke/list) × 발급자 권한 3종(admin/trade-write/read) 조합이 모두 명시된다. -2. Given 매트릭스의 admin 자기 발급 정책 항목, When 본 REQ 구현 후 검토, Then admin이 본인 user_id로 service token 발급 가능함이 명시되고 최소 권한 권장 주석이 포함된다. -3. Given 매트릭스 위반 호출 정책, When 본 REQ 구현 후 검토, Then 모든 위반 호출에 HTTP 403 fail-closed 응답이 명시된다. -4. Given 매트릭스와 현행 코드 dependency(`require_admin`/`require_read`) 매핑, When 사람 리뷰, Then 1:1 매핑이 확인되고 불일치 항목은 별도 follow-up으로 기록된다. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — "토큰 발급 권한 매트릭스" 섹션 신규 추가 -- Modules: 없음 (문서 전용. 코드 dependency 불일치 발견 시 별도 REQ 또는 REQ-018 보강) -- Tests: 없음 (문서 전용. NFR-4 위반 호출 403 검증은 REQ-016/018/022 등 코드 REQ의 회귀 테스트에 포함) - -## Notes -- 본 REQ는 매트릭스 baseline 문서화만 다룬다. 매트릭스 위반 호출의 코드 레벨 enforcement는 기존 dependency 체계로 충분하며 본 REQ에서는 정책 정의만 한다. -- env admin token의 회전 주기·강도 가이드는 REQ-024(env admin SOP)가 별도로 다룬다. 본 REQ는 발급 권한 매트릭스에만 집중한다. -- Source: dcy-004 FR-5, dcy-004 NFR-4, dcy-004 OQ-4 결정. - -## 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-5 산출물. - -#### Intent -- 토큰 발급 권한 정책 source of truth 확보 + admin 자기 발급 허용 정책 baseline 명시. - -#### Acceptance Criteria Delta -- Added: - - AC-1~4 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md — 최초 지정 -- Modules: 없음 -- Tests: 없음 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-019 baseline 정합 작업과 동일 batch 권장 - -#### Validation Plan -- 사람 리뷰로 AC-1~4 검증; 매트릭스-코드 dependency 매핑 사전 검토 diff --git a/docs/srs/Documentation/req-021_token-expiry-and-rotation-policy.md b/docs/srs/Documentation/req-021_token-expiry-and-rotation-policy.md deleted file mode 100644 index c904504..0000000 --- a/docs/srs/Documentation/req-021_token-expiry-and-rotation-policy.md +++ /dev/null @@ -1,124 +0,0 @@ -# REQ-021: 토큰 만료·회전 정책 정의 - -- Status: Implemented -- Type: Documentation -- Priority: Medium -- Owner: Justin Ko - -## Intent -- 현행 토큰 만료 정책이 코드에 흩어져 있고(session은 settings.session_token_ttl_hours, service는 365일 default, `--no-expiry` 옵션) baseline 정책 명세가 없어 운영 일관성이 떨어진다. 토큰 type별 기본 TTL과 `no_expiry` 허용 범위·강제 회전 정책을 baseline에 명시한다. -- dcy-004 OQ-5 결정에 따라 현행 TTL 유지로 시작하되 정책 표면을 명시해 후속 변경 추적 기반을 확보한다. - -## Requirement -- `docs/runtime-flows.md`(또는 `docs/interface-contract.md`)에 "토큰 만료·회전 정책" 섹션을 추가한다. 명시 항목: - - **type별 기본 TTL**: session = `settings.session_token_ttl_hours`(현행 값 유지), service = 365일 default, env = 만료 없음 - - **no_expiry 허용 범위**: service token 한정. session/(client는 폐지됨) 발급 경로는 모두 거부 - - **강제 회전 주기**: 본 정책에서는 강제 주기 두지 않음. env admin token의 권장 회전 주기는 REQ-024(env admin SOP)에서 별도 정의 - - **정책 위반 발급 처리**: `no_expiry`를 session에 요청하면 HTTP 422 또는 ValueError로 거부 -- 정책 명세는 현행 코드 구현과 일치해야 한다(`AuthService.login`, `AuthService.issue_service_token`의 만료 처리 로직 reference 명시). -- 본 REQ는 정책 baseline 명시에 집중하고 코드 enforcement(no_expiry session 거부 등)가 누락된 경우 follow-up REQ로 분리한다. - -## Acceptance Criteria -1. Given `docs/runtime-flows.md` 검토, When 본 REQ 구현 후, Then "토큰 만료·회전 정책" 섹션이 존재하고 type별 기본 TTL이 명시된다. -2. Given 정책 검토, When `no_expiry` 허용 범위 항목을 확인, Then service 한정으로 명시되고 session/client는 거부됨이 명시된다. -3. Given 정책 검토, When 강제 회전 주기 항목을 확인, Then 본 정책에서는 두지 않으며 env admin은 REQ-024에서 별도 정의됨이 명시된다. -4. Given 정책 위반 발급 시도(예: `no_expiry`를 session에 요청), When 정책 명세 검토, Then HTTP 422 또는 ValueError 거부 응답이 명시된다. -5. Given 정책 명세와 현행 코드 매핑, When `app/services/auth_service.py`의 `login`(session 만료)·`issue_service_token`(service 만료·no_expiry) 분기와 비교, Then 정책 본문이 코드와 일치하거나 불일치 항목이 follow-up으로 기록된다. - -## Impacted Area -- Docs: - - `docs/runtime-flows.md` — "토큰 만료·회전 정책" 섹션 신규 추가 (또는 `docs/interface-contract.md`에 추가 후 runtime-flows에서 참조) -- Modules: 없음 (정책 baseline 문서 정의. 코드 enforcement는 현행 그대로 유지) -- Tests: 없음 (문서 전용. AC-4 정책 위반 거부 회귀는 향후 코드 변경 시점에 추가) - -## Notes -- dcy-004 OQ-5 결정으로 현행 TTL 유지가 채택됐다. 본 REQ는 정책 명세 baseline화를 통해 후속 변경 추적 기반을 확보한다. -- session 만료 시 in-flight 거래 영향 같은 정책 세부 사항은 본 REQ 범위 밖이며 release plan 또는 별도 운영 Discovery에서 다룬다. -- 강제 회전 주기(분기/반기/연간)는 env admin token에 한정해 REQ-024에서 다루며, session/service에는 강제 주기를 두지 않는다. -- Source: dcy-004 FR-6, dcy-004 OQ-5 결정. - -## 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-6 산출물. - -#### Intent -- 토큰 만료 정책 source of truth 확보 + 현행 TTL 유지 정책 baseline 명시. - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: docs/runtime-flows.md — 최초 지정 -- Modules: 없음 -- Tests: 없음 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-019/020과 동일 batch 권장 - -#### Validation Plan -- 사람 리뷰로 AC-1~5 검증 diff --git a/docs/srs/Documentation/req-024_env-admin-token-rotation-sop.md b/docs/srs/Documentation/req-024_env-admin-token-rotation-sop.md deleted file mode 100644 index cf3e983..0000000 --- a/docs/srs/Documentation/req-024_env-admin-token-rotation-sop.md +++ /dev/null @@ -1,133 +0,0 @@ -# REQ-024: env admin token 운영 SOP 정의 - -- Status: Implemented -- Type: Documentation -- Priority: Medium -- Owner: Justin Ko - -## Intent -- 현행 env admin token(`PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수)의 회전·폐기·강도 기준이 baseline에 명시되지 않아 운영 단계에서 회전이 잘못 적용되면 운영자 admin 접근이 차단될 수 있는 운영 리스크가 있다(dcy-004 R-4). -- dcy-004 OQ-6 결정에 따라 분기(3개월) 권장 회전 주기, 강제 회전 없음 정책으로 SOP를 baseline에 명시한다. - -## Requirement -- `docs/runtime-flows.md` 또는 `README.md`에 "env admin token 운영 SOP" 섹션을 추가한다. 명시 항목: - - **권장 회전 주기**: 분기(3개월). 강제 회전·만료 알람 없음. 운영자 자율로 회전 시점 결정. - - **회전 트리거 조건**: (a) 정기(분기), (b) token leak 의심, (c) 운영자 교체·퇴사, (d) 보안 사고 대응. - - **다단계 회전 절차**: - 1. 강한 무작위 token 생성: `python3 -c "import secrets; print(secrets.token_urlsafe(48))"` - 2. `PORTFOLIOHUB_ADMIN_TOKENS`에 신규 token을 콤마 분리로 추가(다중 token 동시 운영) - 3. service rolling restart - 4. 신규 token으로 admin 호출 검증(예: `GET /health`, admin 작업 sanity check) - 5. 구 token을 `PORTFOLIOHUB_ADMIN_TOKENS`에서 제거 - 6. service rolling restart(구 token 무효화) - 7. evidence 기록(회전 일자, 운영자, 신규 token 강도 확인, sanity check 결과) - - **rollback 절차**: 회전 실패 시 환경 변수에 구 token 재추가 후 rolling restart. fallback admin 접근 경로(예: DB 직접 접근으로 token 재생성) 보장. - - **운영자 권한 매트릭스**: env admin token 추가/제거는 인프라 운영자 권한. release approver 또는 운영자 교체 시 SOP를 참조. - - **token 강도 가이드**: 최소 48-byte URL-safe 무작위. 평문 단어·예측 가능한 패턴(예: `admin-token-789`) 금지. -- SOP는 dev/test 환경과 prod 환경 모두에 적용되며, prod 환경의 약한 placeholder token이 발견된 경우(R-7) 본 SOP를 즉시 적용하도록 권고한다. - -## Acceptance Criteria -1. Given `docs/runtime-flows.md` 또는 `README.md` 검토, When 본 REQ 구현 후, Then "env admin token 운영 SOP" 섹션이 존재하고 권장 회전 주기 + 강제 회전 없음 정책이 명시된다. -2. Given SOP의 다단계 회전 절차 항목, When 검토, Then 7단계(token 생성 → 추가 → restart → 검증 → 구 token 제거 → restart → evidence 기록)가 명시된다. -3. Given SOP의 rollback 절차 항목, When 검토, Then 회전 실패 시 환경 변수 복구 + fallback admin 접근 경로 명시. -4. Given SOP의 token 강도 가이드, When 검토, Then 최소 48-byte URL-safe 무작위 요구 + 약한 placeholder 금지 명시. -5. Given staging 환경에서 SOP에 따라 dry-run 회전 실행, When 7단계 절차 수행, Then 각 단계가 실제 환경에서 적용 가능함이 확인된다(release plan verification). - -## Impacted Area -- Docs: - - `docs/runtime-flows.md` — "env admin token 운영 SOP" 섹션 신규 추가 (또는 `README.md`에 추가 후 runtime-flows에서 참조) - - `README.md` — daemon provisioning runbook에 env admin token SOP 링크 추가 (선택) -- Modules: 없음 (정책 baseline 문서. 코드 변경 없음. 환경 변수 자체는 운영 작업) -- Tests: 없음 (문서 전용. AC-5는 release plan verification 영역) - -## Notes -- dcy-004 R-7(prod 환경 약한 admin token)은 본 SOP의 첫 번째 적용 사례로, dcy-004 confirm-discovery 시점 또는 이전에 운영자가 본 SOP의 7단계 절차를 적용해 `admin-token-789`를 강한 무작위 값으로 교체하기를 권고. hotfix 추적은 dcy-004 §10 DATA_PROD_ADMIN_TOKEN_HOTFIX 항목으로 관리. -- env admin token 다중 운영 허용은 현행 `app/core/auth.py:_resolve_permission`이 set 기반으로 처리하므로 코드 변경 없이 가능([app/core/auth.py:43-50](app/core/auth.py#L43-L50)). -- 본 SOP는 prod 환경 적용을 우선으로 작성됐으나 dev/test 환경에서도 약한 placeholder 사용을 금지한다. dev 환경 사용자는 `secrets.token_urlsafe` 또는 동등한 강도의 값을 생성해 .env 파일에 보관. -- Source: dcy-004 FR-9, dcy-004 OQ-6 결정. - -## 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-9 산출물. - -#### Intent -- env admin token 운영 SOP source of truth 확보 + 분기 권장 회전 주기 baseline 명시 + dcy-004 R-7 hotfix 표준 절차 정의. - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: docs/runtime-flows.md, README.md — 최초 지정 -- Modules: 없음 -- Tests: 없음 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정). dcy-004 R-7 hotfix는 본 REQ confirm-req 이전이라도 본 SOP 절차로 즉시 적용 권장. - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-019/020/021과 동일 batch 권장 - -#### Validation Plan -- 사람 리뷰로 AC-1~4 검증; AC-5는 staging 환경 dry-run으로 release plan 검증 diff --git a/docs/srs/Documentation/req-032_portfolio-v1-baseline-docs-update.md b/docs/srs/Documentation/req-032_portfolio-v1-baseline-docs-update.md deleted file mode 100644 index 6efc9b6..0000000 --- a/docs/srs/Documentation/req-032_portfolio-v1-baseline-docs-update.md +++ /dev/null @@ -1,203 +0,0 @@ -# REQ-032: portfolio v1 baseline 문서 갱신 - -- Status: Implemented -- Type: Documentation -- Priority: Medium -- Owner: Justin Ko - -## Intent -- REQ-025~031의 v1 portfolio mutation 라우트 신설 + path 분리 + soft delete + 토큰 분기 정책을 baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md`, `docs/project-structure.md`)에 일괄 반영해 후속 batch/release가 단일 source of truth로 사용할 수 있도록 정합성을 회복한다. -- dcy-001 FR-5(REQ-013) Deprecated 후속 정리도 함께 반영 — `GET /v1/assets`는 catalog 전용 의미로 baseline에 복원. - -## Requirement -- `docs/interface-contract.md` 갱신: - - `/v1/portfolios*` 라우트 명세 추가: POST(REQ-025), PATCH(REQ-026), DELETE(REQ-027), GET(REQ-028), `/ledger/trades`(REQ-029), `/tags`(REQ-030), `/comments`(REQ-031) - - 각 라우트의 토큰 분기 규칙(`token_type ∈ {service, client}` 거부 + `permission` 기준 분기) 일관 명시 - - `PortfolioItem` 응답 예시에 `symbol` 필드를 포함하고, 특히 `GET /v1/portfolios` 는 `portfolios JOIN assets` 기반 응답으로 `assets.symbol` 을 노출한다는 점을 명시 - - 구 path `POST /v1/assets/{asset_id}/ledger/trades` 제거 + 동일 batch release 정책 명시 - - `GET /v1/assets`는 dcy-001 FR-2 admin catalog 조회 전용으로 의미 복원 (REQ-013 Deprecated 정합) - - daemon authorization model 갱신 (3단계 fail-closed) -- `docs/runtime-flows.md` 갱신: - - portfolio mutation flow(create + catalog 검증 + ownership 검증 + disabled 보호) - - GET /v1/portfolios 토큰 분기 조회 flow - - ledger trade path 이전 흐름 + daemon assignment 3단계 인가 - - soft delete flow (status 전환 + daemon 자동 해제 + 의존 데이터 보존 + 복구 경로) -- `docs/data-model.md` 갱신: - - Portfolio 엔티티의 `status` 필드 `disabled` 의미 명시 + 의존 데이터 보존 정책 - - dcy-001 catalog/portfolio 분리 모델 baseline 반영(REQ-009/011 정합 — dcy-001 REQ-015에서 처리될 수 있음, 본 REQ는 portfolio v1 측면만 보강) -- `docs/project-structure.md` 갱신: - - `app/api/v1_portfolios.py` 신규 파일 추가 + 라우터 책임 갱신 - - `app/api/v1_ledger.py` 제거 또는 v1_portfolios.py에 흡수 반영 -- 갱신은 v1 portfolio mutation surface 정합성 회복에 집중한다. dcy-002 tag 정책의 baseline 반영은 dcy-002 REQ-008이 별도로 다루며, 본 REQ는 portfolio tag 적용 라우트 명세만 포함한다. - -## Acceptance Criteria -1. Given `grep -n "POST /v1/portfolios\|PATCH /v1/portfolios\|DELETE /v1/portfolios\|GET /v1/portfolios" docs/interface-contract.md`, When 본 REQ 구현 후, Then 각 라우트 명세 entry가 1건 이상 발견된다. -2. Given `grep -n "POST /v1/assets/{asset_id}/ledger/trades" docs/interface-contract.md`, When 본 REQ 구현 후, Then 구 path 명세가 제거되어 0건이거나 "deprecated/removed" 표시로만 잔존. -3. Given `grep -n "GET /v1/portfolios.*?market\|managed_by_daemon_id" docs/runtime-flows.md`, When 본 REQ 구현 후, Then `GET /v1/portfolios` 토큰 분기 flow + daemon-scoped 필터가 명시된다. -4. Given `grep -n "soft delete\|status='disabled'\|disabled" docs/data-model.md`, When 본 REQ 구현 후, Then Portfolio.status='disabled' 의미와 의존 데이터 보존 정책이 반영된다. -5. Given `grep -n "v1_portfolios.py" docs/project-structure.md`, When 본 REQ 구현 후, Then 신규 라우터 파일이 명시된다. -6. Given 사람 리뷰, When baseline 4종 문서를 점검, Then REQ-025~031의 v1 portfolio mutation 정책과 충돌하는 잔재가 없다. -7. Given `grep -n "symbol" docs/interface-contract.md`, When 본 REQ 구현 후, Then `PortfolioItem` 기반 POST/PATCH/DELETE/GET `/v1/portfolios` 응답 설명에 `symbol` 필드가 명시되고 GET 항목은 joined asset result 설명을 포함한다. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` - - `docs/runtime-flows.md` - - `docs/data-model.md` - - `docs/project-structure.md` -- Modules: 없음 (문서 전용) -- Tests: 없음 (문서 전용. AC-1~6은 grep + 사람 리뷰) - -## Notes -- 본 REQ는 REQ-025~031 코드 변경 후 또는 동일 batch에서 함께 처리하는 것이 적절하다. baseline 문서가 코드 변경 사항을 반영하지 않으면 후속 REQ/batch가 잘못된 source of truth를 참조한다. -- dcy-004 REQ-019(토큰 surface baseline 정합성)와 본 REQ는 baseline 갱신 대상이 부분적으로 겹치나 책임 분담: - - REQ-019: 토큰 surface (client 폐지, rebind, AuthContext daemon_id, auth_tokens 인덱스) - - 본 REQ-032: portfolio v1 surface (path 분리, soft delete, ownership/disabled, ledger path 이전) - - 두 REQ가 동일 batch에서 처리될 경우 baseline 문서 동시 갱신 시 conflict 회피를 위해 commit 순서 또는 작업자 분담 명시 필요. -- dcy-001 REQ-015(asset catalog baseline)도 baseline 갱신 대상이라 batch design 단계에서 3개 REQ(REQ-015 + REQ-019 + REQ-032)의 baseline 작업을 통합 batch로 묶거나 의존성 순서를 명시. -- Source: dcy-003 FR-10. dcy-001 REQ-013 Deprecated 후속 baseline 정리 포함. - -## Change Log - -### CHG-20260524-02 -- Date: 2026-05-24 -- Author: Hermes Agent - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-014 verification 완료. `docs/interface-contract.md` 에 portfolio CRUD/list 응답의 `symbol` 필드와 GET join 기반 응답 설명이 반영되어 REQ-032 augmentation 범위가 충족되었다. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: BAT-014 (release-candidate); BAT-010 interface-contract evidence superseded by BAT-014 -- Releases: pending - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-implemented -- Follow-up Action: release 단계 전 추가 baseline drift 점검 - -### CHG-20260524-01 -- Date: 2026-05-24 -- Author: Hermes Agent - -#### Change Summary -- portfolio v1 baseline 문서에 `PortfolioItem.symbol` 및 GET `/v1/portfolios` asset-join 응답 계약 반영이 필요해 REQ-032를 augment 하고 상태를 Implemented → Approved로 되돌림. - -#### Intent -- trex-orbit 팀 요청으로 CRUD/list portfolio 응답 계약이 `symbol` 포함 형태로 확장되었다. baseline 문서가 이를 반영하지 않으면 구현 후에도 API source of truth가 stale 해지므로, interface-contract/runtime 설명의 후속 갱신을 별도 delivery trace로 강제한다. - -#### Acceptance Criteria Delta -- Added: - - AC-7: baseline 문서에 `symbol` 필드 및 GET join 기반 응답 설명이 존재해야 함 - -#### Impacted Area Delta -- Docs: `docs/interface-contract.md` (필수), 필요 시 `docs/runtime-flows.md` 의 GET `/v1/portfolios` 설명 보강 -- Modules: 없음 -- Tests: 없음 (문서 grep + 사람 리뷰) - -#### Delivery Trace Delta -- Batches: BAT-010 evidence partially superseded; follow-up BAT-014 예정 -- Releases: REL-001 baseline evidence 일부 재검토 필요 - -#### Revalidation Impact -- Reverification Needed: yes -- Existing Implementation Invalidated: yes (baseline documentation evidence partial) -- Status Recommendation: revert-to-approved -- Follow-up Action: BAT-014에서 interface-contract 예시와 설명 갱신 후 재검증 - -#### Validation Plan -- `docs/interface-contract.md` 에서 POST/PATCH/DELETE/GET `/v1/portfolios` 응답 설명과 예시에 `symbol` 이 추가됐는지, GET 섹션에 joined asset result 설명이 있는지 grep + 사람 리뷰로 확인한다. - -### 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-10 산출물. - -#### Intent -- v1 portfolio mutation surface의 baseline 문서 정합성 회복 + dcy-001 REQ-013 Deprecated 후속 정리. - -#### Acceptance Criteria Delta -- Added: - - AC-1~6 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md, docs/runtime-flows.md, docs/data-model.md, docs/project-structure.md — 최초 지정 -- Modules: 없음 -- Tests: 없음 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-025~031 코드 변경 후 또는 동일 batch에서 처리. dcy-004 REQ-019, dcy-001 REQ-015와의 baseline 작업 충돌 조율 필요. - -#### Validation Plan -- grep + 사람 리뷰로 AC-1~6 검증 diff --git a/docs/srs/Integration/req-041_wac-cost-basis-pnl-calculation.md b/docs/srs/Integration/req-041_wac-cost-basis-pnl-calculation.md deleted file mode 100644 index 7eb9301..0000000 --- a/docs/srs/Integration/req-041_wac-cost-basis-pnl-calculation.md +++ /dev/null @@ -1,142 +0,0 @@ -# REQ-041: WAC 기반 cost_basis 산출 및 실현 PnL 계산 로직 (service/domain 계층 격리) - -- Status: Implemented -- Type: Integration -- Priority: High -- Owner: Justin Ko - -## Intent -- portfolio_ledger의 buy/sell 기록에서 weighted average cost(WAC, 가중평균 취득단가) 방식으로 cost_basis를 계산하고, 매도 실현 손익 = (매도가 − 평균취득가) × 수량 − 수수료 − 세금 공식을 service 또는 domain 계층에 격리 구현한다(dcy-007 FR-3, OQ-1=WAC 확정). -- repository(SQL) 계층과 PnL 계산 로직을 분리하여 단위 테스트가 DB 없이 가능하도록 한다(dcy-007 NFR-3). -- R-1(이력 완전성) 완화를 위해 cost_basis 산출 시작 시점과 이력 불완전 감지 조건을 이 계층에서 판단하고 경고 신호를 반환한다. - -## Requirement -- WAC cost_basis 계산 함수는 `app/services/` 또는 `app/domain/` 계층에 위치해야 하며, repository(psycopg/SQL) 직접 의존성 없이 순수 Python으로 구현해야 한다. -- 입력: portfolio_ledger 행 목록(side, quantity, price, fee_amount, tax_amount, executed_at, order_id, execution_id 포함) — 시간 순 정렬. -- 출력: 건별 매도에 대한 실현 PnL 계산 결과 목록, cost_basis_from(첫 buy 기록 시점), data_completeness_warning(이력 불완전 감지 시 경고 문자열, 없으면 None). -- WAC 계산 규칙 (**매수 수수료 cost basis 편입 포함, 2026-06-26 확정**): - - buy 시: `running_wac = (running_wac × running_qty + price × qty + buy_fee) / (running_qty + qty)`, `running_qty += qty`. - - `buy_fee`는 해당 buy 행의 `fee_amount`. 매수 수수료를 cost basis에 포함함으로써 실현 PnL이 "당일 net"과 정확히 일치한다. - - sell 시: `realized_pnl = (sell_price − running_wac) × qty − sell_fee − tax`, `running_qty -= qty`. - - sell fee는 running_wac에 편입하지 않고 sell 시 차감한다(기존 방식 유지). - - 매도 수량이 보유 수량을 초과(running_qty < qty)하는 경우: 이력 불완전으로 판단하고 `data_completeness_warning` 반환 + 최선 추정으로 계속 계산(중단하지 않음). -- cost_basis_from은 입력 행 목록에서 가장 이른 buy 기록의 executed_at으로 설정. buy 기록이 없으면 None. -- 이 함수는 repository를 통해 얻은 원시 행 목록을 받으므로, repository는 관련 buy/sell 이력을 충분한 범위로 조회해 전달해야 한다(repository와 계층 경계 명시). - -## Acceptance Criteria -1. Given buy 100주@10,000 fee=0 → buy 100주@12,000 fee=0 → sell 50주@15,000 fee=500 tax=200 순 행 목록 입력(buy fee=0이므로 기존 수치 유지), When WAC 계산 함수 호출, Then cost_basis_per_unit=11,000(WAC = (100×10,000 + 0 + 100×12,000 + 0) / 200) + realized_pnl=199,300((15,000-11,000)×50 - 500 - 200) 반환. -2. Given buy 100주@10,000만 이력이 있고 sell 없음, When 함수 호출, Then items=[] + total_realized_pnl=0 + cost_basis_from=buy 시각 + data_completeness_warning=None 반환. -3. Given 이력이 sell부터 시작(buy 이력 없음 = running_qty=0에서 sell 발생), When 함수 호출, Then data_completeness_warning에 비어 있지 않은 경고 문자열 반환 + 계산은 중단하지 않고 최선 추정 계속. -4. Given 매도 수량이 현재 running_qty를 초과하는 경우, When 함수 호출, Then data_completeness_warning에 비어 있지 않은 경고 문자열 반환. -5. Given 동일 포트폴리오에 여러 번 buy/sell 반복(시간 순 정렬 입력 10건), When 함수 호출, Then 각 sell 시점 WAC가 해당 시점까지의 buy 누적 가중평균(buy fee 포함)을 정확히 반영. -6. Given 입력 행 목록이 빈 배열, When 함수 호출, Then items=[] + total_realized_pnl=0 + cost_basis_from=None + data_completeness_warning=None 반환. -7. Given 이 계산 함수가 `app/services/` 또는 `app/domain/` 계층에 위치, When `grep -rn "psycopg\|asyncpg\|psycopg2" app/services/ app/domain/` 실행, Then WAC 계산 함수 파일에서 DB 라이브러리 직접 import가 0건(순수 Python 격리 확인, NFR-3 반영). -8. Given 계산 함수가 구현된 모듈, When 단위 테스트(`tests/test_wac_calculation.py`)를 pytest로 실행, Then DB 컨테이너 없이 통과(--no-db 마커 또는 DB 의존성 없는 pytest 실행). -9. Given buy 10주@1,000 fee=5, sell 10주@1,100 fee=5 tax=33(일중 닫힌 거래), When WAC 계산 함수 호출, Then cost_basis_per_unit=1,000.5(WAC = (1000×10+5)/10) + realized_pnl=957((1100-1000.5)×10-5-33). 이 값이 "당일 net"(= 1100×10 - 1000×10 - 5 - 5 - 33 = 957)과 정확히 일치. -10. Given calculate_current_wac 함수에 buy 10주@1,000 fee=5 입력, When 호출, Then current_wac=1,000.5(buy fee 편입) + current_quantity=10. - -## Impacted Area -- Docs: - - `docs/project-structure.md` — app/services/ 또는 app/domain/ 책임 갱신(WAC PnL 산출 로직 추가 반영) -- Modules: - - `app/services/portfolio_service.py` 또는 `app/domain/pnl.py`(신규) — WAC cost_basis 계산 함수 구현 - - `app/repositories/postgres.py` — WAC 산출을 위한 buy/sell 이력 조회 메서드(`get_all_trades_for_pnl(portfolio_id, from_, to_, symbol)`) 추가 — service 계층이 consume -- Tests: - - `tests/test_wac_calculation.py` 신규 — AC-1~8 순수 단위 테스트(DB 불필요) - -## Notes -- 구현 위치 결정(app/services/ vs app/domain/): 도메인 규칙으로서 domain 계층이 적절하나, 현행 코드 패턴(app/domain/services.py)을 따르는 것이 권장. 구현 단계에서 팀장 확인 필요. -- repository는 PnL 집계 기간(`from`~`to`) 이전의 buy 이력도 cost_basis 산출을 위해 조회해야 한다. 즉, 집계 기간 내 매도가 있을 경우 그 이전 buy 이력 전부를 시간 순으로 함께 전달해야 WAC가 정확하다. 이 쿼리 범위 설계는 구현 단계에서 확인. -- 이력 불완전 감지 조건은 현재 AC-3/AC-4의 2가지로 정의. 추가 조건은 구현 단계에서 확인. -- FIFO 방식은 OQ-1에서 명시적으로 제외(WAC 확정). 이 REQ는 WAC 전용이다. -- Source: dcy-007 FR-3, NFR-3, R-1, OQ-1(WAC 확정). - -## Change Log - -### CHG-20260626-01 -- Date: 2026-06-26 -- Author: Justin Ko (portfolio-hub-dev) - -#### Change Summary -- WAC 공식에 **매수 수수료 cost basis 편입** 적용. 사장님 결정(2026-06-26): 매수 fee를 WAC에 포함해 실현 PnL이 "당일 net"과 정확히 일치하도록. - -#### Intent -- 기존 공식 `running_wac = (running_wac*qty + price*qty) / new_total_qty`에서 buy row의 `fee_amount`를 분자에 추가. - 신 공식: `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty` -- `calculate_realized_pnl`과 `calculate_current_wac` 양 함수 모두 동일하게 적용해 WAC 정의 일관성 유지. -- trex-core dcy-035 멀티데이 운영리포트가 이 API를 소비하며, 일중 닫힌 거래에서 실현 PnL = 당일 net이 정확히 성립한다. -- sell 분기의 `realized_pnl = (price - running_wac)*qty - sell_fee - tax` 공식 자체는 변경 없음. sell fee/tax는 그대로 차감 유지. - -#### Acceptance Criteria Delta -- Changed: - - AC-1: WAC 계산에 buy fee=0 명시 (기존 buy fee 0 가정을 명문화해 기댓값 수치 199,300 유지) - - AC-5: "buy 누적 가중평균(buy fee 포함)을 정확히 반영"으로 명시 -- Added: - - AC-9: 일중 닫힌 거래 기준 케이스 — buy 10@1000 fee=5, sell 10@1100 fee=5 tax=33 → realized=957, 당일 net=957 일치 - - AC-10: calculate_current_wac buy fee 편입 — buy 10@1000 fee=5 → current_wac=1000.5 - -#### Impacted Area Delta -- Modules: - - `app/domain/pnl.py` — `calculate_realized_pnl` buy 분기 WAC 공식 갱신(fee 편입). `calculate_current_wac` buy 분기에 fee_amount 읽기 추가 및 동일 편입. -- Tests: - - `tests/test_wac_calculation.py` — `TestBuyFeeInCostBasis`(5개) + `TestCurrentWacBuyFeeInCostBasis`(4개) 신규 클래스 추가. 기존 테스트 기댓값 변경 없음(기존 케이스 모두 buy fee=0). - -#### Delivery Trace Delta -- Batches: 직접 batch 없음 — 사장님 지시 핫픽스 적용 -- Releases: none (미배포) - -#### Revalidation Impact -- Reverification Needed: yes (기존 구현 공식이 바뀌므로 재검증) -- Existing Implementation Invalidated: yes (매수 fee>0인 실제 거래에서 기존 realized_pnl 값이 부정확했음) -- Status Recommendation: `Implemented` 유지 — 기존 테스트 기댓값은 모두 buy fee=0 케이스라 기존 테스트 PASS 그대로 유지. 신규 AC-9/AC-10 테스트 통과 확인됨(pytest 456 passed / 0 failed, 2026-06-26 실행). -- Follow-up Action: REQ-040 Notes 갱신(WAC 정의 변경 참조 추가), REQ-043 Notes 갱신(동일). docs/srs/index.md Recent Change Log 갱신. - -#### Validation Plan -- pytest tests/test_wac_calculation.py — 29 passed (신규 TestBuyFeeInCostBasis 5개 + TestCurrentWacBuyFeeInCostBasis 4개 포함). DB 불필요. -- pytest 전체 스위트 — 456 passed / 0 failed (2026-06-26 실행 확인). - -### CHG-20260618-01 -- Date: 2026-06-18 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-007 confirm-discovery 후 draft-req 실행. WAC PnL 산출 로직 격리 산출물. - -#### Intent -- dcy-007 FR-3(WAC cost_basis 계산) + NFR-3(service/domain 계층 격리, 단위 테스트 가능)을 Integration REQ로 정규화. R-1 완화(경고 필드 조건)를 AC-3/AC-4에 반영. - -#### Acceptance Criteria Delta -- Added: - - AC-1~8 최초 작성 - -#### Impacted Area Delta -- Docs: docs/project-structure.md — 최초 지정 -- Modules: app/services/portfolio_service.py 또는 app/domain/pnl.py, app/repositories/postgres.py — 최초 지정 -- Tests: tests/test_wac_calculation.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-040이 이 REQ에 의존하므로 동일 batch 편입 권장 - -#### Validation Plan -- pytest tests/test_wac_calculation.py (DB 불필요 단위 테스트) + AC-7 grep 검증 - -### CHG-20260618-02 -- Date: 2026-06-18 -- Author: 팀장 (Claude) - -#### Change Summary -- confirm-req: `Proposed` → `Approved`. WAC 공식 확정(FIFO 제외), NFR-3 격리 AC-7/AC-8·R-1 완화 AC-3/AC-4 확인, blocker 없음. 구현 위치(domain vs service)는 구현 단계 결정. M1 fast-track. - -### CHG-20260618-03 -- Date: 2026-06-18 -- Author: 팀장 (Claude) - -#### Change Summary -- confirm-req-implemented: `Approved` → `Implemented`. Evidence: app/domain/pnl.py 구현, WAC 누적 정확성+199,300 검증, NFR-3 격리(psycopg import 0) QC 확인, R-1 경고 동작, test_wac_calculation.py 20 passed. diff --git a/docs/srs/Interface/req-004_tags-http-api.md b/docs/srs/Interface/req-004_tags-http-api.md deleted file mode 100644 index 225a403..0000000 --- a/docs/srs/Interface/req-004_tags-http-api.md +++ /dev/null @@ -1,139 +0,0 @@ -# REQ-004: tags 마스터 HTTP API - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- tags 마스터 테이블에 대한 HTTP API를 제공하여 태그 생성, 목록 조회, 비활성화 기능을 권한 기반으로 제어한다. -- 태그 이름은 저장 전 소문자로 정규화하여 대소문자 변형 중복을 방지한다. -- admin 토큰으로 생성된 태그는 is_admin_tag=TRUE로 기록되며, 이는 asset 태그 적용 정책의 핵심 조건이 된다. - -## Requirement -- `POST /v1/tags` — 태그 생성; trade-write 이상 권한 필요; name을 str.lower()로 정규화 후 저장; 중복 name(정규화 후) 시 HTTP 409 반환 -- `GET /v1/tags` — 태그 목록 조회; read 이상 권한 필요; `?is_active=true|false` 필터 지원 -- `PATCH /v1/tags/{tag_id}/deactivate` — 태그 비활성화; admin 또는 태그 생성자 본인만 가능; 이미 비활성화된 태그에 멱등 처리(재요청 시 200 반환); 존재하지 않는 tag_id는 HTTP 404 - -## Acceptance Criteria -1. Given trade-write 토큰, When `POST /v1/tags {"name": "TestTag"}`, Then HTTP 201, name="testtag"(소문자 정규화)로 저장된다. -2. Given admin 토큰, When `POST /v1/tags {"name": "adminTag"}`, Then HTTP 201, is_admin_tag=TRUE인 태그가 생성된다. -3. Given non-admin trade-write 토큰, When `POST /v1/tags {"name": "userTag"}`, Then HTTP 201, is_admin_tag=FALSE인 태그가 생성된다. -4. Given "testtag" 태그가 이미 존재, When `POST /v1/tags {"name": "TestTag"}` (대소문자 변형), Then HTTP 409 Conflict가 반환된다. -5. Given read 토큰, When `GET /v1/tags`, Then HTTP 200과 활성 태그 목록이 반환된다. -6. Given read 토큰, When `GET /v1/tags?is_active=false`, Then 비활성 태그만 반환된다. -7. Given admin 토큰, When `PATCH /v1/tags/{tag_id}/deactivate` (본인 생성 또는 타인 생성 무관), Then HTTP 200, is_active=FALSE로 설정된다. -8. Given 태그 생성자(non-admin) 토큰, When `PATCH /v1/tags/{tag_id}/deactivate` (본인 생성 태그), Then HTTP 200 반환된다. -9. Given non-admin 비생성자 토큰, When `PATCH /v1/tags/{tag_id}/deactivate`, Then HTTP 403 Forbidden이 반환된다. -10. Given 존재하지 않는 tag_id, When `PATCH /v1/tags/{tag_id}/deactivate`, Then HTTP 404가 반환된다. -11. Given 이미 is_active=FALSE인 태그, When `PATCH /v1/tags/{tag_id}/deactivate` 재요청, Then HTTP 200 반환(멱등). - -## Impacted Area -- Docs: docs/interface-contract.md (/v1/tags 엔드포인트 명세 추가) -- Modules: - - app/api/ — v1_tags.py 라우터 추가 (또는 기존 라우터에 통합) - - app/schemas/v1.py — TagItem, TagCreateRequest, TagListResponse 스키마 추가 - - app/services/ — 태그 생성/조회/비활성화 서비스 로직 - - app/repositories/portfolio_repository.py — insert_tag, get_tags, deactivate_tag 프로토콜 - - app/repositories/postgres.py — 태그 CRUD SQL 구현 - - app/core/auth.py — permission 검증 (기존 require_read, require_trade_write, require_admin 재사용) -- Tests: 엔드포인트별 permission 검증, 소문자 정규화, 중복 거부, 비활성화 멱등성 - -## Notes -- NFR-1 흡수: name 정규화는 서비스/라우터 레이어에서 str.lower() 호출, DB는 TEXT UNIQUE 그대로. -- NFR-3 흡수: 권한 미충족 시 HTTP 403은 기존 permission_dependency 패턴 재사용. -- `POST /v1/tags` 권한: trade-write 이상이면 일반 사용자도 태그 생성 가능하며 is_admin_tag는 admin 여부에 따라 자동 결정. -- `GET /v1/tags` 기본 필터: is_active=true (활성 태그만). ?is_active=false로 비활성 태그 조회 가능. -- Source: dcy-002 FR-4(CRUD 부분), NFR-1, NFR-3 - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-004 confirm-batch-verification 완료. POST/GET /v1/tags, PATCH /v1/tags/{id}/deactivate 권한 분기·소문자 정규화·멱등성 모든 AC 검증 통과. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-004 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-002 confirm-discovery 후 REQ 초안 작성 - -#### Intent -- dcy-002 FR-4의 tags 마스터 CRUD API를 Interface REQ로 분리 - -#### Acceptance Criteria Delta -- Added: - - AC-1~11 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/interface-contract.md — /v1/tags 명세 추가 -- Modules: - - app/api/, app/schemas/v1.py, app/services/, app/repositories/ — 최초 지정 -- Tests: - - 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-001 선행 의존성 확인 diff --git a/docs/srs/Interface/req-005_tag-apply-remove-http-api.md b/docs/srs/Interface/req-005_tag-apply-remove-http-api.md deleted file mode 100644 index 1897317..0000000 --- a/docs/srs/Interface/req-005_tag-apply-remove-http-api.md +++ /dev/null @@ -1,226 +0,0 @@ -# REQ-005: 태그 적용·해제 HTTP API - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- asset(portfolios)과 portfolio에 태그를 적용하고 해제하는 HTTP API를 제공하며, asset에는 admin 생성 태그만, portfolio에는 admin 또는 소유자 생성 태그만 적용 가능하도록 정책을 적용한다. -- 정책 위반 시 HTTP 403을 반환하여 명확한 오류 피드백을 제공한다. -- 존재하지 않는 tag_id 적용 시도는 HTTP 404로 처리한다. - -## Requirement -- `POST /v1/assets/{asset_id}/tags {"tag_id": "..."}` — asset에 태그 적용; admin 권한 필요; 적용 조건: tags.is_admin_tag=TRUE AND tags.is_active=TRUE; 조건 불만족 시 HTTP 403; 존재하지 않는 tag_id 시 HTTP 404 -- `DELETE /v1/assets/{asset_id}/tags/{tag_id}` — asset에서 태그 해제; admin 권한 필요 -- `POST /v1/portfolios/{portfolio_id}/tags {"tag_id": "..."}` — portfolio에 태그 적용; trade-write 이상 권한 필요; 적용 조건: tags.is_active=TRUE AND (tags.is_admin_tag=TRUE OR tags.created_by_user_id = portfolio 소유자); 조건 불만족 시 HTTP 403; 존재하지 않는 tag_id 시 HTTP 404 -- `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}` — portfolio에서 태그 해제; trade-write 이상, 소유자 본인 또는 admin - -## Acceptance Criteria -1. Given admin 토큰, admin이 생성한 활성 tag_id, When `POST /v1/assets/{asset_id}/tags`, Then HTTP 200, 해당 tag_id가 **assets 테이블의** tag_ids 컬럼 배열에 추가된다 (portfolios 테이블이 아님). -2. Given admin 토큰, is_admin_tag=FALSE인 tag_id, When `POST /v1/assets/{asset_id}/tags`, Then HTTP 403 Forbidden이 반환된다. -3. Given admin 토큰, is_active=FALSE인 tag_id, When `POST /v1/assets/{asset_id}/tags`, Then HTTP 403 Forbidden이 반환된다. -4. Given non-admin 토큰, When `POST /v1/assets/{asset_id}/tags`, Then HTTP 403 Forbidden이 반환된다. -5. Given admin 토큰, When `DELETE /v1/assets/{asset_id}/tags/{tag_id}`, Then HTTP 200, tag_id가 **assets 테이블의** tag_ids 컬럼 배열에서 제거된다 (portfolios 테이블이 아님). -6. Given trade-write 토큰(portfolio 소유자), 소유자 본인이 생성한 tag_id (is_admin_tag=FALSE, is_active=TRUE), When `POST /v1/portfolios/{portfolio_id}/tags`, Then HTTP 200, 태그가 portfolio의 tag_ids에 추가된다. -7. Given trade-write 토큰(portfolio 소유자), admin이 생성한 활성 tag_id, When `POST /v1/portfolios/{portfolio_id}/tags`, Then HTTP 200, 태그가 추가된다. -8. Given trade-write 토큰(user A, 소유자), 다른 user B가 생성한 non-admin tag_id, When `POST /v1/portfolios/{portfolio_id}/tags`, Then HTTP 403 Forbidden이 반환된다. -9. Given 소유자 또는 admin 토큰, When `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}`, Then HTTP 200, tag_id가 portfolio의 tag_ids에서 제거된다. -10. Given 유효한 권한, 존재하지 않는 tag_id, When 적용 요청, Then HTTP 404 Not Found가 반환된다. -11. Given 존재하지 않는 asset_id 또는 portfolio_id, When 태그 적용 요청, Then HTTP 404가 반환된다. - -## Impacted Area -- Docs: docs/interface-contract.md (/v1/assets/{id}/tags, /v1/portfolios/{id}/tags 엔드포인트 명세 추가) -- Modules: - - app/api/v1_assets.py — POST/DELETE /v1/assets/{asset_id}/tags 라우트 추가; CHG-20260521-02에서 라우트 내부 호출을 assets 테이블 대상 repository 메서드로 교체 - - app/api/portfolios.py — POST/DELETE /v1/portfolios/{portfolio_id}/tags 라우트 추가 (또는 별도 파일) - - app/schemas/v1.py — TagApplyRequest 스키마 추가 - - app/services/ — 태그 적용/해제 서비스 로직, 정책 검증 - - app/repositories/postgres.py — tag_ids 배열 append/remove SQL (array_append, array_remove 또는 동등). assets 대상은 `append_asset_tag_id`/`remove_asset_tag_id`, portfolios 대상은 `append_portfolio_tag_id`/`remove_portfolio_tag_id`로 분리(CHG-20260521-02). -- Tests: 정책 위반 시 403, 존재하지 않는 tag_id 시 404, admin/non-admin 구분 검증 - -## Notes -- NFR-3 흡수: 정책 위반 시 HTTP 403은 이 REQ의 핵심 동작. -- REQ-001(tags 테이블), REQ-002(assets.tag_ids), REQ-003(portfolios.tag_ids)이 선행되어야 한다. -- asset에 태그 적용 권한이 admin 전용인 이유: assets는 admin 관리 카탈로그이며 asset 태그에는 is_admin_tag=TRUE만 허용 (dcy-002 FR-2). -- portfolio 소유자 확인: portfolios.owner_user_id = auth_context.user_id 비교. -- Source: dcy-002 FR-2, FR-3, FR-4(apply/remove 부분), NFR-3 - -## Change Log - -### CHG-20260521-03 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented (CHG-20260521-02로 명문화된 AC-1/AC-5 의도가 BAT-013 verification에서 evidence 확보) - -#### Intent -- BAT-013 confirm-batch-verification 완료. assets 테이블 tag_ids 정합화 범위(`append_asset_tag_id`/`remove_asset_tag_id` 신규 + 라우트 호출 교체)에 대한 AC-1·AC-5 신규 evidence + AC-2~4·EC-1 회귀 + AC-6~11(portfolios 경로) 무영향 회귀 모두 확인. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-013 (release-candidate) — primary trace, assets.tag_ids mutation 정합화 범위 AC-1·AC-5 + EC-1 evidence - - BAT-004 (release-candidate) — AC-2~4·AC-6~11 회귀 evidence 유지; AC-1·AC-5 evidence는 BAT-013에서 superseded (mock 단언이 신규 메서드로 갱신됨) -- Releases: - - REL-001 — BAT-004 trace는 REQ-004 측면에서 유효; REQ-005 측면의 신규 evidence는 BAT-013에서 확보. REL-001에 BAT-013 포함 여부는 release 갱신 시 결정 (또는 신규 REL에 BAT-013 포함). - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: - - REL-001 release 문서에 BAT-013 trace 반영 (또는 신규 REL 발의 시 BAT-013 포함) - - CLI 동일 mismatch 후속 REQ 발의: `pfhub tag apply asset` / `pfhub tag remove asset`도 `append_portfolio_tag_id`/`remove_portfolio_tag_id` 호출 (REQ-007/008 augmentation 또는 신규 REQ) - - 실 PostgreSQL `assets.tag_ids` mutation 회귀: REL 배포 직후 smoke로 admin POST → GET /v1/assets에서 tag_ids 채워짐 확인 - -#### Validation Plan -- `tests/test_tags_http_api.py::test_apply_asset_tag_admin_success` (갱신) — `append_asset_tag_id` 호출 단언 + portfolios 미호출 단언 -- `tests/test_tags_http_api.py::test_remove_asset_tag_admin_success` (갱신) — `remove_asset_tag_id` 호출 단언 + portfolios 미호출 단언 -- `tests/test_asset_catalog.py::test_postgres_repository_has_append_asset_tag_id_method` / `test_postgres_repository_has_remove_asset_tag_id_method` — 메서드 존재 검증 -- `tests/test_asset_catalog.py::test_append_asset_tag_id_targets_assets_table_not_portfolios` / `test_remove_asset_tag_id_targets_assets_table_not_portfolios` — SQL 대상 테이블 검증 -- `tests/test_asset_catalog.py::test_append_asset_tag_id_raises_when_asset_not_found` / `test_remove_asset_tag_id_raises_when_asset_not_found` — asset 부재 ValueError -- `tests/test_asset_catalog.py::test_append_asset_tag_id_idempotent_guard_present` — 중복 방지 가드 -- 전체 회귀: 369 passed in 11.48s - -### CHG-20260521-02 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Implemented → Approved -- AC-1, AC-5의 "asset의 tag_ids 배열" 의도를 "assets 테이블의 tag_ids 컬럼"으로 명문화. 기존 구현이 portfolios 테이블을 대상화하고 있어 AC 의미 충족 미달. - -#### Intent -- BAT-012 implementation 단계에서 REL-001 Feedback Handoff 항목으로 발견 (rel-001 117행): `apply_asset_tag`([app/api/v1_assets.py:175](../../../app/api/v1_assets.py#L175))/`remove_asset_tag`([app/api/v1_assets.py:200](../../../app/api/v1_assets.py#L200))가 `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id`를 호출하여 실제로는 **portfolios 테이블**을 update. -- BAT-012로 `assets.tag_ids` 컬럼이 도입됐으나 apply/remove 경로가 정합되지 않아 신설 컬럼이 실제로 채워지지 않는 상태. -- BAT-004 verification이 portfolios.tag_ids 변경 결과로 통과한 흔적(또는 asset_id == portfolio_id 동일 UUID fixture)으로 추정되며 AC-1/AC-5의 진정한 의도(assets 테이블의 tag_ids)는 검증되지 않았다. -- dcy-002 FR-2 — "admin이 assets에 태그를 부여" — 원래 의도에 맞춰 구현·검증 경로를 정합화한다. - -#### Acceptance Criteria Delta -- Modified: - - AC-1: "해당 tag_id가 **asset의** tag_ids 배열에 추가된다" → "해당 tag_id가 **assets 테이블의** tag_ids 컬럼 배열에 추가된다 (portfolios 테이블이 아님)" - - AC-5: "tag_id가 **asset의** tag_ids 배열에서 제거된다" → "tag_id가 **assets 테이블의** tag_ids 컬럼 배열에서 제거된다 (portfolios 테이블이 아님)" -- 기타 AC (2,3,4,6~11): 변경 없음. - -#### Impacted Area Delta -- Modules added/modified: - - `app/repositories/postgres.py` — `append_asset_tag_id(asset_id, tag_id)`, `remove_asset_tag_id(asset_id, tag_id)` 메서드 신규. SQL은 assets 테이블 대상 `array_append`/`array_remove` UUID[]. - - `app/api/v1_assets.py` — `apply_asset_tag`/`remove_asset_tag` 라우트의 `append_portfolio_tag_id`/`remove_portfolio_tag_id` 호출을 신규 assets 대상 메서드로 교체. -- Tests added: - - `tests/test_asset_catalog.py` (또는 `tests/test_v1.py`) — POST /v1/assets/{id}/tags 후 `assets.tag_ids`에 tag_id가 실제 추가되는지 + portfolios.tag_ids는 변동 없는지 회귀 확인. DELETE 동일. -- Docs: - - 변경 없음 (interface-contract.md/runtime-flows.md의 라우트 명세는 동일). - -#### Delivery Trace Delta -- Batches: - - BAT-004 (asset/portfolio tag apply-remove HTTP API) — AC-1/AC-5 evidence 무효(portfolios 테이블 대상화로 의미 미충족). BAT-004의 REQ-004 trace는 영향 없음. - - 신규 batch (TBD) — append_asset_tag_id/remove_asset_tag_id 도입 + 라우트 교체 + 회귀 테스트. -- Releases: - - REL-001 — BAT-004 의 REQ-005 trace는 본 augmentation으로 부분 무효. 본 REQ 재구현 결과는 후속 release(REL-002 등) 또는 REL-001 추가 batch 편입 시 trace. - -#### Revalidation Impact -- Reverification Needed: yes -- Existing Implementation Invalidated: yes (POST/DELETE /v1/assets/{asset_id}/tags 내부 SQL이 portfolios 테이블을 대상화, AC-1/AC-5 의미상 미충족) -- Status Recommendation: revert-to-approved -- Follow-up Action: 신규 batch 발의 → repository 메서드 추가 → 라우트 교체 → pytest 회귀 (DB 인스턴스로 assets.tag_ids mutation 검증) → confirm-req-implemented 재진입 - -#### Validation Plan -- pytest로 신규 회귀 케이스 추가: POST /v1/assets/{id}/tags 200 후 PostgresRepository.get(asset_id).tag_ids 배열에 tag_id 포함 확인; DELETE 후 제거 확인. portfolios 테이블은 동일 호출에 대해 무변동 확인. -- BAT-004 기존 AC-1, AC-5 케이스가 portfolios.tag_ids 의존 fixture에 묻혀 통과했는지 점검; assets 전용 fixture로 교체. - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-004 confirm-batch-verification 완료. asset/portfolio 태그 적용·해제 HTTP API 정책 검증·403/404 분기 모든 AC 검증 통과. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-004 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-002 confirm-discovery 후 REQ 초안 작성 - -#### Intent -- dcy-002 FR-4의 태그 적용·해제 엔드포인트와 FR-2/FR-3의 정책 검증을 Interface REQ로 분리 - -#### Acceptance Criteria Delta -- Added: - - AC-1~11 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/interface-contract.md — apply/remove 엔드포인트 추가 -- Modules: - - app/api/v1_assets.py, app/api/portfolios.py, app/schemas/v1.py, app/services/, app/repositories/postgres.py — 최초 지정 -- Tests: - - 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-001, REQ-002, REQ-003 선행 의존성 확인 diff --git a/docs/srs/Interface/req-007_pfhub-tag-cli.md b/docs/srs/Interface/req-007_pfhub-tag-cli.md deleted file mode 100644 index 666e468..0000000 --- a/docs/srs/Interface/req-007_pfhub-tag-cli.md +++ /dev/null @@ -1,138 +0,0 @@ -# REQ-007: pfhub tag CLI 명령어 - -- Status: Implemented -- Type: Interface -- Priority: Medium -- Owner: Justin Ko - -## Intent -- tags 마스터 테이블 기반의 태그 관리 CLI 명령어를 `pfhub tag` 최상위 서브커맨드로 추가하여 운영자·사용자가 터미널에서 태그 생성, 조회, 비활성화, 적용, 해제를 수행할 수 있도록 한다. -- 기존 `pfhub portfolio tag add/list/remove` 명령어(portfolio_tags 자유 문자열 기반)는 이 REQ 구현과 함께 제거한다. - -## Requirement -- `pfhub tag create ` — 태그 생성 (trade-write 이상 권한) -- `pfhub tag list [--inactive]` — 태그 목록; --inactive 없으면 활성 태그만, --inactive 플래그 시 비활성 태그 포함 전체 조회 -- `pfhub tag deactivate ` — 태그 비활성화; `--force` 확인 필요 (admin 또는 생성자 본인) -- `pfhub tag apply asset ` — asset에 태그 적용 (admin 권한) -- `pfhub tag apply portfolio ` — portfolio에 태그 적용 (trade-write 이상, 소유자 또는 admin) -- `pfhub tag remove asset ` — asset에서 태그 해제; `--force` 확인 필요 (admin) -- `pfhub tag remove portfolio ` — portfolio에서 태그 해제; `--force` 확인 필요 (소유자 또는 admin) -- 기존 `pfhub portfolio tag add`, `pfhub portfolio tag list`, `pfhub portfolio tag remove` 명령어를 제거해야 한다. - -## Acceptance Criteria -1. Given trade-write 토큰, When `pfhub tag create "코스피200"`, Then 태그가 생성되고 `pfhub tag list`에 나타난다. -2. Given 활성 태그 존재, When `pfhub tag list`, Then 활성 태그 목록이 출력된다. -3. Given 활성·비활성 태그 모두 존재, When `pfhub tag list --inactive`, Then 비활성 태그가 포함된 전체 목록이 출력된다. -4. Given admin 또는 생성자 토큰, When `pfhub tag deactivate --force`, Then 태그가 비활성화되고 `pfhub tag list --inactive`에서 비활성 상태로 확인된다. -5. Given admin 토큰, admin 생성 활성 tag_id, When `pfhub tag apply asset `, Then asset에 태그가 적용된다. -6. Given trade-write 토큰(소유자 또는 admin), When `pfhub tag apply portfolio ` (정책 충족 태그), Then portfolio에 태그가 적용된다. -7. Given admin 토큰, When `pfhub tag remove asset --force`, Then asset에서 태그가 제거된다. -8. Given 소유자 또는 admin 토큰, When `pfhub tag remove portfolio --force`, Then portfolio에서 태그가 제거된다. -9. Given 기존 CLI 실행 환경, When `pfhub portfolio tag add/list/remove`를 실행, Then 명령어가 존재하지 않는다(Typer error 또는 명령 없음). - -## Impacted Area -- Docs: docs/project-structure.md (pfhub tag 커맨드 그룹 추가, pfhub portfolio tag 제거), docs/runtime-flows.md (tag CLI flow 추가) -- Modules: - - app/cli/commands/ — tag.py 신규 파일 (pfhub tag 커맨드 그룹) - - app/cli/main.py — app.add_typer(tag_app, name="tag") 추가 - - app/cli/commands/portfolio.py — tag_app, tag_add, tag_list, tag_remove 제거 - - app/services/ — 태그 CLI 서비스 메서드 (create, list, deactivate, apply, remove) 추가 -- Tests: pfhub tag 커맨드 각 AC 기능 테스트, pfhub portfolio tag 제거 확인 - -## Notes -- runtime-flows.md의 CLI 흐름 제약 준수: AppContext 통해 settings와 repository 주입. -- `pfhub tag apply`/`remove`는 REST API(REQ-005)를 통해 정책 검증이 이루어지므로 CLI는 HTTP 오류를 그대로 사용자에게 표시. -- REQ-004, REQ-005가 선행 구현되어야 한다. -- Source: dcy-002 FR-6 - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-005 confirm-batch-verification 완료. pfhub tag create/list/deactivate/apply/remove CLI 모든 AC 검증 통과. 기존 pfhub portfolio tag add/list/remove 제거 확인. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-005 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-002 confirm-discovery 후 REQ 초안 작성 - -#### Intent -- dcy-002 FR-6를 Interface REQ로 분리; 기존 pfhub portfolio tag 명령어 교체 명시 - -#### Acceptance Criteria Delta -- Added: - - AC-1~9 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/project-structure.md, docs/runtime-flows.md — 최초 지정 -- Modules: - - app/cli/commands/tag.py (신규), app/cli/main.py, app/cli/commands/portfolio.py, app/services/ — 최초 지정 -- Tests: - - 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-004, REQ-005 선행 의존성 확인 diff --git a/docs/srs/Interface/req-010_admin-asset-catalog-crud.md b/docs/srs/Interface/req-010_admin-asset-catalog-crud.md deleted file mode 100644 index 1c2fb0c..0000000 --- a/docs/srs/Interface/req-010_admin-asset-catalog-crud.md +++ /dev/null @@ -1,231 +0,0 @@ -# REQ-010: admin asset catalog CRUD - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- admin이 CLI 및 HTTP API를 통해 거래 가능한 종목을 `assets` catalog에 등록·수정·비활성화할 수 있도록 한다. -- admin이 아닌 user 또는 daemon의 catalog 직접 변경 시도를 HTTP 403으로 거부한다(NFR-3). - -## Requirement -- CLI: `pfhub asset create [--markets ] [--display-name ]` — admin 권한 필요. markets 값은 쉼표 구분 MarketCode Enum 값. -- CLI: `pfhub asset list [--inactive]` — active asset만 기본 출력; `--inactive` 지정 시 inactive 포함 전체 출력. -- CLI: `pfhub asset update [--markets ] [--display-name ]` — admin 권한 필요. -- CLI: `pfhub asset deactivate [--force]` — admin 권한 필요. `--force` 없으면 확인 프롬프트 출력. -- HTTP API: `POST /v1/assets` — admin token 필요. request body에 symbol, markets, display_name. 성공 시 HTTP 201 + 생성된 asset 반환. -- HTTP API: `PATCH /v1/assets/{asset_id}` — admin token 필요. markets, display_name 변경 가능. 성공 시 HTTP 200 + 갱신된 asset 반환. -- HTTP API: `PATCH /v1/assets/{asset_id}/deactivate` — admin token 필요. `status='inactive'`로 변경. 성공 시 HTTP 200. -- HTTP API: `GET /v1/assets` — admin token 필요. query param `status` (active|inactive|all, 기본 `active`), `market` (선택, MarketCode 1개). 응답: `{ items: [{ asset_id, symbol, markets, display_name, status, created_at, updated_at }] }` 형식. 응답 본문에 portfolio 관련 필드는 포함하지 않는다(path 의미 분리 — portfolio 조회는 `GET /v1/portfolios`). -- admin 이외의 token으로 POST/PATCH/GET 요청 시 HTTP 403을 반환한다. -- asset hard DELETE는 제공하지 않는다(soft delete 전용). -- `app/cli/commands/asset.py` 신규 파일로 pfhub asset 커맨드 그룹을 구현한다. -- `app/cli/main.py`에 `app.add_typer(asset_app, name="asset")`을 추가한다. -- `app/services/` 또는 관련 서비스에 asset create/update/deactivate/list 로직을 추가한다. - -## Acceptance Criteria -1. Given admin 토큰, When `pfhub asset create "005930.KS" --markets KRX,NXT`, Then assets catalog에 symbol='005930.KS', markets=['KRX','NXT'], status='active' 항목이 생성된다. -2. Given admin 토큰, When `pfhub asset list`, Then status='active'인 asset 목록이 출력된다. -3. Given active/inactive asset 모두 존재, When `pfhub asset list --inactive`, Then inactive 포함 전체 목록이 출력된다. -4. Given admin 토큰, When `pfhub asset deactivate "005930.KS" --force`, Then 해당 asset의 status가 'inactive'로 변경된다. -5. Given admin 토큰, When `POST /v1/assets` with valid symbol and markets, Then HTTP 201과 생성된 asset_id가 반환된다. -6. Given admin 토큰, When `PATCH /v1/assets/{asset_id}` with updated markets, Then HTTP 200과 변경된 asset이 반환된다. -7. Given admin 토큰, When `PATCH /v1/assets/{asset_id}/deactivate`, Then HTTP 200과 status='inactive'가 반환된다. -8. Given non-admin token (user 또는 service token), When `POST /v1/assets` 요청, Then HTTP 403이 반환된다. -9. Given admin 토큰 + active/inactive asset 혼재, When `GET /v1/assets`, Then HTTP 200 + status='active' 항목만 포함된 `{items: [...]}` 응답. 각 item은 `asset_id, symbol, markets, display_name, status, created_at, updated_at` 필드만 포함하고 portfolio 관련 필드(`portfolio_id`, `owner_user_id`, `cash_balance` 등)는 포함하지 않는다. -10. Given admin 토큰 + active/inactive asset 혼재, When `GET /v1/assets?status=all`, Then 두 상태 모두 포함; `GET /v1/assets?status=inactive`, Then inactive만 포함. -11. Given admin 토큰 + 서로 다른 markets 값을 가진 asset 다수, When `GET /v1/assets?market=KRX`, Then `markets`에 `KRX`가 포함된 asset만 응답된다. -12. Given non-admin token (read/trade-write 권한 또는 service token), When `GET /v1/assets`, Then HTTP 403이 반환된다. - -## Impacted Area -- Docs: - - docs/project-structure.md — `app/cli/` Command groups에 `asset` 추가 (REQ-015에서 처리) - - docs/interface-contract.md — POST/PATCH/GET /v1/assets 엔드포인트 명세 추가 (CHG-20260521-02에서 GET 추가; 후속 docs sync 필요 시 별도 REQ) -- Modules: - - `app/cli/commands/asset.py` — 신규 파일, pfhub asset 커맨드 그룹 - - `app/cli/main.py` — `app.add_typer(asset_app, name="asset")` 추가 - - `app/services/` — asset create/update/deactivate/list 서비스 메서드 추가 - - `app/api/v1_assets.py` — POST, PATCH 라우트 추가; GET /v1/assets 라우트는 admin catalog 조회로 재정의(기존 token_type 분기 portfolio 조회 코드 제거) - - `app/schemas/v1.py` — `CreateAssetRequest`, `UpdateAssetRequest`, `AssetCatalogItem`, `AssetCatalogListResponse` 스키마 추가 -- Tests: - - `tests/test_asset_catalog.py` (신규) — admin CRUD CLI/API AC 검증, non-admin 403 검증, GET /v1/assets AC-9~12 검증 - - `tests/test_v1.py` (기존) — GET /v1/assets에 portfolio 응답을 기대하는 케이스가 있다면 GET /v1/portfolios로 이전 또는 catalog 응답 schema로 갱신 - -## Notes -- `pfhub asset list`의 HTTP 대응(catalog 전용 GET): dcy-003 옵션 B(path 명명 분리) 결정에 따라 REQ-013(GET /v1/assets 포트폴리오 조회)이 Deprecated(2026-05-20) 처리되어 path 충돌 우려 해소. **`GET /v1/assets`는 admin catalog 조회 전용**으로 본 REQ에서 다룬다(2026-05-21 CHG-20260521-02로 augmentation 적용). 포지션 조회 `GET /v1/portfolios`는 REQ-028에서 분리 구현됨. -- deactivate 후 해당 asset을 참조하는 기존 portfolio는 보존된다. 신규 portfolio 생성만 거부(REQ-012에서 처리). -- REQ-009 선행 필요. -- Source: dcy-001 FR-2, NFR-3; CHG-20260521-02 augmentation는 dcy-003 옵션 B (REQ-013 Deprecation) 후속 작업. - -## Change Log - -### CHG-20260521-03 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented (CHG-20260521-02 augmentation으로 신설된 AC-9~12가 BAT-012 verification에서 evidence 확보) - -#### Intent -- BAT-012 confirm-batch-verification 완료. GET /v1/assets admin catalog 재정의(AC-9~12) 신규 evidence + 기존 AC-1~8 회귀 통과 모두 확인. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-012 (release-candidate) — primary trace, AC-1~12 전체 evidence - - BAT-002 (release-candidate) — augmentation superseded by BAT-012. BAT-002 본래 trace인 REQ-012/REQ-015는 영향 없음. -- Releases: - - REL-001 — CHG-20260521-02 augmentation scope는 BAT-012로 이관됨. REL-001 release 본문은 BAT-012 inclusion 정리 시 별도 갱신 필요. - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL-001 release 문서에 BAT-012 trace 반영 (또는 신규 REL에 BAT-012 포함) - -#### Validation Plan -- tests/test_asset_catalog.py AC-9~12 신규 8건: test_get_v1_assets_admin_returns_catalog_response, test_get_v1_assets_status_filter_*, test_get_v1_assets_market_filter, test_get_v1_assets_user_token_returns_403, test_get_v1_assets_service_token_returns_403 -- 기존 AC-1~8 회귀 9건: test_asset_cli_create_success / list / update / deactivate / test_asset_http_create_returns_201 / update_returns_200 / deactivate_returns_200 / non_admin_403 -- 전체 회귀: 362 passed in 13.06s - -### CHG-20260521-02 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Implemented → Approved (REL-001 smoke 검증에서 GET /v1/assets가 dcy-001 시점 portfolio 조회 코드를 유지 중인 불일치 확인. 본 REQ Notes의 1순위 경로(REQ-010 augmentation)에 따라 GET 명세를 추가하고 기존 구현을 재정의.) - -#### Intent -- dcy-003 옵션 B로 REQ-013 Deprecated 후 본 REQ Notes에 명시된 "GET /v1/assets는 admin catalog 조회 전용" 재정의가 미구현 상태로 잔존. REL-001 배포 직전 로컬 smoke([rel-001.md:50](../../releases/rel-001_20260521_v1-api-full-surface.md#L50))에서 admin session token → HTTP 403 "Unsupported token type" 발생으로 release 체크리스트와 코드 상태 불일치 확인. 본 augmentation으로 GET /v1/assets를 admin catalog 조회로 재정의해 path 분리 정책(dcy-003 옵션 B)을 코드에 반영. - -#### Acceptance Criteria Delta -- Added: - - AC-9 (admin token GET 200 + asset catalog 응답) - - AC-10 (`status=all`/`status=inactive` 필터) - - AC-11 (`market=` 필터) - - AC-12 (non-admin token → HTTP 403) -- 기존 AC-1~8: 변경 없음. - -#### Impacted Area Delta -- Modules added: - - `app/api/v1_assets.py` — GET /v1/assets 라우트를 portfolio 조회에서 asset catalog 조회로 재정의 (token_type 분기 제거, admin permission 검증 + `list_assets` 호출) - - `app/schemas/v1.py` — `AssetCatalogItem`, `AssetCatalogListResponse` 신규 schema -- Tests added/updated: - - `tests/test_asset_catalog.py` — GET /v1/assets AC-9~12 검증 신규 케이스 (기존 portfolio 조회 의미의 GET /v1/assets 테스트가 있다면 제거/대체) - - `tests/test_v1.py` — 기존 GET /v1/assets 호출 패턴이 portfolio 응답을 기대한다면 GET /v1/portfolios로 이전 또는 응답 schema 변경 반영 -- Repository: - - `app/repositories/postgres.py` — `list_assets` 메서드가 이미 존재하면 재사용; 없으면 보강 - -#### Delivery Trace Delta -- Batches: - - BAT-002 (asset-catalog-api-completion) — augmentation 누적, verification 재실행 필요 -- Releases: - - REL-001 — scope 잔존, 본 augmentation 포함 (`docs/releases/rel-001_20260521_v1-api-full-surface.md`) - -#### Revalidation Impact -- Reverification Needed: yes -- Existing Implementation Invalidated: yes (기존 GET /v1/assets portfolio 조회 동작이 제거됨) -- Status Recommendation: revert-to-approved -- Follow-up Action: 구현 → pytest 회귀 → 로컬 smoke 재실행 → confirm-req-implemented 재진입 - -#### Validation Plan -- pytest로 AC-9~12 검증; 로컬 smoke로 admin session token + GET /v1/assets → HTTP 200 + asset catalog 응답 확인 - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-002 confirm-batch-verification 완료. admin asset catalog CRUD CLI/HTTP API(POST/PATCH/deactivate), non-admin 403 모든 AC 검증 통과. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-002 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행 - -#### Intent -- dcy-001 FR-2, NFR-3을 Interface REQ로 분리; admin catalog CRUD CLI + HTTP API 정의 - -#### Acceptance Criteria Delta -- Added: - - AC-1~8 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/project-structure.md, docs/interface-contract.md — 최초 지정 -- Modules: - - app/cli/commands/asset.py, app/cli/main.py, app/services/, app/api/v1_assets.py, app/schemas/ — 최초 지정 -- Tests: - - tests/test_asset_catalog.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-009 선행 의존성 확인 - -#### Validation Plan -- pytest로 AC-1~8 검증; CLI는 CliRunner, HTTP는 TestClient 사용 diff --git a/docs/srs/Interface/req-012_portfolio-create-catalog-validation.md b/docs/srs/Interface/req-012_portfolio-create-catalog-validation.md deleted file mode 100644 index e7beeba..0000000 --- a/docs/srs/Interface/req-012_portfolio-create-catalog-validation.md +++ /dev/null @@ -1,130 +0,0 @@ -# REQ-012: portfolio 생성 CLI catalog 검증 - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- user가 `pfhub portfolio create` CLI로 portfolio를 생성할 때 지정한 asset이 catalog에 active 상태로 존재하는지 검증하여, 미등록 또는 비활성 asset 기반의 portfolio 생성을 방지한다. - -## Requirement -- `pfhub portfolio create` 명령에 `--asset-id ` 또는 `--symbol ` 옵션을 추가하여 catalog asset을 지정하도록 한다. -- portfolio 생성 전 지정된 `asset_id`(또는 symbol로 조회한 asset_id)가 `assets` catalog에 `status='active'`로 존재하는지 검증한다. -- catalog에 없거나 `status='inactive'`인 asset이면 오류 메시지와 함께 생성을 거부한다. -- `app/services/portfolio_service.py`의 `create_portfolio`에 asset 검증 로직을 추가한다. -- user portfolio CRUD HTTP API(POST /portfolios 등)는 이 REQ 범위 밖이다(별도 Discovery에서 처리). - -## Acceptance Criteria -1. Given active asset이 catalog에 존재, When `pfhub portfolio create --asset-id --user `, Then portfolio가 생성되고 해당 asset_id로 연결된다. -2. Given 존재하지 않는 asset_id, When `pfhub portfolio create` with 미등록 asset_id, Then "asset not found" 오류가 출력되고 portfolio가 생성되지 않는다. -3. Given `status='inactive'`인 asset_id, When `pfhub portfolio create` with 해당 asset_id, Then "asset is inactive" 오류가 출력되고 portfolio가 생성되지 않는다. -4. Given `--symbol ` 옵션 사용, When 해당 symbol이 catalog에 active로 존재, Then symbol로 asset_id를 조회하여 portfolio를 생성한다. - -## Impacted Area -- Docs: 없음 (별도 문서 갱신 불필요) -- Modules: - - `app/cli/commands/portfolio.py` — create 명령에 `--asset-id` / `--symbol` 옵션 추가 - - `app/services/portfolio_service.py` — create_portfolio에 catalog 검증 로직 추가 -- Tests: - - `tests/test_asset_catalog.py` (신규) — catalog 검증 AC-1~4 검증 - -## Notes -- REQ-009(assets 테이블), REQ-011(portfolios 재설계) 선행 필요. -- HTTP API를 통한 portfolio 생성 시 catalog 검증 정책은 동일하게 적용되어야 하나, HTTP mutation API는 Out of Scope(별도 Discovery 처리 예정)이다. -- Source: dcy-001 FR-4 - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-002 confirm-batch-verification 완료. pfhub portfolio create --asset-id/--symbol catalog 검증(존재·비활성 거부) 모든 AC 검증 통과. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-002 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행 - -#### Intent -- dcy-001 FR-4를 Interface REQ로 분리; portfolio create CLI catalog 검증 정의 - -#### Acceptance Criteria Delta -- Added: - - AC-1~4 최초 작성 - -#### Impacted Area Delta -- Docs: - - 없음 -- Modules: - - app/cli/commands/portfolio.py, app/services/portfolio_service.py — 최초 지정 -- Tests: - - tests/test_asset_catalog.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-009, REQ-011 선행 의존성 확인 - -#### Validation Plan -- pytest CliRunner로 AC-1~4 검증 diff --git a/docs/srs/Interface/req-013_get-v1-assets-token-branching.md b/docs/srs/Interface/req-013_get-v1-assets-token-branching.md deleted file mode 100644 index 6319a3e..0000000 --- a/docs/srs/Interface/req-013_get-v1-assets-token-branching.md +++ /dev/null @@ -1,151 +0,0 @@ -# REQ-013: GET /v1/assets 토큰 타입별 분기 재정의 - -- Status: Deprecated -- Type: Interface -- Priority: High -- Owner: Justin Ko -- Deprecated By: dcy-003 (path 명명 분리 옵션 B 결정), 2026-05-20 - -## Intent -- 현재 service token(daemon) 전용으로 구현된 `GET /v1/assets`를 user token 및 admin token도 지원하는 통합 portfolio 조회 엔드포인트로 재정의한다. -- daemon(service token), 로그인 사용자(user token), 관리자(admin token) 모두 단일 엔드포인트로 각자의 portfolio 목록을 조회할 수 있도록 한다. - -## Requirement -- `GET /v1/assets` 엔드포인트를 토큰 타입에 따라 아래와 같이 분기한다. - - **service token (daemon)**: auth context에서 `daemon_id`를 자동 추출, `managed_by_daemon_id = auth.daemon_id` 필터링. `daemon_id`가 없으면 HTTP 403. - - **user token**: `owner_user_id = auth.user_id` 필터링. 본인 portfolio만 반환. - - **admin token**: 기본 `owner_user_id = auth.user_id`(본인 portfolio). `?user_id=` 파라미터 지정 시 해당 user의 portfolio 반환. -- 모든 토큰 타입에서 `?market=` 파라미터를 지원한다. 지정 시 ` = ANY(assets.markets)`인 portfolio만 반환한다. -- 응답 항목의 `asset_id`는 catalog의 `assets.asset_id`(UUID)를 참조한다(현재 `portfolio.portfolio_id`가 아님). -- `app/api/v1_assets.py`의 `list_assets` 함수를 토큰 타입 분기 로직으로 재작성한다. 현재 `_require_service_token` 강제를 제거하고 토큰 타입 자동 분기로 교체한다. - -## Acceptance Criteria -1. Given daemon service token(daemon_id 있음), When `GET /v1/assets`, Then `managed_by_daemon_id = auth.daemon_id`로 필터링된 portfolio 목록이 반환된다. -2. Given daemon service token(daemon_id 없음), When `GET /v1/assets`, Then HTTP 403이 반환된다. -3. Given user token, When `GET /v1/assets`, Then `owner_user_id = auth.user_id`인 portfolio 목록만 반환된다. -4. Given admin token, When `GET /v1/assets` (`?user_id` 없음), Then admin 본인의 portfolio 목록이 반환된다. -5. Given admin token, When `GET /v1/assets?user_id=`, Then 해당 user의 portfolio 목록이 반환된다. -6. Given 모든 토큰 타입, When `GET /v1/assets?market=KRX`, Then `KRX = ANY(assets.markets)`인 portfolio만 반환된다. -7. Given 응답 항목, When `asset_id` 필드 확인, Then `assets.asset_id`(catalog UUID)를 참조한다(portfolio_id가 아님). - -## Impacted Area -- Docs: - - docs/runtime-flows.md — "GET /v1/assets" 플로우 재정의 (REQ-015에서 처리). 현재 기술(service-token-only, daemon-id guard)을 토큰 타입 분기 구조로 교체. - - docs/interface-contract.md — GET /v1/assets 명세 갱신 (REQ-015에서 처리) -- Modules: - - `app/api/v1_assets.py` — `list_assets` 토큰 타입 분기 재작성, `?user_id` 파라미터 추가, `_require_service_token` 강제 제거 - - `app/repositories/postgres.py` — token-type별 portfolios 쿼리 분기 -- Tests: - - `tests/test_v1_asset_catalog.py` (신규) — AC-1~7 검증 - - 기존 `test_v1_assets.py` 또는 관련 테스트 — service token 전용 동작 갱신 - -## Notes -- 현재 `app/api/v1_assets.py`의 `_require_service_token`은 service token 전용으로 구현되어 있다. 이 REQ는 해당 guard를 제거하고 토큰 타입 자동 분기로 완전 재작성하는 작업이다. -- 현재 `runtime-flows.md` "Flow: v1 API — GET /v1/assets"는 service-token-only 구조를 기술하고 있다. 이 REQ 구현 후 해당 플로우를 REQ-015에서 갱신해야 한다. -- REQ-009(assets 테이블), REQ-011(portfolios 재설계) 선행 필요. -- Source: dcy-001 FR-5 - -## Change Log - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → **Deprecated**. dcy-003(`docs/discovery/dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md`, confirmed 2026-05-20)의 옵션 B(완전 분리) 결정으로 본 REQ의 path 결정이 무효화됨. - -#### Intent -- dcy-003은 `GET /v1/assets`를 admin asset catalog 전용으로 회수하고 포지션 조회는 `GET /v1/portfolios` 신설 경로로 분리하는 정책을 채택했다. 본 REQ의 통합 endpoint 정의(`GET /v1/assets`가 토큰 타입별로 catalog/포지션을 동시 처리)는 dcy-003 결정에 따라 더 이상 기준 문서가 아니므로 Deprecated로 전환한다. -- 본 REQ의 후속: - - `GET /v1/assets` (admin catalog 조회 의미) → dcy-001 batch design 단계에서 REQ-010 또는 신규 REQ로 분리 처리 (REQ-010 Notes 참조) - - `GET /v1/portfolios` (포지션 토큰 분기 조회) → dcy-003 FR-5에서 별도 신규 REQ로 작성 예정 (dcy-003 draft-req 단계) - -#### Acceptance Criteria Delta -- Removed: AC-1~7 전체 (본 REQ Deprecated로 인해 검증 대상에서 제외). 후속 신규 REQ에서 새 path 기준으로 재작성 예정. - -#### Impacted Area Delta -- Docs: - - docs/discovery/dcy-003 §6 OQ-5, §10 CONFIRM_DCY001_FR5_CHANGE_REQ가 본 Deprecated 처리의 근거 - - docs/srs/Interface/req-010 Notes에 후속 GET /v1/assets catalog 명세 처리 계획 반영 -- Modules: - - 본 REQ Approved 시점에 코드 변경이 일어나지 않은 상태였으므로 코드 회귀 없음. 후속 신규 REQ에서 `app/api/v1_portfolios.py` 신규 + `app/api/v1_assets.py` 정리. -- Tests: - - 신규 `tests/test_v1_asset_catalog.py`(REQ-013 Notes에 지정)는 작성되지 않은 상태. 후속 신규 REQ로 이관. - -#### Delivery Trace Delta -- Batches: 본 REQ가 포함된 batch 미정. Deprecated 이후 batch 편입 없음. -- Releases: 본 REQ 포함 release 미정. - -#### Revalidation Impact -- Reverification Needed: no (구현 미진행 상태에서 Deprecated) -- Existing Implementation Invalidated: no -- Status Recommendation: deprecated -- Follow-up Action: dcy-003 draft-req 단계에서 `GET /v1/portfolios` 신규 REQ 작성, dcy-001 batch design 단계에서 `GET /v1/assets` catalog 조회 명세 처리(REQ-010 보강 또는 신규 REQ) - -#### Validation Plan -- 없음 (Deprecated 전환만) - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행 - -#### Intent -- dcy-001 FR-5를 Interface REQ로 분리; GET /v1/assets 토큰 타입별 분기 정의 - -#### Acceptance Criteria Delta -- Added: - - AC-1~7 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/runtime-flows.md, docs/interface-contract.md — 최초 지정 -- Modules: - - app/api/v1_assets.py, app/repositories/postgres.py — 최초 지정 -- Tests: - - tests/test_v1_asset_catalog.py (신규), 기존 v1 테스트 — 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-009, REQ-011 선행 의존성 확인 - -#### Validation Plan -- pytest TestClient로 AC-1~7 검증; 토큰 타입별 시나리오를 각각 독립 테스트로 분리 diff --git a/docs/srs/Interface/req-016_client-token-issuance-deprecation.md b/docs/srs/Interface/req-016_client-token-issuance-deprecation.md deleted file mode 100644 index cd4802a..0000000 --- a/docs/srs/Interface/req-016_client-token-issuance-deprecation.md +++ /dev/null @@ -1,127 +0,0 @@ -# REQ-016: client token 발급 경로 코드 폐지 - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- dcy-003 옵션 B 결정으로 client token이 v1 portfolio mutation에서 거부되며, dcy-004 OQ-8 결정으로 user 자동화 use case는 admin 발급 service token으로 흡수된다. client token 발급 경로 자체를 코드에서 폐지해 토큰 surface를 session/service/env 3종으로 축소하고 발급 경로 중복을 제거한다. -- DATA-1 inventory(2026-05-20) active client token 0건 확인됐으므로 즉시 코드 폐지로 진행하며 별도 revoke 스크립트나 grace period 운영 없이 release 단계에서 직접 제거한다. - -## Requirement -- `POST /auth/tokens` HTTP route([app/api/auth_routes.py:100-122](app/api/auth_routes.py#L100-L122)), `app/services/auth_service.py:issue_client_token`, `app/schemas/auth.py:CreateClientTokenRequest`/`IssuedClientTokenResponse`, 관련 헬퍼·테스트를 모두 제거한다. -- HTTP 호출 시 라우트가 등록되어 있지 않아 FastAPI 기본 404 응답을 반환하도록 한다(별도 405 처리 추가하지 않음). -- 폐지 release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` 재확인을 release plan에 포함하며, >0이면 발급 사유 조사 후 release 보류 또는 즉시 revoke 처리한다. -- 토큰 분기에서 `token_type='client'`는 더 이상 발급될 수 없으므로 dcy-003 FR-1/3/4/5의 게이트는 자연 유효한 상태로 유지되되 게이트 자체는 제거하지 않는다(env token 침투 방어). - -## Acceptance Criteria -1. Given codebase grep `grep -rn "issue_client_token\|CreateClientTokenRequest\|IssuedClientTokenResponse"`, When 본 REQ 구현 후, Then 결과는 0건이다. -2. Given `POST /auth/tokens` HTTP 호출, When 본 REQ 구현 후, Then HTTP 404(또는 FastAPI 기본 not-found 응답)가 반환된다. -3. Given 폐지 release 직전 DATA-1 재확인 쿼리, When 실행, Then `COUNT = 0` 결과가 release plan evidence로 첨부된다. -4. Given dcy-003 신규 REQ들의 token_type 게이트 회귀 테스트, When 본 REQ 구현 후 재실행, Then 모두 통과한다(client token 발급 불가에 따른 회귀 없음). - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — POST /auth/tokens 명세 제거 (REQ-019 baseline 정합 작업에 포함) -- Modules: - - `app/api/auth_routes.py` — POST /auth/tokens 라우트, CreateClientTokenRequest/IssuedClientTokenResponse import, `create_token`/`revoke_token` 중 client 의존 부분 제거 - - `app/services/auth_service.py` — `issue_client_token`, `IssuedClientToken` 데이터 클래스 제거. 단, `list_tokens`/`revoke_token` 등이 client 토큰을 함께 다루는 부분은 token_type 분기 정리 검토 - - `app/schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 제거 -- Tests: - - 신규 `tests/test_auth_surface_after_client_deprecation.py` — AC-1/AC-2/AC-4 검증 - - 기존 `tests/test_auth.py` 중 client token 관련 케이스 제거 또는 service token 케이스로 대체 - -## Notes -- DATA-1 inventory(2026-05-20) active client token 0건 확인됨. 발급 보유자에 대한 별도 통보 절차 불요. -- 토큰 surface 3종(session/service/env)으로 축소 후 user 자동화 use case는 admin 발급 service token으로 흡수(dcy-004 OQ-8 결정). -- dcy-003 OQ-5 결정의 token_type 게이트는 본 REQ 구현 후에도 코드에 유지(env token 외부 침투 방어). -- Source: dcy-004 FR-1, dcy-004 NFR-1, dcy-003 OQ-5 후속. - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-006 confirm-batch-verification 완료. client token 발급 경로(POST /auth/tokens, issue_client_token) 코드 제거, HTTP 404 확인, token_type 게이트 회귀 없음 확인(AC-1/2/4). AC-3(release 직전 DATA-1 재확인)은 운영 릴리즈 단계에서 evidence 수집 예정. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-006 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL-001 배포 직전 DATA-1 SELECT COUNT 재확인(AC-3) evidence를 release execution evidence에 첨부 - -### CHG-20260520-02 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-1 (client token 발급 경로 코드 폐지) 산출물. - -#### Intent -- 토큰 surface 3종 축소 + client 폐지 정책 코드 반영. - -#### Acceptance Criteria Delta -- Added: - - AC-1~4 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md (REQ-019에서 처리) -- Modules: app/api/auth_routes.py, app/services/auth_service.py, app/schemas/auth.py — 최초 지정 -- Tests: tests/test_auth_surface_after_client_deprecation.py (신규), 기존 tests/test_auth.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; release 직전 DATA-1 재확인 절차 포함 - -#### Validation Plan -- pytest로 AC-1~4 검증; codebase grep + HTTP 404 회귀 + dcy-003 token_type 게이트 회귀 diff --git a/docs/srs/Interface/req-018_service-token-http-daemon-id-forward.md b/docs/srs/Interface/req-018_service-token-http-daemon-id-forward.md deleted file mode 100644 index 5dd1cee..0000000 --- a/docs/srs/Interface/req-018_service-token-http-daemon-id-forward.md +++ /dev/null @@ -1,125 +0,0 @@ -# REQ-018: POST /auth/service-tokens body daemon_id forward 보정 - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- 현행 `POST /auth/service-tokens` HTTP route([app/api/auth_routes.py:131-137](app/api/auth_routes.py#L131-L137))가 `IssueServiceTokenRequest.daemon_id` 필드를 스키마에서 노출하지만 `service.issue_service_token` 호출 시 forward하지 않아 silently drop되는 결함을 정상화한다. -- CLI `pfhub service-token issue --daemon-id`는 정상 forward하나 HTTP 경로만 daemon binding이 불가능한 상태였다. HTTP/CLI 일관성을 회복한다. - -## Requirement -- `app/api/auth_routes.py:131-137`의 `service.issue_service_token(...)` 호출에 `daemon_id=payload.daemon_id`를 추가한다. -- 비존재 또는 비활성 daemon_id가 body로 전달되면 `AuthService.issue_service_token`의 기존 검증 로직(`get_daemon_by_id` None 또는 `is_active=False`)이 ValueError를 발생시키고, `_raise_auth_error`를 통해 HTTP 4xx로 변환된다. -- daemon_id 검증 실패 응답 코드는 ValueError detail에 따라 HTTP 400 또는 404로 분기된다(`_raise_auth_error` 기존 로직). -- 본 REQ는 HTTP body daemon_id 처리만 보정한다. CLI 동작, daemon registry 자체, service token 발급 정책은 변경하지 않는다. - -## Acceptance Criteria -1. Given admin token, When `POST /auth/service-tokens` body `{"name": "...", "username": "...", "daemon_id": ""}` 호출, Then HTTP 201 + issued token의 `auth_token.daemon_id == ` 반환. -2. Given admin token, When `POST /auth/service-tokens` body `{"name": "...", "username": "...", "daemon_id": ""}` 호출, Then HTTP 4xx + `_raise_auth_error` 패턴에 따른 오류 메시지 반환. -3. Given admin token, When `POST /auth/service-tokens` body `daemon_id` 필드 미포함 또는 null, Then 기존 동작 유지(daemon binding 없는 service token 발급, HTTP 201). -4. Given AC-1과 CLI `pfhub service-token issue NAME --user U --daemon-id ` 실행 결과 비교, When 둘 다 발급된 토큰의 `daemon_id` 확인, Then 동일한 값이 저장된다(HTTP/CLI 동등성). - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — POST /auth/service-tokens body 명세에 daemon_id forward 동작 추가 (REQ-019에서 처리) -- Modules: - - `app/api/auth_routes.py:131-137` — `issue_service_token` 호출 시 `daemon_id=payload.daemon_id` 추가 -- Tests: - - 갱신 `tests/test_auth.py` — AC-1~3 HTTP request 시뮬레이션 추가 - - 갱신 `tests/test_daemon_registry.py` — AC-4 HTTP/CLI 동등성 회귀 - -## Notes -- 본 REQ는 1-line 보정에 가까운 단순 결함 수정이지만, HTTP API 사용자에게는 daemon binding 경로가 새로 작동하기 시작하는 의미 있는 변경이라 별도 REQ로 분리. -- daemon registry 자체(존재/활성 검증)는 기존 `AuthService.issue_service_token` 내부 로직이 그대로 처리하므로 본 REQ에서는 daemon 모델을 건드리지 않는다. -- HTTP body daemon_id를 누락하거나 null로 보내는 경우의 기본 동작(unbound service token 발급)은 dcy-004 OQ-7에서 "수동 매핑" 정책이 결정되었으므로 본 REQ에서 추가 제한하지 않는다. NULL daemon 토큰의 운영 정리는 REQ-023(마이그레이션 CLI)에서 처리. -- Source: dcy-004 FR-3. - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-006 confirm-batch-verification 완료. POST /auth/service-tokens daemon_id forward 1-line 보정 구현. HTTP/CLI daemon binding 동등성, non-existing daemon 4xx 분기 모든 AC 검증 통과. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-006 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260520-02 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-3 산출물. - -#### Intent -- HTTP/CLI 토큰 발급 surface 일관성 회복 (daemon_id forward 누락 결함 정상화). - -#### Acceptance Criteria Delta -- Added: - - AC-1~4 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md (REQ-019에서 처리) -- Modules: app/api/auth_routes.py — 최초 지정 -- Tests: tests/test_auth.py, tests/test_daemon_registry.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입 - -#### Validation Plan -- pytest TestClient로 AC-1~4 검증; HTTP/CLI 동등성 회귀 diff --git a/docs/srs/Interface/req-022_daemon-deactivation-auto-revoke.md b/docs/srs/Interface/req-022_daemon-deactivation-auto-revoke.md deleted file mode 100644 index 0444912..0000000 --- a/docs/srs/Interface/req-022_daemon-deactivation-auto-revoke.md +++ /dev/null @@ -1,103 +0,0 @@ -# REQ-022: daemon deactivation 시 바인딩 service token 자동 revoke - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- 현재 `pfhub daemon update --deactivate`가 daemon `is_active=false`만 설정하고 바인딩된 service token에 영향이 없어 비활성 daemon의 토큰이 계속 유효한 상태로 남는 운영 공백을 해소한다. -- dcy-004 OQ-3 결정에 따라 deactivation 즉시 바인딩 service token 모두 revoke해 daemon 권한 종료를 token 단계까지 즉각 반영한다. - -## Requirement -- `pfhub daemon update --deactivate` 실행 시(또는 동등 service 메서드 호출 시) 동일 트랜잭션 내에서 `auth_tokens` 중 `daemon_id == ` AND `revoked_at IS NULL`인 모든 row를 revoke(`revoked_at = NOW()`)한다. -- 회피 옵션(예: `--keep-tokens`)은 노출하지 않는다(정책 일관성). -- graceful drain은 daemon 측 책임으로 분리한다. release plan에 "(1) daemon 프로세스 종료/drain 완료 확인 → (2) `pfhub daemon update --deactivate` 실행 → (3) 자동 revoke" 순서로 SOP를 명시한다(REQ-019 baseline 작업에 포함). -- `app/cli/commands/daemon.py:update_daemon`이 `deactivate=True`로 호출될 때 `repository` 또는 신규 service 메서드(`revoke_tokens_for_daemon(daemon_id)`)를 통해 자동 revoke를 트리거한다. -- 재활성화(`--activate`)는 자동 token 재발급이나 unrevoke를 수행하지 않는다. 운영자가 별도 발급 또는 rebind를 수행해야 한다. -- 본 REQ는 v1 API의 daemon 모델([app/domain/models.py:Daemon](app/domain/models.py))과 service token 모델 위에서 작동하며 daemon registry 자체는 변경하지 않는다. - -## Acceptance Criteria -1. Given active daemon에 바인딩된 active service token 1개 이상 존재, When `pfhub daemon update --deactivate --force` 실행, Then 해당 daemon에 바인딩된 모든 service token의 `revoked_at`가 NOT NULL로 갱신되고 daemon `is_active=false`로 설정된다. -2. Given AC-1 실행 후, When revoke된 token으로 v1 API 호출(예: `GET /v1/portfolios` 또는 `GET /v1/assets`), Then HTTP 401(Invalid token) 반환. -3. Given daemon에 바인딩된 token이 0개인 daemon, When `--deactivate --force` 실행, Then daemon만 비활성화되고 token revoke 동작은 no-op이며 정상 종료. -4. Given AC-1 실행 후 `--activate`로 재활성화, When 재실행, Then daemon만 활성화되고 이미 revoke된 token은 자동 복구되지 않는다(운영자가 신규 발급 필요). -5. Given deactivation 실행 중 in-flight 거래(daemon이 ledger trade 호출 중), When 자동 revoke가 즉시 적용, Then in-flight 거래는 daemon 측 graceful drain 책임이며 본 REQ는 추가 보호하지 않음(release plan SOP 참조). - -## Impacted Area -- Docs: - - `docs/runtime-flows.md` — "Daemon Deactivation 자동 Revoke" flow 추가 (REQ-019 baseline 작업에 포함) - - `README.md` — daemon provisioning runbook의 deactivation SOP 갱신 (REQ-019 또는 release plan) -- Modules: - - `app/cli/commands/daemon.py:update_daemon` — `--deactivate` 분기에서 자동 revoke 호출 추가 - - `app/services/auth_service.py` 또는 신규 service — `revoke_tokens_for_daemon(daemon_id)` 헬퍼 추가 - - `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `revoke_tokens_by_daemon_id(daemon_id)` 메서드 신규 추가 -- Tests: - - 갱신 `tests/test_daemon_registry.py` — AC-1~4 회귀 시나리오 추가 - - 신규 또는 갱신 `tests/test_v1_*.py` — AC-2 revoke된 token으로 v1 호출 시 401 확인 - -## Notes -- graceful drain을 본 REQ 범위에 포함하지 않기로 결정(dcy-004 OQ-3). drain은 daemon 측(trex-basic 등)이 종료 신호 수신 시 in-flight 마무리 후 종료하는 형태로 구현되어야 한다. portfolio-hub 측은 즉시 revoke만 수행. -- 본 REQ는 daemon registry CRUD(REQ-009/010 후속의 daemon 모델) 위에 얹는 추가 동작이며 daemon 모델 자체에는 변경 없음. -- 재활성화 시 token 자동 복구를 하지 않는 이유: revoke된 token은 보안상 폐기 의미가 있어 자동 복구하면 권한 escalation 위험. 운영자가 신규 발급 절차를 통해 새 token을 부여하는 게 안전. -- Source: dcy-004 FR-7, dcy-004 OQ-3 결정. - -## Change Log - -### CHG-20260520-02 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-7 산출물. - -#### Intent -- daemon deactivation 운영 공백 해소 — 비활성 daemon의 token이 계속 유효한 문제를 즉시 revoke 정책으로 정상화. - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: docs/runtime-flows.md, README.md — 최초 지정 (REQ-019 baseline 작업 또는 release plan) -- Modules: app/cli/commands/daemon.py, app/services/auth_service.py, app/repositories/portfolio_repository.py, app/repositories/postgres.py — 최초 지정 -- Tests: tests/test_daemon_registry.py, tests/test_v1_*.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입 - -#### Validation Plan -- pytest로 AC-1~4 검증; AC-5는 정책 reference (release plan SOP 점검) diff --git a/docs/srs/Interface/req-023_null-daemon-token-migration-cli.md b/docs/srs/Interface/req-023_null-daemon-token-migration-cli.md deleted file mode 100644 index 95a215e..0000000 --- a/docs/srs/Interface/req-023_null-daemon-token-migration-cli.md +++ /dev/null @@ -1,103 +0,0 @@ -# REQ-023: NULL daemon_id legacy token 마이그레이션 CLI - -- Status: Implemented -- Type: Interface -- Priority: Medium -- Owner: Justin Ko - -## Intent -- `daemon_id IS NULL`인 active service token이 운영 환경에 존재하면 v1 API daemon-scoped endpoints가 fail-closed로 작동해 운영 정리가 필요하다. 자동 매핑은 권한 escalation 리스크로 채택하지 않고(dcy-004 OQ-7), 운영자 수동 매핑을 보조하는 진단 CLI를 제공한다. -- dry-run으로 inventory + 매핑 후보를 진단 출력하고, 운영자가 token별 `rebind` 또는 `revoke`를 명시적으로 실행하는 패턴을 표준화한다. - -## Requirement -- 신규 CLI 서브커맨드 `pfhub service-token migrate-null-daemon` 추가: - - `--dry-run` 옵션: NULL daemon active service token inventory를 출력. 각 token에 대해 `id`, `user_id`, `name`, `runtime_id`, `created_at`, 매핑 후보 daemon(같은 `owner_user_id == user_id`인 active daemon 목록)을 표 또는 JSON 형식으로 보고. - - 매핑 후보 검증: `token.user_id == daemon.owner_user_id` 일치 여부를 명시 표시. 불일치 가능성이 있는 token은 별도 경고 표시. - - `--dry-run` 없이 실행하면 안전상 명시적 거부(예: "use --dry-run for inspection, then `pfhub service-token rebind` or `revoke` per token") 또는 no-op. -- 본 CLI는 자동 rebind/revoke를 수행하지 않는다. 실제 적용은 운영자가 token별로 `pfhub service-token rebind NAME --daemon-id ID` 또는 `pfhub service-token revoke NAME`을 명시 실행해야 한다. -- 본 CLI는 idempotent하다(재실행 안전, NULL token이 0건이어도 정상 종료). -- DATA-2 inventory(2026-05-20)에서 발견된 admin 테스트 fixture 1건은 본 batch release 시점에 운영자가 직접 `pfhub service-token revoke admin` 수동 실행으로 정리(release plan에 명시). - -## Acceptance Criteria -1. Given `pfhub service-token migrate-null-daemon --dry-run` 실행, When NULL daemon active service token 1건 이상 존재, Then 각 token의 `id`, `user_id`, `name`, `runtime_id`, `created_at`, 매핑 후보 daemon 목록, user_id 일치 여부가 출력된다. -2. Given `pfhub service-token migrate-null-daemon --dry-run` 실행, When NULL daemon 활성 service token이 0건, Then "no NULL daemon tokens" 메시지 출력 후 exit code 0으로 정상 종료. -3. Given `pfhub service-token migrate-null-daemon` (--dry-run 없이) 실행, When 호출, Then 자동 변경을 거부하고(예: error message + non-zero exit) 운영자에게 `rebind`/`revoke` 수동 실행을 안내한다. -4. Given AC-1 출력 후 운영자가 `pfhub service-token rebind NAME --daemon-id ID` 실행, When 매핑이 성공적으로 적용, Then 해당 token의 `daemon_id`가 갱신되고 다시 `--dry-run` 실행 시 해당 token이 inventory에서 제외된다. -5. Given AC-1 실행 후 운영자가 `pfhub service-token revoke admin` 실행, When DATA-2 admin 테스트 fixture가 revoke, Then 다시 `--dry-run` 실행 시 해당 token이 inventory에서 제외된다. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — `pfhub service-token migrate-null-daemon` CLI 명세 추가 (REQ-019 baseline 작업) -- Modules: - - `app/cli/commands/service_tokens.py` — `migrate_null_daemon` 함수 신규 추가 (Typer 서브커맨드 등록) - - `app/services/auth_service.py` — `list_null_daemon_service_tokens()` 및 `find_candidate_daemons_for_user(user_id)` 헬퍼 추가(또는 직접 repository 조회) - - `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `list_service_tokens_with_null_daemon()` 메서드 신규 추가 -- Tests: - - 신규 `tests/test_null_daemon_migration.py` — AC-1~5 시나리오. dry-run 출력 검증 + idempotency 회귀 + non-dry-run 거부 동작 검증 - -## Notes -- dcy-004 OQ-7 결정으로 자동 매핑/auto-create daemon은 채택하지 않음(권한 escalation 리스크). 본 CLI는 진단 도구로만 작동. -- DATA-2 inventory 1건(admin 테스트 fixture)은 본 batch release 시점에 운영자가 직접 처리. CLI 진단 + 운영자 수동 적용이라는 본 REQ 패턴의 첫 번째 적용 사례. -- 매핑 후보가 0건(같은 user_id의 active daemon 없음)인 token은 dry-run 출력에 별도 표시해 운영자가 daemon 신규 생성 후 rebind를 진행하거나 token revoke를 결정하도록 안내. -- 본 CLI는 batch 시점 1회 정리 + 향후 NULL daemon token이 발생할 때마다 사용 가능한 영구 운영 도구로 작동한다. -- Source: dcy-004 FR-8, dcy-004 NFR-5, dcy-004 OQ-7 결정. - -## Change Log - -### CHG-20260520-02 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-8 산출물. - -#### Intent -- NULL daemon legacy token 마이그레이션을 운영자 수동 매핑 패턴으로 표준화 + 진단 CLI 제공. - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md (REQ-019에서 처리) -- Modules: app/cli/commands/service_tokens.py, app/services/auth_service.py, app/repositories/portfolio_repository.py, app/repositories/postgres.py — 최초 지정 -- Tests: tests/test_null_daemon_migration.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정). DATA-2 정리(admin 테스트 fixture revoke)는 release plan에 명시. - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입 - -#### Validation Plan -- pytest로 AC-1~5 검증; CLI smoke + idempotency 회귀 + non-dry-run 거부 동작 diff --git a/docs/srs/Interface/req-025_v1-portfolios-create.md b/docs/srs/Interface/req-025_v1-portfolios-create.md deleted file mode 100644 index 0ddafdf..0000000 --- a/docs/srs/Interface/req-025_v1-portfolios-create.md +++ /dev/null @@ -1,226 +0,0 @@ -# REQ-025: POST /v1/portfolios 신설 (토큰 분기 + catalog 검증) - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- v1 API에 portfolio 생성 경로(`POST /v1/portfolios`)를 신설해 user/admin이 HTTP로 본인 또는 타 user의 portfolio를 직접 프로비저닝할 수 있도록 한다. -- dcy-001 FR-4의 catalog 검증(asset_id active)을 portfolio 생성 시점에 통합 적용해 dcy-001 asset catalog 모델 위에서만 portfolio가 생성되도록 강제한다. - -## Requirement -- 신규 라우트 `POST /v1/portfolios` (Bearer 인증 필수). -- **토큰 분기 규칙** (dcy-003 OQ-2 + OQ-5 결정, dcy-004 정책과 정합): - - `token_type ∈ {service, client}` → HTTP 403 fail-closed (`detail="service token cannot create portfolios"` 또는 client 대응 메시지). service는 daemon-bound로 portfolio CRUD 주체가 아니며 client는 dcy-004 REQ-016에서 폐지 확정. - - 그 외(`token_type=session` 또는 env token): - - `permission='admin'` → admin 분기. body `user_id` non-null이면 해당 user의 portfolio로, null이면 인증 주체의 user_id로 생성. - - `permission ∈ {read, trade-write}` → user 분기. 인증 주체의 user_id로 생성. body `user_id` 지정은 무시 또는 HTTP 422. -- **catalog 검증** (dcy-001 FR-4 통합): `asset_id`가 `assets` 테이블에 존재하고 `status='active'`인지 검증. 미등록/inactive면 dcy-001 FR-4와 일관된 응답 코드로 거부(HTTP 422 또는 403; REQ 단계 확정은 본 REQ 또는 후속 보강). -- **body 스키마** (`PortfolioCreateRequest` 신규): - - 필수: `asset_id`, `broker` - - 선택: `name`, `cash_balance`, `asset_quantity`, `asset_price`, `profit_buffer`, `strategy_name`, `strategy_params`, `managed_by_daemon_id`, `user_id`(admin 분기 전용) -- 응답: HTTP 201 + 생성된 portfolio의 `PortfolioItem`(`portfolio_id`, `asset_id`, `symbol`, `owner_user_id`, ...). -- 응답의 `symbol` 값은 생성된 `portfolios.asset_id`가 가리키는 `assets.symbol`을 기준으로 server-side join 결과에서 채운다. 클라이언트가 별도 catalog lookup으로 복원하는 방식은 본 REQ 범위의 정식 계약이 아니다. -- catalog 검증 위치(서비스 계층 공통 진입점 vs v1 라우터 레이어 한정)는 본 REQ 구현 시점에 결정. dcy-003 R-5 트레이드오프 reference 참조. - -## Acceptance Criteria -1. Given admin token + body `{"asset_id": "", "broker": "kiwoom", "user_id": ""}`, When `POST /v1/portfolios`, Then HTTP 201 + 생성된 portfolio의 `owner_user_id == ` 반환. -2. Given session/env token with `permission ∈ {read, trade-write}` + body `{"asset_id": ..., "broker": ...}`, When `POST /v1/portfolios`, Then HTTP 201 + `owner_user_id == auth.user_id` 반환. -3. Given service token, When `POST /v1/portfolios`, Then HTTP 403 fail-closed + `detail="service token cannot create portfolios"`. -4. Given client token(폐지 전 운영 단계), When `POST /v1/portfolios`, Then HTTP 403 fail-closed. -5. Given body `asset_id`가 catalog에 미존재 또는 `status='inactive'`, When `POST /v1/portfolios`, Then HTTP 422 또는 403로 거부되고 portfolio row가 생성되지 않는다. -6. Given Bearer token 없음, When `POST /v1/portfolios`, Then HTTP 401. -7. Given non-admin token + body `user_id` 지정, When `POST /v1/portfolios`, Then `user_id`는 무시되어 `owner_user_id == auth.user_id`로 생성되거나 HTTP 422. -8. Given active catalog asset(`asset_id=`, `symbol="005930"`), When `POST /v1/portfolios`, Then HTTP 201 응답의 `PortfolioItem.symbol == "005930"` 이고 값은 해당 asset row join 결과와 일치한다. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — POST /v1/portfolios 명세 추가 (REQ-032 baseline 작업) - - `docs/runtime-flows.md` — portfolio create flow + catalog 검증 단계 (REQ-032) -- Modules: - - `app/api/v1_portfolios.py` (신규) — POST /v1/portfolios 라우트, 토큰 분기 인증 헬퍼(`_require_portfolio_access`) - - `app/main.py` — v1_portfolios_router include - - `app/schemas/v1.py` — `PortfolioCreateRequest`, `PortfolioItem` 신규 - - `app/services/portfolio_service.py:create_portfolio` — catalog 검증 통합(dcy-001 FR-4 정책) - - `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `get_asset_by_id(asset_id, only_active=True)` 헬퍼(또는 직접 query) 추가 -- Tests: - - 신규 `tests/test_v1_portfolios_crud.py` — AC-1~7 검증 - -## Notes -- 본 REQ는 dcy-003 FR-1(생성) + FR-2(catalog 검증)을 통합. 생성 시점에 catalog 검증이 자연 적용되므로 단일 REQ로 분리. -- catalog 검증 위치: dcy-003 §5 FR-2 본문에 "REQ 작성 단계에서 결정" 위임. R-5(CLI 회귀) 완화 방안을 참조해 본 REQ 구현 시 결정. -- 토큰 분기 게이트는 dcy-004 REQ-016 client 폐지 이후 client 분기가 자연 사라지나, 폐지 release 이전·이후 모두 정합하도록 게이트 자체는 유지(env token 침투 방어). -- daemon이 portfolio를 직접 생성하지 못함(OQ-2). v1 API에서 daemon의 권한은 조회(REQ-028)와 ledger 기록(REQ-029)에 한정. -- Source: dcy-003 FR-1, FR-2, OQ-2, OQ-5, NFR-1, NFR-3, NFR-4. dcy-001 FR-4 통합. - -## Change Log - -### CHG-20260627-03 -- Date: 2026-06-27 -- Author: portfolio-hub-dev - -#### Change Summary -- `_PORTFOLIO_NAME_RE` 정규식 완화: 한글(가-힣) + 공백 허용 추가. - -#### Intent -- 포트폴리오 name은 `(owner_user_id, name)` 조회 키로만 쓰이며 wire 식별자가 아니므로 UTF-8 한글이 안전하다. 사용자 가독성 향상을 위해 한글 이름과 공백을 허용한다. - -#### Acceptance Criteria Delta -- No change (기존 AC 그대로 유지) - -#### Impacted Area Delta -- `app/schemas/portfolio.py`: `_PORTFOLIO_NAME_RE` → `^[a-zA-Z0-9가-힣 ._:\-]{1,100}$`, `validate_name` 에러 메시지 갱신. -- `tests/test_portfolio_uuid.py`: 한글 name 통과(`test_name_validation_accepts_korean`), 기존 ASCII name 통과(`test_name_validation_accepts_ascii_unchanged`), 101자 초과 거부(`test_name_validation_rejects_over_100_chars`) 케이스 추가. - -#### Delivery Trace Delta -- Batches: minor-change (batch-lite 불필요 단일 저위험 변경) -- Releases: pending - -#### Revalidation Impact -- Reverification Needed: no (기존 AC 충족 범위 확장만이며 거부 규칙 약화 없음 — `!` 등 비허용 문자는 여전히 거부) -- Existing Implementation Invalidated: no -- Status Recommendation: keep-implemented -- Follow-up Action: 없음 - -### CHG-20260524-02 -- Date: 2026-05-24 -- Author: Hermes Agent - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-014 verification 완료. `PortfolioItem.symbol` 응답 계약이 코드, 테스트, baseline 문서에 반영되었고 REQ-025 acceptance criteria 전부가 trace 가능해졌다. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: BAT-014 (release-candidate); BAT-009 response-contract evidence superseded by BAT-014 -- Releases: pending - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-implemented -- Follow-up Action: release 단계에서 smoke 확인 - -### CHG-20260524-01 -- Date: 2026-05-24 -- Author: Hermes Agent - -#### Change Summary -- `PortfolioItem.symbol` 응답 계약 추가에 따라 REQ-025 생성 응답 contract를 보강하고 상태를 Implemented → Approved로 되돌림. - -#### Intent -- trex-orbit 팀 이슈(`docs/issues/20260524_portfolio-hub-ticket-symbol-in-portfolios.md`) 후속. 생성 응답만 `asset_id`를 반환하고 `symbol`을 생략하면 클라이언트가 별도 asset catalog 조회/캐시를 유지해야 하므로, 생성 직후 응답에서도 joined asset의 `symbol`을 즉시 반환하도록 계약을 확장한다. - -#### Acceptance Criteria Delta -- Added: - - AC-8: POST 응답의 `PortfolioItem.symbol` 이 `assets.symbol` join 결과와 일치해야 함 - -#### Impacted Area Delta -- Docs: `docs/interface-contract.md` 의 POST `/v1/portfolios` 응답 예시에 `symbol` 추가 필요 (REQ-032 후속) -- Modules: `app/schemas/v1.py`, `app/api/v1_portfolios.py` (`_portfolio_to_item`), `app/repositories/postgres.py:get()` join 응답 확인 -- Tests: `tests/test_v1_portfolios_crud.py` 에 POST 응답 `symbol` 검증 추가 필요 - -#### Delivery Trace Delta -- Batches: BAT-009 evidence partially superseded; follow-up BAT-014 예정 -- Releases: REL-001 response-contract evidence 일부 재검토 필요 - -#### Revalidation Impact -- Reverification Needed: yes -- Existing Implementation Invalidated: yes (response schema evidence partial) -- Status Recommendation: revert-to-approved -- Follow-up Action: BAT-014에서 POST/PATCH/DELETE/GET portfolio 응답 `symbol` 회귀 검증 추가 - -#### Validation Plan -- `tests/test_v1_portfolios_crud.py` 에서 POST 201 응답 body의 `symbol` 존재 및 `asset_id`가 가리키는 asset row의 `symbol` 일치 여부를 검증한다. - -### 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-1 + FR-2 통합 산출물. - -#### Intent -- v1 portfolio mutation의 핵심 진입점(생성) + dcy-001 catalog 검증 통합. - -#### Acceptance Criteria Delta -- Added: - - AC-1~7 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md, docs/runtime-flows.md (REQ-032에서 처리) -- Modules: app/api/v1_portfolios.py (신규), app/main.py, app/schemas/v1.py, app/services/portfolio_service.py, app/repositories/* — 최초 지정 -- Tests: tests/test_v1_portfolios_crud.py (신규) — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; dcy-001 REQ-009/011/012 선행 의존성 확인(assets 테이블 + portfolios asset_id FK) - -#### Validation Plan -- pytest TestClient로 AC-1~7 검증; 토큰 타입별·permission별 시나리오 + catalog hit/miss diff --git a/docs/srs/Interface/req-026_v1-portfolios-update.md b/docs/srs/Interface/req-026_v1-portfolios-update.md deleted file mode 100644 index 6d8bea7..0000000 --- a/docs/srs/Interface/req-026_v1-portfolios-update.md +++ /dev/null @@ -1,196 +0,0 @@ -# REQ-026: PATCH /v1/portfolios/{portfolio_id} 신설 - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- v1 API에 portfolio 부분 수정 경로(`PATCH /v1/portfolios/{portfolio_id}`)를 신설해 user/admin이 HTTP로 portfolio 속성(broker/status/name/symbol/strategy 등)을 직접 수정할 수 있도록 한다. -- 토큰 분기 + ownership 검증을 REQ-025와 동일 패턴으로 적용해 v1 portfolio CRUD 인가 일관성을 확보한다. - -## Requirement -- 신규 라우트 `PATCH /v1/portfolios/{portfolio_id}` (Bearer 인증 필수). -- **토큰 분기 규칙** (REQ-025 + dcy-003 OQ-2/OQ-5 결정과 정합): - - `token_type ∈ {service, client}` → HTTP 403 fail-closed. - - 그 외(`token_type=session` 또는 env token): - - `permission='admin'` → 모든 portfolio 수정 가능. - - `permission ∈ {read, trade-write}` → 자기 소유(`owner_user_id = auth.user_id`) portfolio만 수정 가능, 불일치 시 HTTP 403. -- 비존재 portfolio는 HTTP 404. -- 수정 가능 필드는 기존 `UpdatePortfolioRequest` 범위: `broker`, `status`, `name`, `symbol`, `strategy_name`, `strategy_params`, `asset_price`, `managed_by_daemon_id` 등. -- **disabled portfolio 보호** (REQ-027 soft delete 정책 정합): `portfolios.status == 'disabled'`인 portfolio에 대해 status 전이(`'enabled'`로 복구) 외 다른 필드 변경 시 HTTP 409 또는 422로 거부. -- 응답: HTTP 200 + 갱신된 `PortfolioItem`. -- 응답의 `symbol` 값은 수정 대상 portfolio가 참조하는 `assets.symbol`을 기준으로 server-side join 결과에서 채운다. - -## Acceptance Criteria -1. Given admin token + 본인 또는 타 user portfolio, When `PATCH /v1/portfolios/{id}` body `{"strategy_name": "..."}`, Then HTTP 200 + 갱신된 portfolio 반환. -2. Given session/env token with `permission ∈ {read, trade-write}` + 본인 portfolio, When `PATCH /v1/portfolios/{id}`, Then HTTP 200 + 갱신된 portfolio. -3. Given session/env token with `permission ∈ {read, trade-write}` + 타 user portfolio, When `PATCH /v1/portfolios/{id}`, Then HTTP 403 (ownership 불일치). -4. Given service token, When `PATCH /v1/portfolios/{id}`, Then HTTP 403 fail-closed. -5. Given client token(폐지 전), When `PATCH /v1/portfolios/{id}`, Then HTTP 403 fail-closed. -6. Given Bearer token 없음, When `PATCH /v1/portfolios/{id}`, Then HTTP 401. -7. Given 비존재 `portfolio_id`, When `PATCH /v1/portfolios/{id}`, Then HTTP 404. -8. Given `status='disabled'` portfolio + body `{"strategy_name": "..."}`(status 외 필드), When `PATCH`, Then HTTP 409 또는 422 (disabled write 차단). -9. Given `status='disabled'` portfolio + body `{"status": "enabled"}` (admin), When `PATCH`, Then HTTP 200 + status 복구. -10. Given portfolio가 active asset(`symbol="005930"`)을 참조, When `PATCH /v1/portfolios/{id}` 성공, Then HTTP 200 응답의 `PortfolioItem.symbol == "005930"` 이고 값은 joined asset row와 일치한다. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — PATCH /v1/portfolios/{id} 명세 추가 (REQ-032) - - `docs/runtime-flows.md` — portfolio update flow + ownership 검증 + disabled 보호 (REQ-032) -- Modules: - - `app/api/v1_portfolios.py` — PATCH 라우트 추가 (REQ-025의 인증 헬퍼 재사용) - - `app/schemas/v1.py` — `PortfolioUpdateRequest` 신규 (기존 `UpdatePortfolioRequest` 재사용 또는 v1 전용 래핑) - - `app/services/portfolio_service.py:update_portfolio` — 기존 메서드 재사용 + disabled 보호 검증 추가 -- Tests: - - 신규/갱신 `tests/test_v1_portfolios_crud.py` — AC-1~9 검증 - -## Notes -- ownership 검증은 admin/user 분기 후 service 계층 또는 라우터에서 일관 처리. 본 REQ의 검증 위치는 REQ-025 catalog 검증 위치 결정과 함께 결정한다. -- disabled portfolio 보호는 REQ-027(soft delete) 정책의 일부로, 본 REQ에서 PATCH 측면 검증을 명시. ledger trade/tag/comment 측면은 REQ-029/030/031에서 각각 처리. -- 본 REQ는 dcy-003 FR-3 산출물이며 NFR-1/NFR-3/NFR-4를 검증 기준에 흡수. -- Source: dcy-003 FR-3, OQ-2, OQ-5, NFR-1, NFR-3, NFR-4. - -## Change Log - -### CHG-20260524-02 -- Date: 2026-05-24 -- Author: Hermes Agent - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-014 verification 완료. PATCH 성공 응답의 `PortfolioItem.symbol` 계약이 코드/테스트/문서에 반영되어 REQ-026 acceptance criteria 전부가 다시 충족되었다. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: BAT-014 (release-candidate); BAT-009 response-contract evidence superseded by BAT-014 -- Releases: pending - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-implemented -- Follow-up Action: release 단계에서 smoke 확인 - -### CHG-20260524-01 -- Date: 2026-05-24 -- Author: Hermes Agent - -#### Change Summary -- `PortfolioItem.symbol` 응답 계약 추가에 따라 REQ-026 수정 응답 contract를 보강하고 상태를 Implemented → Approved로 되돌림. - -#### Intent -- trex-orbit 팀이 portfolio mutation 후에도 추가 catalog lookup 없이 즉시 symbol을 사용해야 하는 요구를 제기했다. PATCH 응답이 기존 portfolio state만 되돌리고 joined asset 식별자(`symbol`)를 생략하면 CRUD 응답 contract가 비대칭이므로, 수정 응답에도 동일 필드를 포함하도록 정의를 확장한다. - -#### Acceptance Criteria Delta -- Added: - - AC-10: PATCH 성공 응답의 `PortfolioItem.symbol` 이 joined `assets.symbol` 과 일치해야 함 - -#### Impacted Area Delta -- Docs: `docs/interface-contract.md` 의 PATCH `/v1/portfolios/{id}` 응답 예시에 `symbol` 추가 필요 (REQ-032 후속) -- Modules: `app/schemas/v1.py`, `app/api/v1_portfolios.py` (`_portfolio_to_item`) -- Tests: `tests/test_v1_portfolios_crud.py` 에 PATCH 응답 `symbol` 검증 추가 필요 - -#### Delivery Trace Delta -- Batches: BAT-009 evidence partially superseded; follow-up BAT-014 예정 -- Releases: REL-001 response-contract evidence 일부 재검토 필요 - -#### Revalidation Impact -- Reverification Needed: yes -- Existing Implementation Invalidated: yes (response schema evidence partial) -- Status Recommendation: revert-to-approved -- Follow-up Action: BAT-014에서 PATCH 응답 contract 회귀 검증 추가 - -#### Validation Plan -- `tests/test_v1_portfolios_crud.py` 에서 PATCH 200 응답 body의 `symbol` 존재 및 joined asset symbol 일치 여부를 검증한다. - -### 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-3 산출물. - -#### Intent -- v1 portfolio mutation의 부분 수정 진입점 + ownership 검증 + disabled 보호. - -#### Acceptance Criteria Delta -- Added: - - AC-1~9 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md, docs/runtime-flows.md (REQ-032) -- Modules: app/api/v1_portfolios.py, app/schemas/v1.py, app/services/portfolio_service.py — 최초 지정 -- Tests: tests/test_v1_portfolios_crud.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-025와 동일 batch 권장 - -#### Validation Plan -- pytest TestClient로 AC-1~9 검증; ownership·token type·disabled 보호 시나리오 diff --git a/docs/srs/Interface/req-027_v1-portfolios-soft-delete.md b/docs/srs/Interface/req-027_v1-portfolios-soft-delete.md deleted file mode 100644 index 3529a4b..0000000 --- a/docs/srs/Interface/req-027_v1-portfolios-soft-delete.md +++ /dev/null @@ -1,207 +0,0 @@ -# REQ-027: DELETE /v1/portfolios/{portfolio_id} 신설 (soft delete) - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- v1 API에 portfolio 종료 경로(`DELETE /v1/portfolios/{portfolio_id}`)를 신설해 user/admin이 HTTP로 portfolio를 종료할 수 있도록 한다. -- dcy-003 OQ-1 결정에 따라 soft delete(`status='disabled'`)로 구현해 데이터 보존성과 운영 복구 가능성을 확보한다. hard delete 경로는 v1에 노출하지 않는다. - -## Requirement -- 신규 라우트 `DELETE /v1/portfolios/{portfolio_id}` (Bearer 인증 필수). -- **토큰 분기 규칙** (REQ-025/026과 동일 패턴): - - `token_type ∈ {service, client}` → HTTP 403 fail-closed. - - 그 외(`token_type=session` 또는 env token): - - `permission='admin'` → 모든 portfolio 삭제 가능. - - `permission ∈ {read, trade-write}` → 자기 소유(`owner_user_id = auth.user_id`) portfolio만, 불일치 시 HTTP 403. -- **soft delete 동작**: - - `portfolios.status = 'disabled'`로 전환 - - `managed_by_daemon_id`는 `NULL`로 자동 해제 - - `updated_at` 갱신 - - 의존 데이터(`portfolio_ledger`, `ledger_trades`, `portfolio_comments`, `portfolios.tag_ids`) 모두 보존 -- **disabled 상태 write 차단** (REQ-026/029/030/031에서도 검증되는 정책): - - `PATCH`(status 전이 외 필드 변경) → HTTP 409 또는 422 - - `POST /ledger/trades` → 차단(REQ-029에서 처리) - - tag/comment 추가·수정·삭제 → 차단(REQ-030/031에서 처리) -- **복구 경로**: admin이 `PATCH /v1/portfolios/{portfolio_id}` body `{"status": "enabled"}`(또는 이전 status)로 전환(REQ-026 AC-9). daemon 재배정은 별도 호출. -- 비존재 `portfolio_id`는 HTTP 404. ownership 불일치는 HTTP 403. -- 이미 `disabled` 상태인 portfolio에 재호출 시 HTTP 200 + 멱등 응답(또는 HTTP 409 — 본 REQ 구현 시 확정). -- 응답: HTTP 200 또는 204 + (HTTP 200 시) disabled 상태 portfolio 또는 확정 응답. -- HTTP 200 응답을 반환하는 구현에서는 `PortfolioItem.symbol` 도 함께 포함해야 하며, 값은 soft delete 이후에도 linked asset row join 결과와 동일해야 한다. - -## Acceptance Criteria -1. Given session token + 본인 active portfolio, When `DELETE /v1/portfolios/{id}`, Then HTTP 200/204 + `portfolios.status='disabled'` + `managed_by_daemon_id IS NULL` 사후 상태. -2. Given AC-1 실행 후, When `portfolio_ledger`/`ledger_trades`/`portfolio_comments`/`portfolios.tag_ids` 조회, Then 의존 데이터가 모두 보존되어 있다. -3. Given admin token + 타 user active portfolio, When `DELETE /v1/portfolios/{id}`, Then HTTP 200/204 + 동일 사후 상태. -4. Given session token + 타 user portfolio, When `DELETE /v1/portfolios/{id}`, Then HTTP 403 (ownership 불일치). -5. Given service token, When `DELETE /v1/portfolios/{id}`, Then HTTP 403 fail-closed. -6. Given client token(폐지 전), When `DELETE /v1/portfolios/{id}`, Then HTTP 403 fail-closed. -7. Given Bearer token 없음, When `DELETE /v1/portfolios/{id}`, Then HTTP 401. -8. Given 비존재 `portfolio_id`, When `DELETE /v1/portfolios/{id}`, Then HTTP 404. -9. Given disabled portfolio + admin `PATCH status='enabled'`, When `PATCH`, Then HTTP 200 + 복구. (REQ-026 AC-9와 연계) -10. Given AC-1 실행 후 disabled portfolio에 ledger trade/tag/comment write 시도, When 시도, Then 모두 차단(HTTP 409 또는 422). (REQ-029/030/031과 연계 검증) -11. Given active asset(`symbol="005930"`)을 참조하던 portfolio, When `DELETE /v1/portfolios/{id}` 가 HTTP 200 응답을 반환, Then 응답의 `PortfolioItem.symbol == "005930"` 이고 값은 joined asset row와 일치한다. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — DELETE /v1/portfolios/{id} 명세 (REQ-032) - - `docs/runtime-flows.md` — soft delete flow + disabled write 차단 + 복구 (REQ-032) - - `docs/data-model.md` — Portfolio.status='disabled' 의미 + 의존 데이터 보존 정책 (REQ-032) -- Modules: - - `app/api/v1_portfolios.py` — DELETE 라우트 - - `app/services/portfolio_service.py:delete` — 기존 메서드 재사용 시 soft delete로 변경 또는 신규 `soft_delete_portfolio` 헬퍼 - - `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `set_portfolio_status(portfolio_id, status, clear_daemon=True)` 메서드 신규 또는 기존 `update_portfolio` 활용 -- Tests: - - 신규/갱신 `tests/test_v1_portfolios_crud.py` — AC-1~10 검증 - -## Notes -- dcy-003 R-2(disabled 누적 가시성·write 차단 일관성)는 본 REQ + REQ-028 `?include_disabled` opt-in 정책 + REQ-026/029/030/031의 차단 검증으로 함께 완화. -- 누적 disabled 정리(아카이브, 영구 삭제 운영 SOP)는 본 Discovery 범위 밖. 별도 운영 Discovery로 위임. -- 본 REQ는 hard delete 경로를 v1에 노출하지 않으므로 hard 삭제 운영은 DB 직접 접근 또는 별도 운영 도구로만 가능. -- Source: dcy-003 FR-4, OQ-1, OQ-2, OQ-5, NFR-1, NFR-3, NFR-4. - -## Change Log - -### CHG-20260524-02 -- Date: 2026-05-24 -- Author: Hermes Agent - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-014 verification 완료. DELETE(soft delete) HTTP 200 응답의 `PortfolioItem.symbol` 계약이 코드/테스트/문서에 반영되어 REQ-027 acceptance criteria 전부가 다시 충족되었다. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: BAT-014 (release-candidate); BAT-009 response-contract evidence superseded by BAT-014 -- Releases: pending - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-implemented -- Follow-up Action: release 단계에서 smoke 확인 - -### CHG-20260524-01 -- Date: 2026-05-24 -- Author: Hermes Agent - -#### Change Summary -- `PortfolioItem.symbol` 응답 계약 추가에 따라 REQ-027 soft delete 응답 contract를 보강하고 상태를 Implemented → Approved로 되돌림. - -#### Intent -- trex-orbit 팀 이슈 후속으로, delete/disable 응답에서도 client가 별도 asset lookup 없이 포트폴리오 식별 symbol을 유지할 수 있어야 한다. soft delete는 asset reference를 제거하지 않으므로 HTTP 200 응답이 있는 경우 `symbol`을 함께 반환하는 계약이 자연스럽다. - -#### Acceptance Criteria Delta -- Added: - - AC-11: DELETE(HTTP 200) 응답의 `PortfolioItem.symbol` 이 joined `assets.symbol` 과 일치해야 함 - -#### Impacted Area Delta -- Docs: `docs/interface-contract.md` 의 DELETE `/v1/portfolios/{id}` 응답 예시에 `symbol` 추가 필요 (REQ-032 후속) -- Modules: `app/schemas/v1.py`, `app/api/v1_portfolios.py` (`_portfolio_to_item`) -- Tests: `tests/test_v1_portfolios_crud.py` 에 DELETE 200 응답 `symbol` 검증 추가 필요 - -#### Delivery Trace Delta -- Batches: BAT-009 evidence partially superseded; follow-up BAT-014 예정 -- Releases: REL-001 response-contract evidence 일부 재검토 필요 - -#### Revalidation Impact -- Reverification Needed: yes -- Existing Implementation Invalidated: yes (response schema evidence partial) -- Status Recommendation: revert-to-approved -- Follow-up Action: BAT-014에서 DELETE 응답 contract 회귀 검증 추가 - -#### Validation Plan -- `tests/test_v1_portfolios_crud.py` 에서 DELETE 200 응답 body의 `symbol` 존재 및 joined asset symbol 일치 여부를 검증한다. - -### 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-4 산출물. - -#### Intent -- v1 portfolio 종료 경로 + soft delete 정책 명시. - -#### Acceptance Criteria Delta -- Added: - - AC-1~10 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md, docs/runtime-flows.md, docs/data-model.md (REQ-032) -- Modules: app/api/v1_portfolios.py, app/services/portfolio_service.py, app/repositories/* — 최초 지정 -- Tests: tests/test_v1_portfolios_crud.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-025/026과 동일 batch 권장 - -#### Validation Plan -- pytest TestClient로 AC-1~10 검증; soft delete 사후 상태 + 의존 데이터 보존 + 복구 경로 + write 차단 일관성 diff --git a/docs/srs/Interface/req-028_v1-portfolios-list.md b/docs/srs/Interface/req-028_v1-portfolios-list.md deleted file mode 100644 index e0a9525..0000000 --- a/docs/srs/Interface/req-028_v1-portfolios-list.md +++ /dev/null @@ -1,271 +0,0 @@ -# REQ-028: GET /v1/portfolios 신설 (토큰 분기 조회) - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- v1 API에 portfolio 조회 경로(`GET /v1/portfolios`)를 신설해 service/user/admin 세 principal이 단일 엔드포인트로 분기 조회할 수 있도록 한다. -- dcy-001 FR-5(REQ-013 Deprecated 처리)에서 회수된 `GET /v1/assets` 통합 endpoint 정의를 본 신규 path로 이전한다(dcy-003 옵션 B 결정). - -## Requirement -- 신규 라우트 `GET /v1/portfolios` (Bearer 인증 필수). -- **토큰 분기 규칙** (dcy-003 OQ-5 결정): - - `token_type='service'`: daemon-bound 조회. `daemon_id is None`이면 HTTP 403 fail-closed. 그 외 `managed_by_daemon_id = auth.daemon_id` 필터 + `?market=` 지원. - - `token_type='client'`: HTTP 403 fail-closed (dcy-004 REQ-016 폐지 확정 정합). - - `token_type ∈ {session, None(env)}`: - - `permission='admin'` → 기본 `owner_user_id = auth.user_id`(본인 portfolio) 반환. `?user_id=` 지정 시 해당 user의 portfolio 반환. - - `permission ∈ {read, trade-write}` → `owner_user_id = auth.user_id` 필터, 본인 portfolio만 반환. -- **`?market=` 필터**: 모든 토큰 타입에서 지원. ` = ANY(assets.markets)`인 portfolio만 반환(catalog와 join). -- **`?include_disabled=true` 옵션** (dcy-003 R-2 완화): 기본 응답은 `status='disabled'`인 portfolio 제외. opt-in 시에만 포함. -- 응답 항목 schema: 신규 `PortfolioItem` (`portfolio_id`, `asset_id`, `symbol`, `display_name`, `owner_user_id`, `broker`, `status`, `cash_balance`, ..., `tag_ids`, `managed_by_daemon_id`, ...). `asset_id`는 catalog의 `assets.asset_id`(UUID) 참조. -- `GET /v1/portfolios` 의 각 item은 반드시 `portfolios JOIN assets` 결과를 기반으로 직렬화되어야 한다. 특히 `symbol`/`display_name` 은 linked asset row의 `assets.symbol`/`assets.display_name` 을 그대로 노출해야 하며, 클라이언트 캐시나 후속 catalog lookup으로 복원하는 모델은 정식 응답 계약이 아니다. `assets.display_name`이 NULL이면 응답 `display_name`도 `null`. -- 응답: HTTP 200 + `PortfolioListResponse` (`portfolios: list[PortfolioItem]`, `total: int`). - -## Acceptance Criteria -1. Given service token(daemon-bound, `daemon_id` 있음), When `GET /v1/portfolios`, Then `managed_by_daemon_id = auth.daemon_id`인 portfolio 목록만 반환. -2. Given service token(`daemon_id` 없음), When `GET /v1/portfolios`, Then HTTP 403 fail-closed. -3. Given session/env token + `permission ∈ {read, trade-write}`, When `GET /v1/portfolios`, Then `owner_user_id = auth.user_id`인 portfolio 목록만 반환. -4. Given admin token (no `?user_id`), When `GET /v1/portfolios`, Then admin 본인의 portfolio 목록 반환. -5. Given admin token + `?user_id=`, When `GET /v1/portfolios`, Then `` user의 portfolio 목록 반환. -6. Given 임의 토큰 + `?market=KRX`, When `GET /v1/portfolios?market=KRX`, Then `'KRX' = ANY(assets.markets)`인 portfolio만 반환. -7. Given disabled portfolio 1건 + active portfolio 1건 보유 user, When `GET /v1/portfolios` (`?include_disabled` 없음), Then active 1건만 반환. -8. Given AC-7 환경 + `?include_disabled=true`, When `GET /v1/portfolios?include_disabled=true`, Then 2건 모두 반환. -9. Given client token, When `GET /v1/portfolios`, Then HTTP 403 fail-closed. -10. Given Bearer token 없음, When `GET /v1/portfolios`, Then HTTP 401. -11. Given 응답 portfolio 항목, When `asset_id` 필드 확인, Then `assets.asset_id`(catalog UUID)를 참조한다. -12. Given `GET /v1/portfolios` 응답 item, When `symbol` 필드를 확인, Then 값은 같은 row의 `asset_id`가 가리키는 joined `assets.symbol` 과 일치한다. -13. Given `GET /v1/portfolios` 응답 item, When `display_name` 필드를 확인, Then 값은 같은 row의 `asset_id`가 가리키는 joined `assets.display_name`과 일치한다. 참조 asset의 `display_name`이 NULL이면 응답 필드도 `null`. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — GET /v1/portfolios 명세 + `?market`/`?user_id`/`?include_disabled` 파라미터 (REQ-032) - - `docs/runtime-flows.md` — 토큰 분기 조회 flow + daemon-scoped 필터 (REQ-032) -- Modules: - - `app/api/v1_portfolios.py` — GET 라우트 (REQ-025의 인증 헬퍼 재사용 + GET 전용 service 토큰 분기 추가); `_portfolio_to_item` 매핑에 `display_name` 포함 (CHG-20260601-01) - - `app/api/v1_assets.py` — 기존 `list_assets` 함수에서 portfolio 조회 로직 제거(catalog 전용 회수). REQ-013 Deprecated 후속 정리. - - `app/domain/models.py` — `Portfolio` dataclass에 `display_name` 필드 추가 (CHG-20260601-01, asset join 결과 매핑) - - `app/schemas/v1.py` — `PortfolioItem`, `PortfolioListResponse` 신규; `PortfolioItem`에 `display_name` 필드 추가 (CHG-20260601-01) - - `app/services/portfolio_service.py:list_portfolios` — token-type별 분기 + `?market`/`?include_disabled` 처리. dcy-001 REQ-013 Deprecated 후속 작업 흡수. - - `app/repositories/postgres.py:list_portfolios_with_strategies` — `managed_by_daemon_id` 필터 + `markets` array 필터 + `include_disabled` 분기; `assets.display_name` join 보강 (CHG-20260601-01) -- Tests: - - 신규 `tests/test_v1_portfolios_list.py` — AC-1~11 검증; `display_name` 응답 검증 (AC-13) - -## Notes -- 본 REQ는 dcy-001 REQ-013 Deprecated(CHG-20260520-01)의 후속 신규 REQ. 토큰 분기 정책은 REQ-013의 본문을 path만 변경해 흡수. -- `GET /v1/assets`(catalog 조회 의미)는 본 REQ 범위 밖이며 dcy-001 batch design 단계에서 REQ-010 보강 또는 신규 REQ로 처리. -- service token이 NULL `daemon_id`로 호출하면 fail-closed. NULL daemon legacy token 운영 정리는 dcy-004 REQ-023(`migrate-null-daemon` CLI)로 처리. -- `?include_disabled` 기본 false 정책은 R-2 완화의 핵심. REQ-027 soft delete와 정합. -- Source: dcy-003 FR-5, OQ-5, NFR-1, NFR-3, R-2 완화. dcy-001 REQ-013 후속. - -## Change Log - -### CHG-20260601-02 -- Date: 2026-06-01 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented (CHG-20260601-01 augmentation에 대한 verification evidence 확보) - -#### Intent -- CHG-20260601-01에서 신설된 AC-13(`display_name` 응답 joined `assets.display_name`)이 구현·테스트·로컬 smoke 모두 evidence 확보됨. BAT-009 verification.md에 AC-13 매핑 누적 기록되었고 release-candidate 상태이므로 `confirm-req-implemented` 게이트 통과. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-009 (release-candidate) — augmentation 누적 verification 갱신 -- Releases: - - REL-001 — local smoke evidence 기록 ([rel-001:55](../../releases/rel-001_20260521_v1-api-full-surface.md#L55)) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL-001 실 prod/staging 배포 시 GET /v1/portfolios 응답에 `display_name` 노출 최종 확인 (배포 후 smoke evidence) - -### CHG-20260601-01 -- Date: 2026-06-01 -- Author: Justin Ko - -#### Change Summary -- Status: Implemented → Approved (응답 schema에 `display_name` 필드 추가 augmentation. portfolio가 참조하는 asset의 display_name을 join하여 응답에 포함.) - -#### Intent -- 현행 `GET /v1/portfolios` 응답은 `asset_id`, `symbol`만 노출하고 `display_name`은 클라이언트가 별도 `GET /v1/assets` catalog 조회로 조립해야 한다. UI/CLI 표시 경로에서 N+1 호출 패턴이 발생. 본 augmentation으로 응답 item에 `display_name`을 포함시켜 단일 호출로 표시 가능하도록 한다. 응답 schema는 확장형(필드 추가)이라 기존 클라이언트 호환 유지. - -#### Acceptance Criteria Delta -- Added: - - AC-13 — Given `GET /v1/portfolios` 응답 item, When `display_name` 필드 확인, Then 값은 같은 row의 `asset_id`가 가리키는 joined `assets.display_name`과 일치한다. 참조 asset의 `display_name`이 NULL이면 응답 필드도 `null`. -- 기존 AC-1~12: 변경 없음. - -#### Impacted Area Delta -- Modules added: - - `app/domain/models.py` — `Portfolio` dataclass에 `display_name: str | None = None` 필드 추가 (asset join 결과를 매핑하는 뷰 캐시) - - `app/schemas/v1.py` — `PortfolioItem`에 `display_name: str | None = None` 필드 추가 - - `app/api/v1_portfolios.py` — `_portfolio_to_item` 매핑에 `display_name` 포함 - - `app/repositories/postgres.py` — `list_portfolios` 등 portfolio fetching 경로에 `LEFT JOIN assets`로 `display_name` 보강 -- Tests added/updated: - - `tests/test_v1_portfolios_list.py` — `_make_portfolio` 헬퍼에 `display_name` 인자 추가 + 응답 검증 케이스 (이미 부분 작업 흔적) - -#### Delivery Trace Delta -- Batches: - - BAT-009 (v1-portfolios-crud-core) — augmentation 누적, verification 재실행 필요 -- Releases: - - REL-001 — scope 잔존, 본 augmentation 포함 (`docs/releases/rel-001_20260521_v1-api-full-surface.md`) - -#### Revalidation Impact -- Reverification Needed: yes -- Existing Implementation Invalidated: no (응답 schema 확장. 기존 호출자 backwards-compat. 다만 repo join 회귀 검증 필요) -- Status Recommendation: revert-to-approved -- Follow-up Action: 구현 → pytest 회귀 → 로컬 smoke로 GET /v1/portfolios 응답에 `display_name` 포함 확인 → confirm-req-implemented 재진입 - -#### Validation Plan -- pytest로 AC-13 검증; 로컬 smoke로 admin/session/service token GET /v1/portfolios 응답에 `display_name` 필드 포함 확인. - -### CHG-20260524-02 -- Date: 2026-05-24 -- Author: Hermes Agent - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-014 verification 완료. GET `/v1/portfolios` 응답이 `portfolios JOIN assets` 결과에서 `symbol` 을 직접 반환함이 코드/테스트/문서로 재검증되었다. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: BAT-014 (release-candidate); BAT-009 response-contract evidence superseded by BAT-014 -- Releases: pending - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-implemented -- Follow-up Action: release 단계에서 smoke 확인 - -### CHG-20260524-01 -- Date: 2026-05-24 -- Author: Hermes Agent - -#### Change Summary -- `GET /v1/portfolios` 응답을 joined asset result 기반으로 명확히 고정하고 `PortfolioItem.symbol` 계약을 추가하기 위해 REQ-028을 augment. 상태를 Implemented → Approved로 되돌림. - -#### Intent -- trex-orbit 자동매매 데몬은 `GET /v1/portfolios` 응답만으로 외부 시세 API 호출 대상 symbol을 바로 얻어야 한다. 기존 계약은 `asset_id`만 제공해 클라이언트 측 catalog cache를 강제했으므로, server-side asset join 결과(`assets.symbol`)를 직접 응답에 포함하도록 명시한다. - -#### Acceptance Criteria Delta -- Added: - - AC-12: GET 응답 item의 `symbol` 은 joined `assets.symbol` 과 일치해야 함 - -#### Impacted Area Delta -- Docs: `docs/interface-contract.md` GET `/v1/portfolios` 예시/필드 정의에 `symbol` 및 join 기반 응답 설명 추가 필요 (REQ-032 후속) -- Modules: `app/schemas/v1.py`, `app/api/v1_portfolios.py` (`_portfolio_to_item`), `app/repositories/postgres.py` (`_SELECT_PORTFOLIO` join 결과를 응답까지 전달) -- Tests: `tests/test_v1_portfolios_list.py` 에 list 응답 `symbol` 검증 추가 필요 - -#### Delivery Trace Delta -- Batches: BAT-009 evidence partially superseded; follow-up BAT-014 예정 -- Releases: REL-001 response-contract evidence 일부 재검토 필요 - -#### Revalidation Impact -- Reverification Needed: yes -- Existing Implementation Invalidated: yes (response schema evidence partial) -- Status Recommendation: revert-to-approved -- Follow-up Action: BAT-014에서 GET `/v1/portfolios` 응답이 asset join 결과를 직접 반환하는지 회귀 검증 - -#### Validation Plan -- `tests/test_v1_portfolios_list.py` 에서 각 item의 `symbol` 필드 존재와 `asset_id`가 가리키는 joined asset row의 `symbol` 일치 여부를 검증한다. - -### 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-5 산출물 + REQ-013 Deprecated 후속. - -#### Intent -- v1 portfolio 조회 신설 + 토큰 분기 정책 path 이전 + R-2 disabled 가시성 완화. - -#### Acceptance Criteria Delta -- Added: - - AC-1~11 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md, docs/runtime-flows.md (REQ-032) -- Modules: app/api/v1_portfolios.py, app/api/v1_assets.py(catalog 회수), app/schemas/v1.py, app/services/portfolio_service.py, app/repositories/postgres.py — 최초 지정 -- Tests: tests/test_v1_portfolios_list.py (신규) — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; dcy-001 REQ-013 Deprecated 정리 후속 - -#### Validation Plan -- pytest TestClient로 AC-1~11 검증; 토큰 분기 + `?market` + `?user_id` + `?include_disabled` 시나리오 diff --git a/docs/srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md b/docs/srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md deleted file mode 100644 index 6e41746..0000000 --- a/docs/srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md +++ /dev/null @@ -1,138 +0,0 @@ -# REQ-029: POST /v1/portfolios/{portfolio_id}/ledger/trades 이전 - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- 기존 `POST /v1/assets/{asset_id}/ledger/trades`(`app/api/v1_ledger.py`)의 path param 이름이 `asset_id`이나 실제 값은 `portfolios.portfolio_id`(UUID)로 의미 혼용되는 결함을 해소한다. -- dcy-003 옵션 B(path 명명 분리)에 따라 신 path `POST /v1/portfolios/{portfolio_id}/ledger/trades`로 이전하고 구 path는 동일 release에서 제거한다. 동작·인가·idempotency는 그대로 보존. - -## Requirement -- 신규 라우트 `POST /v1/portfolios/{portfolio_id}/ledger/trades` (Bearer 인증 필수). -- **동작은 기존 `POST /v1/assets/{asset_id}/ledger/trades`와 동일하게 보존**: - - **daemon assignment 3단계 fail-closed 인가**: (1) token `daemon_id IS NULL` → HTTP 403, (2) portfolio `managed_by_daemon_id IS NULL`(미배정 asset) → HTTP 403, (3) `portfolio.managed_by_daemon_id != auth.daemon_id`(불일치) → HTTP 403. - - **idempotency**: `(portfolio_id, order_id, execution_id)` unique constraint (기존 `uq_ledger_trades_idempotency` 재사용). - - **duplicate/conflict 응답**: 동일 키 + 동일 payload → HTTP 200 + `status="duplicate"`. 동일 키 + 다른 payload → HTTP 409. - - **새 ledger row** → HTTP 201 + `status="new"`. -- **disabled portfolio 차단** (REQ-027 정합): `portfolios.status='disabled'`인 portfolio에 대해서는 HTTP 409 또는 422로 차단. -- 구 path `POST /v1/assets/{asset_id}/ledger/trades` 제거: 동일 batch release에서 라우트 삭제. 호출 시 HTTP 404. -- **trex-basic 동시 배포 필수** (dcy-003 OQ-3 + NFR-2): 신 path 단일 서빙. 한시적 별칭/redirect 없음. release plan에 배포 순서 잠금(trex-basic 클라이언트 사전 빌드 → portfolio-hub 배포 → trex-basic 배포) + rollback 시 trex-basic 동반 rollback 전제 명시. - -## Acceptance Criteria -1. Given service token + 본인 daemon에 배정된 active portfolio + 신규 trade payload, When `POST /v1/portfolios/{portfolio_id}/ledger/trades`, Then HTTP 201 + `status="new"` + 생성된 ledger row. -2. Given AC-1 실행 후 동일 key + 동일 payload 재호출, When 호출, Then HTTP 200 + `status="duplicate"` + 단일 ledger row 유지. -3. Given AC-1 실행 후 동일 key + 다른 payload 호출, When 호출, Then HTTP 409 + ledger row 변경 없음. -4. Given service token `daemon_id IS NULL`, When `POST /v1/portfolios/{portfolio_id}/ledger/trades`, Then HTTP 403 fail-closed. -5. Given service token + `portfolio.managed_by_daemon_id IS NULL`(미배정 asset), When 호출, Then HTTP 403 fail-closed. -6. Given service token + `portfolio.managed_by_daemon_id != auth.daemon_id`(불일치), When 호출, Then HTTP 403 fail-closed. -7. Given service token + `status='disabled'` portfolio, When 호출, Then HTTP 409 또는 422 (disabled write 차단). -8. Given 비존재 `portfolio_id`, When 호출, Then HTTP 404. -9. Given 구 path `POST /v1/assets/{asset_id}/ledger/trades` 호출, When 본 REQ 구현 후 동일 batch release, Then HTTP 404. -10. Given Bearer token 없음, When 호출, Then HTTP 401. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — POST /v1/portfolios/{id}/ledger/trades 명세 + 구 path 제거 (REQ-032) - - `docs/runtime-flows.md` — daemon assignment 3단계 인가 + idempotency + path 이전 흐름 (REQ-032) -- Modules: - - `app/api/v1_portfolios.py` — `POST /v1/portfolios/{portfolio_id}/ledger/trades` 라우트 추가 (또는 별도 `v1_ledger.py`를 신 path로 이전) - - `app/api/v1_ledger.py` — 구 path 제거 또는 신 path로 라우트 prefix 이전 - - `app/main.py` — 라우터 include 갱신 (구 ledger 라우터 제거) - - `app/services/portfolio_service.py` 또는 `app/services/ledger_service.py` — 기존 ledger trade 로직 재사용 + disabled 차단 검증 추가 -- Tests: - - 갱신 `tests/test_v1.py` — 구 path 케이스를 신 path로 갱신, 구 path 호출 시 HTTP 404 확인 - - 신규 `tests/test_v1_portfolios_ledger.py` — AC-1~10 검증 - -## Notes -- 본 REQ는 기능 추가가 아닌 **path 이전**이라 기존 동작·테스트 시나리오를 그대로 재사용한다. 다만 disabled portfolio 차단(AC-7)은 REQ-027 soft delete 정책 정합 검증으로 신규. -- dcy-003 NFR-2(trex-basic 동시 배포)는 본 REQ의 가장 큰 운영 리스크(R-1)이며, release plan에 배포 순서 잠금·rollback 전제를 명시해야 함. release plan은 batch design/release 단계 작업. -- ledger_trades 테이블의 `asset_id` 컬럼명은 본 REQ에서 변경하지 않는다(dcy-001 `assets.asset_id`와 의미 혼용이 잔존하나, ledger_trades 컬럼은 portfolio_id를 가리킨다는 점이 path 이름 정합화로 충분히 명확해짐). 컬럼명 변경은 별도 migration REQ로 분리 가능. -- Source: dcy-003 FR-6, OQ-3, NFR-1, NFR-2, R-1 완화. - -## 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: BAT-011 (in-delivery) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: BAT-011 draft-batch-planning 완료 → draft-batch-design 진행 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-6 산출물. - -#### Intent -- ledger trade path 명명 정합화 + 동시 배포 정책 명시 + disabled 차단 추가. - -#### Acceptance Criteria Delta -- Added: - - AC-1~10 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md, docs/runtime-flows.md (REQ-032) -- Modules: app/api/v1_portfolios.py, app/api/v1_ledger.py(제거 또는 이전), app/main.py, app/services/* — 최초 지정 -- Tests: tests/test_v1.py(갱신), tests/test_v1_portfolios_ledger.py(신규) — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정). trex-basic 동시 배포 release plan은 batch design 단계에서 작성. - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; trex-basic 측 release readiness(DATA_TREX_BASIC_RELEASE_WINDOW) 외부 확인 필요 - -#### Validation Plan -- pytest TestClient로 AC-1~10 검증; 신·구 path 회귀 + daemon 인가 3단계 + idempotency·conflict + disabled 차단 diff --git a/docs/srs/Interface/req-030_v1-portfolios-tags-crud.md b/docs/srs/Interface/req-030_v1-portfolios-tags-crud.md deleted file mode 100644 index 9d413f2..0000000 --- a/docs/srs/Interface/req-030_v1-portfolios-tags-crud.md +++ /dev/null @@ -1,142 +0,0 @@ -# REQ-030: portfolio tag 적용/해제/조회 v1 라우트 - -- Status: Implemented -- Type: Interface -- Priority: Medium -- Owner: Justin Ko - -## Intent -- v1 API에 portfolio tag 적용·해제·조회 경로(`/v1/portfolios/{portfolio_id}/tags`)를 신설해 user/admin이 HTTP로 portfolio에 태그를 적용·관리할 수 있도록 한다. -- dcy-002 FR-3 portfolios.tag_ids 정책(`tags.is_admin_tag=TRUE` 또는 `tags.created_by_user_id = portfolio.owner_user_id`)을 v1 라우트에 일관 적용해 태그 적용 정책 위반을 fail-closed로 거부한다. - -## Requirement -- 신규 라우트 (Bearer 인증 필수): - - `POST /v1/portfolios/{portfolio_id}/tags` — body: `{"tag_id": ""}`. 응답 HTTP 201 + 적용된 tag 정보. - - `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}` — 응답 HTTP 200 또는 204. - - `GET /v1/portfolios/{portfolio_id}/tags` — 응답 HTTP 200 + 해당 portfolio에 적용된 tag 목록. -- **ownership 규칙** (REQ-026과 동일): - - `token_type ∈ {service, client}` → HTTP 403 fail-closed. - - `permission='admin'` → 모든 portfolio. - - `permission ∈ {read, trade-write}` → 자기 소유 portfolio만, 불일치 시 HTTP 403. -- **dcy-002 FR-3 태그 적용 정책 검증** (POST 시): `tags.is_active=TRUE`이고 (`tags.is_admin_tag=TRUE` 또는 `tags.created_by_user_id = portfolio.owner_user_id`)인 경우에만 적용 허용. 정책 위반 시 HTTP 403. -- **disabled portfolio 차단** (REQ-027 정합): `status='disabled'`인 portfolio에 대해 POST/DELETE는 HTTP 409 또는 422로 거부. GET은 허용(`?include_disabled=true` 별도 제어 불필요 — GET tag는 항상 조회 가능). -- 비존재 `portfolio_id` 또는 `tag_id`는 HTTP 404. -- tag 적용 후 portfolio의 `tag_ids` UUID 배열에 추가(dcy-002 portfolios.tag_ids 스키마 반영). 중복 적용은 HTTP 409 또는 멱등 200 (REQ 구현 시 확정). - -## Acceptance Criteria -1. Given session token + 본인 active portfolio + admin이 생성한 active tag, When `POST /v1/portfolios/{id}/tags` body `{"tag_id": ...}`, Then HTTP 201 + portfolio.tag_ids 배열에 tag_id 추가. -2. Given session token + 본인 portfolio + 본인이 생성한 active tag, When `POST`, Then HTTP 201. -3. Given session token + 본인 portfolio + 타 user 생성한 non-admin tag, When `POST`, Then HTTP 403 (dcy-002 FR-3 정책 위반). -4. Given session token + 본인 portfolio + `tags.is_active=FALSE` tag, When `POST`, Then HTTP 403 (비활성 tag 적용 불가). -5. Given admin token + 타 user portfolio + 임의 active tag, When `POST`, Then dcy-002 정책 그대로 적용(admin tag 또는 portfolio.owner 생성 tag만 허용). 위반 시 HTTP 403. -6. Given session token + 타 user portfolio, When `POST`, Then HTTP 403 (ownership 불일치). -7. Given service/client token, When `POST`, Then HTTP 403 fail-closed. -8. Given session token + disabled portfolio, When `POST` 또는 `DELETE`, Then HTTP 409 또는 422. -9. Given session token + 본인 portfolio + 적용된 tag, When `DELETE /v1/portfolios/{id}/tags/{tag_id}`, Then HTTP 200/204 + portfolio.tag_ids 배열에서 제거. -10. Given session token + 본인 portfolio, When `GET /v1/portfolios/{id}/tags`, Then HTTP 200 + 해당 portfolio에 적용된 tag 목록 반환. -11. Given 비존재 `portfolio_id` 또는 `tag_id`, When 호출, Then HTTP 404. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — `/v1/portfolios/{id}/tags` 명세 (REQ-032) -- Modules: - - `app/api/v1_portfolios.py` — tag 서브 라우트 추가 - - `app/schemas/v1.py` — `PortfolioTagApplyRequest`, `PortfolioTagResponse` 신규 - - `app/services/portfolio_service.py:add_tag`/`remove_tag`/`list_tags` — dcy-002 FR-3 정책 검증 추가 - - `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `apply_tag(portfolio_id, tag_id)`, `remove_tag`, `list_tags` 메서드 (dcy-002 REQ-003 portfolios.tag_ids 배열 처리와 정합) -- Tests: - - 신규 `tests/test_v1_portfolios_tags_comments.py` — AC-1~11 (tag 부분) - -## Notes -- dcy-002 REQ-001(tags 마스터)·REQ-003(portfolios.tag_ids) 선행 필요. dcy-002 REQ가 모두 Approved 상태이므로 batch design 단계에서 의존성 순서 명시. -- 본 REQ의 tag 적용 정책 검증은 dcy-002 FR-3 본문을 그대로 채택. dcy-002 REQ-005(태그 적용·해제 HTTP API)와 본 REQ의 범위 경계는 다음과 같이 분리: - - dcy-002 REQ-005: `/v1/portfolios/{id}/tags` 적용 정책 정의 + 일반 portfolio 측면 - - 본 REQ: v1 portfolio mutation surface 내에서의 라우트·ownership·disabled 통합 검증 - - 본 REQ가 dcy-002 REQ-005를 흡수하거나 보강하는 형태로 batch design 단계에서 결정. -- Source: dcy-003 FR-7, dcy-002 FR-3. - -## 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-7 산출물. - -#### Intent -- v1 portfolio tag 적용/해제/조회 라우트 신설 + dcy-002 tag 정책 v1 일관 적용. - -#### Acceptance Criteria Delta -- Added: - - AC-1~11 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md (REQ-032) -- Modules: app/api/v1_portfolios.py, app/schemas/v1.py, app/services/portfolio_service.py, app/repositories/* — 최초 지정 -- Tests: tests/test_v1_portfolios_tags_comments.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; dcy-002 REQ-001/003/005 선행 의존성 + 본 REQ와의 범위 경계 확정 - -#### Validation Plan -- pytest TestClient로 AC-1~11 검증; ownership + dcy-002 tag 정책 위반 + disabled 차단 시나리오 diff --git a/docs/srs/Interface/req-031_v1-portfolios-comments-crud.md b/docs/srs/Interface/req-031_v1-portfolios-comments-crud.md deleted file mode 100644 index 678e859..0000000 --- a/docs/srs/Interface/req-031_v1-portfolios-comments-crud.md +++ /dev/null @@ -1,139 +0,0 @@ -# REQ-031: portfolio comment CRUD v1 라우트 - -- Status: Implemented -- Type: Interface -- Priority: Medium -- Owner: Justin Ko - -## Intent -- v1 API에 portfolio comment CRUD 경로(`/v1/portfolios/{portfolio_id}/comments`)를 신설해 user/admin이 HTTP로 portfolio에 코멘트를 작성·관리할 수 있도록 한다. -- ownership 규칙과 disabled 차단을 REQ-026/027과 동일 패턴으로 적용해 v1 portfolio surface 인가 일관성을 확보한다. - -## Requirement -- 신규 라우트 (Bearer 인증 필수): - - `POST /v1/portfolios/{portfolio_id}/comments` — body: `{"content": ""}`. 응답 HTTP 201 + 생성된 comment. - - `GET /v1/portfolios/{portfolio_id}/comments` — 응답 HTTP 200 + comment 목록(created_at desc 순). - - `PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}` — body: `{"content": ""}`. 응답 HTTP 200 + 갱신된 comment. - - `DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}` — 응답 HTTP 200 또는 204. -- **ownership 규칙** (REQ-026/030과 동일): - - `token_type ∈ {service, client}` → HTTP 403 fail-closed. - - `permission='admin'` → 모든 portfolio. - - `permission ∈ {read, trade-write}` → 자기 소유 portfolio만, 불일치 시 HTTP 403. -- **disabled portfolio 차단** (REQ-027 정합): `status='disabled'`인 portfolio에 대해 POST/PATCH/DELETE는 HTTP 409 또는 422로 거부. GET은 허용. -- 비존재 `portfolio_id` 또는 `comment_id`는 HTTP 404. -- 기존 `CommentCreateRequest`/`CommentUpdateRequest`/`CommentResponse` schema 재사용 또는 v1 전용 래핑(`PortfolioCommentRequest`, `PortfolioCommentResponse`). -- 기존 service 메서드(`create_comment`, `list_comments`, `update_comment`, `delete_comment`) 재사용. - -## Acceptance Criteria -1. Given session token + 본인 active portfolio, When `POST /v1/portfolios/{id}/comments` body `{"content": "..."}`, Then HTTP 201 + 생성된 comment. -2. Given session token + 본인 portfolio + 적용된 comments, When `GET /v1/portfolios/{id}/comments`, Then HTTP 200 + 코멘트 목록(created_at desc). -3. Given session token + 본인 portfolio + 본인 comment, When `PATCH /v1/portfolios/{id}/comments/{comment_id}` body `{"content": "..."}`, Then HTTP 200 + 갱신된 comment. -4. Given session token + 본인 portfolio + 본인 comment, When `DELETE /v1/portfolios/{id}/comments/{comment_id}`, Then HTTP 200/204. -5. Given session token + 타 user portfolio, When 임의 동사 호출, Then HTTP 403 (ownership 불일치). -6. Given service/client token, When 임의 동사 호출, Then HTTP 403 fail-closed. -7. Given session token + disabled portfolio, When `POST`/`PATCH`/`DELETE`, Then HTTP 409 또는 422. -8. Given session token + disabled portfolio, When `GET /v1/portfolios/{id}/comments`, Then HTTP 200 + 기존 comments 목록 반환(조회는 허용). -9. Given admin token + 타 user portfolio, When 임의 동사 호출, Then HTTP 200/201/204 (admin은 모든 portfolio 허용). -10. Given Bearer token 없음, When 호출, Then HTTP 401. -11. Given 비존재 `portfolio_id` 또는 `comment_id`, When 호출, Then HTTP 404. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — `/v1/portfolios/{id}/comments` 명세 (REQ-032) -- Modules: - - `app/api/v1_portfolios.py` — comment 서브 라우트 추가 - - `app/schemas/v1.py` — comment 관련 스키마 (기존 `app/schemas/portfolio.py:CommentCreateRequest`/`CommentUpdateRequest`/`CommentResponse` 재사용 또는 v1 래핑) - - `app/services/portfolio_service.py:create_comment`/`list_comments`/`update_comment`/`delete_comment` — 기존 메서드 재사용 + disabled 차단 검증 추가 -- Tests: - - 신규/갱신 `tests/test_v1_portfolios_tags_comments.py` — AC-1~11 (comment 부분) - -## Notes -- comment 작성자 ownership(본인 comment만 수정/삭제 가능)은 기존 portfolio_comments 테이블 구조에 따라 처리. dcy-003 본문은 portfolio ownership만 명시하고 comment 작성자 ownership은 별도 결정 사항 — 본 REQ에서는 portfolio ownership을 일관 적용하고 comment 작성자 ownership은 batch design 단계에서 확정. -- 본 REQ는 dcy-003 FR-8 산출물. tag(REQ-030)와 별도 REQ로 분리해 동사 surface를 명확히 함. -- Source: dcy-003 FR-8, NFR-1, NFR-3, NFR-4. - -## 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 - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-8 산출물. - -#### Intent -- v1 portfolio comment CRUD 라우트 신설 + ownership/disabled 검증 일관 적용. - -#### Acceptance Criteria Delta -- Added: - - AC-1~11 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md (REQ-032) -- Modules: app/api/v1_portfolios.py, app/schemas/v1.py, app/services/portfolio_service.py — 최초 지정 -- Tests: tests/test_v1_portfolios_tags_comments.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; comment 작성자 ownership 정책은 batch design 단계에서 확정 - -#### Validation Plan -- pytest TestClient로 AC-1~11 검증; ownership + disabled 차단 + admin 분기 시나리오 diff --git a/docs/srs/Interface/req-034_login-request-extra-forbid-enforcement.md b/docs/srs/Interface/req-034_login-request-extra-forbid-enforcement.md deleted file mode 100644 index 0007b9d..0000000 --- a/docs/srs/Interface/req-034_login-request-extra-forbid-enforcement.md +++ /dev/null @@ -1,126 +0,0 @@ -# REQ-034: LoginRequest 추가 필드 거부 enforcement (session no_expiry 정책 정합) - -- Status: Implemented -- Type: Interface -- Priority: Medium -- Owner: Justin Ko - -## Intent -- REQ-021 정책 본문이 "session에서 `no_expiry` 시도 → HTTP 422 또는 ValueError 거부"로 명시하지만, 현재 `LoginRequest` 스키마([app/schemas/auth.py:13-23](../../app/schemas/auth.py#L13-L23))가 Pydantic 기본(`extra="ignore"`)이라 외부에서 `no_expiry: true`를 추가해 보내도 silently 무시된다. -- `LoginRequest`에 `extra="forbid"` 적용으로 정책 명문과 코드 동작을 일치시켜 audit 명확성을 확보한다. - -## Requirement -- `app/schemas/auth.py:LoginRequest`에 `model_config = ConfigDict(extra="forbid")`를 적용한다. -- 외부 client가 LoginRequest에 정의되지 않은 필드(`no_expiry` 포함, 기타 임의 필드)를 전송하면 FastAPI/Pydantic이 HTTP 422로 거부한다. -- `AuthService.login`([app/services/auth_service.py:47-65](../../app/services/auth_service.py#L47-L65))의 동작은 변경하지 않는다(정책 enforcement는 스키마 단계에서 처리). -- 기존 정상 client(username/password 2필드만 전송)는 영향 없음을 회귀 테스트로 확인한다. - -## Acceptance Criteria -1. Given valid username/password, When `POST /auth/login` body `{"username": "...", "password": "..."}` 호출, Then HTTP 200 + 정상 TokenResponse 반환(기존 동작 회귀). -2. Given valid username/password + 추가 필드, When `POST /auth/login` body `{"username": "...", "password": "...", "no_expiry": true}` 호출, Then HTTP 422 응답 + Pydantic validation error detail에 `extra_forbidden` 표기. -3. Given valid username/password + 임의 추가 필드, When `POST /auth/login` body `{"username": "...", "password": "...", "arbitrary_field": "x"}` 호출, Then HTTP 422 응답 (extra="forbid" 일반 동작 회귀). -4. Given codebase grep `grep -n "extra.*forbid" app/schemas/auth.py`, When 본 REQ 구현 후, Then `LoginRequest` 정의 부근에 매치된다. -5. Given 본 REQ 구현 후 전체 pytest 회귀, When 실행, Then 기존 통과 케이스 모두 유지. - -## Impacted Area -- Docs: - - (선택) `docs/srs/Documentation/req-021_token-expiry-and-rotation-policy.md` Notes에 "AC-4 enforcement는 REQ-034로 분리 구현" 추가 (cross-reference) -- Modules: - - `app/schemas/auth.py` — `LoginRequest.model_config = ConfigDict(extra="forbid")` 추가 -- Tests: - - 신규 `tests/test_auth.py::test_login_rejects_no_expiry_extra_field` — AC-2 검증 - - 신규 `tests/test_auth.py::test_login_rejects_arbitrary_extra_field` — AC-3 검증 (또는 AC-2와 단일 케이스로 합침) - -## Notes -- 본 REQ는 dcy-005 FR-2 산출물. REQ-021 follow-up. -- `extra="forbid"`는 `LoginRequest` 전체에 추가 필드 거부 효과. 기존 정상 client는 username/password만 전송하므로 영향 없음. 본 워크스페이스는 개발용이라 외부 monitoring 영향 무. -- 본 REQ는 별도 REL-002로 분리 배포(dcy-005 OQ-3 결정, 2026-06-01). -- Source: dcy-005 FR-2, REQ-021 AC-5 follow-up. - -## Change Log - -### CHG-20260601-03 -- Date: 2026-06-01 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-015 confirm-batch-verification 완료(2026-06-05). AC-1~5 모두 PASSED. `LoginRequest` extra="forbid" 적용 + pytest 422 회귀 2건 신규 + 로컬 smoke 422 + extra_forbidden 확인. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-015 (release-candidate) -- Releases: - - REL-002 (예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: none - -### CHG-20260601-02 -- Date: 2026-06-01 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC-1~5 구체성 확인 완료, blocker 없음. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-002 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 (BAT-015 후보) - -### CHG-20260601-01 -- Date: 2026-06-01 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-005 confirm-discovery 후 draft-req 실행. dcy-005 FR-2 산출물. - -#### Intent -- REQ-021 정책 본문과 LoginRequest 코드 동작 일치 (session no_expiry 거부 enforcement). - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: (선택) docs/srs/Documentation/req-021_*.md cross-reference — 최초 지정 -- Modules: app/schemas/auth.py — 최초 지정 -- Tests: tests/test_auth.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-002 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입 - -#### Validation Plan -- pytest TestClient로 AC-1~3 검증; grep으로 AC-4 검증 diff --git a/docs/srs/Interface/req-035_health-response-db-enabled-field.md b/docs/srs/Interface/req-035_health-response-db-enabled-field.md deleted file mode 100644 index e145256..0000000 --- a/docs/srs/Interface/req-035_health-response-db-enabled-field.md +++ /dev/null @@ -1,128 +0,0 @@ -# REQ-035: GET /health 응답에 db_enabled 필드 추가 - -- Status: Implemented -- Type: Interface -- Priority: Medium -- Owner: Justin Ko - -## Intent -- 현재 `HealthResponse` 모델([app/api/health.py:17-21](../../app/api/health.py#L17-L21))이 `status`, `service`, `version`, `checked_at` 4필드만 노출하여 외부 health probe가 DB 사용 설정 상태를 직접 관찰할 수 없다. -- REL-001 verification 체크리스트의 "GET /health → HTTP 200 + `db_enabled: true`" 요구사항과 응답 모델 gap을 닫는다. -- 본 필드는 환경변수 `PORTFOLIOHUB_DB_ENABLED` 설정값을 노출하는 것이며 actual DB connectivity ping이 아님을 baseline 문서(`docs/runtime-flows.md`)에 명문화한다. - -## Requirement -- `HealthResponse` 모델에 `db_enabled: bool` 필드를 추가한다. -- `healthcheck()` 핸들러가 `settings.db_enabled`(환경변수 `PORTFOLIOHUB_DB_ENABLED` 설정값)를 `db_enabled` 필드에 채워서 응답한다. -- `docs/runtime-flows.md`에 "GET /health 응답의 `db_enabled`는 환경변수 설정값 노출이며 actual DB connectivity ping이 아니다. production-grade readiness probe는 별도 `/readiness` endpoint 분리로 후속 처리한다"는 의미 명문화 노트를 추가한다. -- `docs/interface-contract.md`에 /health 응답 schema의 `db_enabled` 필드 명세를 추가한다. - -## Acceptance Criteria -1. Given PORTFOLIOHUB_DB_ENABLED=true 환경, When `GET /health` 호출, Then HTTP 200 응답 본문에 `"db_enabled": true` 필드가 포함된다. -2. Given PORTFOLIOHUB_DB_ENABLED=false 환경, When `GET /health` 호출, Then HTTP 200 응답 본문에 `"db_enabled": false` 필드가 포함된다. -3. Given 응답 본문, When `status`, `service`, `version`, `checked_at`, `db_enabled` 5필드 확인, Then 모두 노출되고 기존 4필드 동작은 회귀 통과한다. -4. Given grep `grep -n "db_enabled" docs/runtime-flows.md`, When 실행, Then "설정값 노출, actual ping 아님" 의미 노트가 매치된다. -5. Given grep `grep -n "/health.*db_enabled\|db_enabled.*health" docs/interface-contract.md`, When 실행, Then /health 응답 schema에 `db_enabled` 필드 명세가 매치된다. - -## Impacted Area -- Docs: - - `docs/runtime-flows.md` — /health probe flow 섹션에 "db_enabled는 설정값 노출, actual ping 아님" 의미 노트 추가 (baseline 문서 갱신) - - `docs/interface-contract.md` — /health 응답 schema에 `db_enabled: bool` 필드 추가 -- Modules: - - `app/api/health.py` — `HealthResponse`에 `db_enabled: bool` 필드 추가 + `healthcheck()` 핸들러가 `settings.db_enabled` 채움 -- Tests: - - 신규 `tests/test_app.py::test_health_exposes_db_enabled` — AC-1 검증 (PORTFOLIOHUB_DB_ENABLED=true 환경) - - (선택) 추가 케이스 — AC-2 검증 (PORTFOLIOHUB_DB_ENABLED=false 환경) - -## Notes -- 본 REQ는 dcy-005 FR-3 산출물. REL-001 verification gap을 닫는 정합화. -- `db_enabled` 값은 단순 설정값 노출이며 actual DB connectivity 보장이 아니다. production-grade readiness probe(SELECT 1 ping 등)는 본 REQ 범위 밖이며 별도 후속 Discovery + `/readiness` endpoint 분리로 처리. -- 본 REQ는 별도 REL-002로 분리 배포(dcy-005 OQ-3 결정, 2026-06-01). REL-001은 현 상태(체크리스트 `db_enabled` 미노출 [~] 부분 통과)로 배포 진행. -- Source: dcy-005 FR-3, rel-001 Feedback Handoff 후보. - -## Change Log - -### CHG-20260601-03 -- Date: 2026-06-01 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-015 confirm-batch-verification 완료(2026-06-05). AC-1~5 모두 PASSED. HealthResponse 확장 + handler + pytest 3건(test_healthcheck 갱신 + 신규 2건) + baseline 문서(`docs/runtime-flows.md`) 명문화 + interface-contract.md 정합 모두 evidence 확보. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-015 (release-candidate) -- Releases: - - REL-002 (예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: 미래 production-grade readiness probe(`/readiness` endpoint 분리)는 본 REQ 범위 밖, 별도 후속 Discovery 후보. - -### CHG-20260601-02 -- Date: 2026-06-01 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC-1~5 구체성 확인 완료, blocker 없음. baseline 문서(docs/runtime-flows.md) 갱신 요구가 AC-4에 반영됨. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-002 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 (BAT-015 후보) - -### CHG-20260601-01 -- Date: 2026-06-01 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-005 confirm-discovery 후 draft-req 실행. dcy-005 FR-3 산출물. - -#### Intent -- REL-001 verification checklist의 `/health db_enabled` gap을 닫고 응답 모델 확장 + baseline 문서(runtime-flows.md) 명문화. - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: docs/runtime-flows.md (baseline), docs/interface-contract.md — 최초 지정 -- Modules: app/api/health.py — 최초 지정 -- Tests: tests/test_app.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-002 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입 - -#### Validation Plan -- pytest TestClient로 AC-1~3 검증; grep으로 AC-4/AC-5 baseline 문서 명문화 검증 diff --git a/docs/srs/Interface/req-037_ledger-trade-write-accounting-and-state-response.md b/docs/srs/Interface/req-037_ledger-trade-write-accounting-and-state-response.md deleted file mode 100644 index f3f0a33..0000000 --- a/docs/srs/Interface/req-037_ledger-trade-write-accounting-and-state-response.md +++ /dev/null @@ -1,177 +0,0 @@ -# REQ-037: 신 path 회계 책임 단일화 + 응답에 portfolio 상태 포함 - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- `POST /v1/portfolios/{portfolio_id}/ledger/trades`를 portfolio 회계의 단일 권위 endpoint로 단일화한다. ledger 기록 시 portfolio `cash_balance`/`asset_quantity`를 트랜잭션 갱신하고, 응답에 거래 반영 후 권위 상태를 포함해 trex-core daemon의 drift를 원천 차단한다(issue #8). -- 현재 audit-only 흐름(`insert_ledger_trade`가 `ledger_trades` INSERT만 수행)을 portfolio_ledger 단일 원장 기록 + portfolio 갱신 + 응답 확장으로 재정의한다. - -## Requirement -- 신 path handler([app/api/v1_portfolios.py:514-588](../../app/api/v1_portfolios.py#L514-L588) `record_ledger_trade_v1`)가 다음을 단일 DB 트랜잭션으로 수행한다(dcy-006 FR-6): - - portfolios row를 `SELECT ... FOR UPDATE`로 잠근다(FR-7, lost update 방지). - - 회계 공식 적용: side="buy" → `cash_balance -= qty*price + fee + tax`, `asset_quantity += qty`; side="sell" → `cash_balance += qty*price - fee - tax`, `asset_quantity -= qty`. - - **잔고/수량 부족 거부 검증을 적용하지 않는다 — 음수 `cash_balance`/`asset_quantity` 모두 허용**(dcy-006 OQ-3). 음수 검증을 제거한 회계 헬퍼를 ledger 흐름 전용으로 추출하거나 inline 구현(기존 `apply_buy`/`apply_sell`은 레거시 전용으로 보존, R-8). - - `portfolio_ledger`에 INSERT(REQ-036 신 스키마 사용). broker 식별자 `order_id`/`execution_id`/`side`/`executed_at` + 갱신 후 잔고 snapshot(`cash_balance_after`, `asset_quantity_after`, `total_value_after`) 기록. - - portfolios 테이블 UPDATE(`cash_balance`, `asset_quantity`, `last_trade_at`, `updated_at`). -- duplicate 감지(dcy-006 FR-15, CHG-20260605-03 재정의): **application-level idempotency** — `SELECT ... FOR UPDATE`로 portfolios row를 잠근 상태에서 `(portfolio_id, order_id, execution_id)`로 기존 portfolio_ledger row를 SELECT한다. 존재하면 duplicate(INSERT/UPDATE 없이 현재 권위 상태 응답), 없으면 INSERT. portfolio_ledger가 TimescaleDB hypertable이라 DB partial UNIQUE index/`ON CONFLICT`가 불가능하므로(REQ-036 CHG-20260605-03) DB 제약 대신 row lock 직렬화로 race-safe 보장. order_id NULL(레거시/수동)은 dedup 조회 생략 → append-only. 트랜잭션은 `conn.transaction()` 사용(psycopg3 `with conn:`는 공유 connection을 close하므로). -- `status="new"`(신규 row): 거래 반영 후 권위 상태를 응답한다(FR-2). -- `status="duplicate"`(재호출): ledger row 재생성 없이 duplicate 호출 시점의 portfolios 테이블 현재 권위 상태를 SELECT로 조회해 응답한다(FR-3, dcy-006 OQ-4 (b)). -- `LedgerTradeResponse`([app/schemas/v1.py](../../app/schemas/v1.py))에 `cash_balance_after: float`, `asset_quantity_after: float` 두 필드를 응답 root에 flat으로 비파괴 추가한다(FR-1, dcy-006 OQ-2 (b)). 기존 응답 필드는 모두 보존. -- 기존 인가(daemon 3단계: token daemon_id == portfolio.managed_by_daemon_id), idempotency, status 의미를 보존한다(FR-4). -- 도메인 모델 통합(FR-13): `LedgerEntry`와 `LedgerTrade` dataclass를 `LedgerEntry` 단일로 통합. `LedgerTrade` 삭제. broker 필드(`ledger_entry_id`, `order_id`, `execution_id`, `side`, `executed_at`)를 `LedgerEntry`에 흡수. -- repository 통합(FR-14): `insert_ledger_trade`를 portfolio_ledger insert + portfolios UPDATE 단일 트랜잭션 함수로 재정의(또는 통합). `_row_to_ledger_trade`는 `_row_to_ledger_entry`와 통합. - -## Acceptance Criteria -1. Given service token + daemon 배정 active portfolio(cash=1,000,000, qty=0) + buy 10@80,000 fee 100, When `POST /v1/portfolios/{id}/ledger/trades`, Then HTTP 201 + `status="new"` + 응답 `cash_balance_after=199900`(1,000,000 - 800,000 - 100) + `asset_quantity_after=10`. DB portfolios도 동일 값으로 갱신. -2. Given AC-1 직후 같은 (portfolio_id, order_id, execution_id)로 재호출, When 동일 payload, Then HTTP 200 + `status="duplicate"` + 응답 `cash_balance_after`/`asset_quantity_after`는 현재 portfolios 권위 값. portfolio_ledger row는 1건 유지(재생성 없음). -3. Given sell로 보유 수량보다 큰 수량 또는 cash를 초과하는 buy, When 호출, Then **HTTP 201**(거부하지 않음) + 음수 `cash_balance_after` 또는 음수 `asset_quantity_after` 반환(음수 허용, OQ-3). -4. Given 기존 응답 필드(ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at, recorded_at, status), When AC-1 응답 확인, Then 모두 보존되고 `cash_balance_after`/`asset_quantity_after`만 추가된다. -5. Given service token `daemon_id IS NULL` 또는 daemon mismatch, When 호출, Then 기존 인가 동작(HTTP 403 fail-closed) 보존. -6. Given codebase grep `grep -rn "ledger_trades\|insert_ledger_trade\|class LedgerTrade\b" app/`, When 본 REQ + REQ-036 구현 후, Then `ledger_trades` 테이블 INSERT 코드 및 `LedgerTrade` dataclass 참조가 0건(LedgerEntry 단일). -7. Given 동시 ledger write 2건(같은 portfolio), When 트랜잭션 실행, Then `SELECT FOR UPDATE` row lock으로 lost update 없이 두 거래가 순차 반영된다(회귀 테스트). - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — `POST /v1/portfolios/{id}/ledger/trades` 회계 동작 + 응답 schema(`cash_balance_after`/`asset_quantity_after`) + status별 동작 명문화 - - `docs/runtime-flows.md` — ledger write flow에 회계 적용 + portfolio 갱신 + 트랜잭션 lock 단계 + portfolio_ledger 단일 원장 명문화 -- Modules: - - `app/api/v1_portfolios.py:record_ledger_trade_v1` — 통합 함수 호출 + 응답 확장 - - `app/repositories/postgres.py` — `insert_ledger_trade` 재정의(portfolio_ledger insert + portfolios UPDATE 단일 트랜잭션 + FOR UPDATE + application-level SELECT-before-insert idempotency + `conn.transaction()`), `_row_to_ledger_*` 통합 - - `app/repositories/portfolio_repository.py` — interface 시그니처 갱신 - - `app/domain/models.py` — `LedgerEntry` 단일화, `LedgerTrade` 삭제 - - `app/domain/services.py` 또는 신규 — 음수 검증 제거한 ledger 전용 회계 헬퍼 추출 - - `app/schemas/v1.py` — `LedgerTradeResponse`에 `cash_balance_after`/`asset_quantity_after` 추가 -- Tests: - - 갱신 `tests/test_v1_portfolios_ledger.py` — AC-1~7 (응답 신규 필드 + 회계 + duplicate + 음수 + 인가 보존 + 동시성) - - 갱신 `tests/test_v1.py`, `tests/test_daemon_assignment.py` — LedgerEntry 통합 + 응답 schema 반영 - -## Notes -- 본 REQ는 dcy-006 FR-1/2/3/4/6/7/13/14/15 산출물 + FR-5(interface-contract/runtime-flows) 일부 흡수. -- REQ-036(마이그레이션) 선행 필요 — portfolio_ledger 신 스키마 위에서 동작. -- 음수 허용은 신 path 한정. 레거시 buy/sell은 기존 거부 정책 유지(REQ-038 deprecated 대상). -- 별도 REL(REL-003 등)로 분리 배포(dcy-006 A-4). -- Source: dcy-006 FR-1/2/3/4/6/7/13/14/15, OQ-1~4/6/7. - -## Change Log - -### CHG-20260605-04 -- Date: 2026-06-05 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-016 confirm-batch-verification 완료. 신 path 회계 책임 단일화 + 응답 cash_balance_after/asset_quantity_after + duplicate 현 상태 + 도메인/repository 통합 구현. 실 DB 통합 테스트(new/duplicate/음수) 통과, AC-1~7 검증. 전체 376 passed. CHG-20260605-03(ON CONFLICT→application-level idempotency + conn.transaction()) 정합 반영. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-016 (release-candidate) -- Releases: - - REL-003 (예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL-003 배포 시 trex-core 응답 명세 정합 최종 확인 - -#### Validation Plan -- 실 DB 통합 테스트 + pytest 376 passed (완료) - -### CHG-20260605-03 -- Date: 2026-06-05 -- Author: Justin Ko - -#### Change Summary -- Requirement 재정의: duplicate 감지를 `ON CONFLICT ... WHERE order_id IS NOT NULL`(DB partial index)에서 **application-level SELECT-before-insert(under FOR UPDATE lock)**로 변경. portfolio_ledger가 TimescaleDB hypertable이라 partial unique index/ON CONFLICT가 불가능(REQ-036 CHG-20260605-03). 추가로 트랜잭션을 `conn.transaction()`으로 처리(psycopg3 `with conn:` connection close 결함 회피). Status Approved 유지. - -#### Intent -- BAT-016 verify에서 실 DB 미동작(ON CONFLICT 에러 + 공유 connection close) 2건 결함 발견. hypertable 현실에 맞춰 idempotency 설계 정합 + 결함 수정. - -#### Acceptance Criteria Delta -- No change (AC-1~7 그대로; AC-2 duplicate idempotency는 application layer로 동일 충족). 검증 방식만 mock → 실 DB 통합 테스트 추가. - -#### Impacted Area Delta -- Modules: `app/repositories/postgres.py` insert_ledger_trade — ON CONFLICT 제거 + SELECT-before-insert + `conn.transaction()` 반영 -- Tests: `tests/test_ledger_consolidation_integration.py`(실 DB) 추가 - -#### Delivery Trace Delta -- Batches: BAT-016 (in-delivery) -- Releases: none (REL-003 등 분리 예정) - -#### Revalidation Impact -- Reverification Needed: yes (실 DB 통합 테스트로 재검증 — 376 passed) -- Existing Implementation Invalidated: no (구현이 본 재정의에 맞춰 수정됨) -- Status Recommendation: keep-current -- Follow-up Action: BAT-016 verification 실 DB evidence 반영 - -#### Validation Plan -- pytest 376 passed(통합 3 포함) + 실 DB new/duplicate/음수 통합 테스트 - -### CHG-20260605-02 -- Date: 2026-06-05 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드 모두 충족, AC-1~7 구체성(수치 회계 예시 + 음수 허용 + duplicate + 동시성) 확인 완료, blocker 없음. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-003 등 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입; REQ-036 선행 의존성 확인 - -### CHG-20260605-01 -- Date: 2026-06-05 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-006 confirm-discovery 후 draft-req 실행. dcy-006 회계 책임 단일화 + 응답 확장 산출물. - -#### Intent -- 신 path를 portfolio 회계 단일 권위 endpoint로 재정의하고 응답에 거래 후 권위 상태를 포함해 trex-core drift 차단. - -#### Acceptance Criteria Delta -- Added: - - AC-1~7 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md, docs/runtime-flows.md — 최초 지정 -- Modules: app/api/v1_portfolios.py, app/repositories/postgres.py, app/repositories/portfolio_repository.py, app/domain/models.py, app/domain/services.py, app/schemas/v1.py — 최초 지정 -- Tests: tests/test_v1_portfolios_ledger.py, tests/test_v1.py, tests/test_daemon_assignment.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-003 등 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-036 선행 의존성 확인 - -#### Validation Plan -- pytest TestClient로 AC-1~7 검증; 동시성은 회귀 또는 staging concurrent test diff --git a/docs/srs/Interface/req-038_legacy-buy-sell-endpoint-deprecation.md b/docs/srs/Interface/req-038_legacy-buy-sell-endpoint-deprecation.md deleted file mode 100644 index 07b782c..0000000 --- a/docs/srs/Interface/req-038_legacy-buy-sell-endpoint-deprecation.md +++ /dev/null @@ -1,126 +0,0 @@ -# REQ-038: 레거시 buy/sell endpoint deprecated 표기 - -- Status: Implemented -- Type: Interface -- Priority: Low -- Owner: Justin Ko - -## Intent -- 신 path(`POST /v1/portfolios/{id}/ledger/trades`)가 portfolio 회계의 단일 권위 endpoint로 단일화됨에 따라(REQ-037), 레거시 거래 endpoint `POST /portfolios/{id}/buy`·`/sell`을 deprecated로 표기해 클라이언트에 폐지 예정을 통지한다(dcy-006 OQ-5). -- 본 REQ는 표기만 다루며 실제 endpoint 제거 + CLI 호출자 전환은 후속 정리 batch/Discovery에서 처리한다. - -## Requirement -- `POST /portfolios/{id}/buy`, `POST /portfolios/{id}/sell` 라우트([app/api/portfolios.py:158,176](../../app/api/portfolios.py#L158))에 deprecated 표기를 추가한다: - - FastAPI route에 `deprecated=True`를 지정해 OpenAPI 문서/`/docs`에 deprecated로 노출. - - 응답 헤더 `Deprecation: true` 추가(가능하면 `Sunset` 또는 안내 link 헤더 동반은 선택). -- endpoint 동작 자체는 변경하지 않는다(회계 거부 정책 + portfolio_ledger 기록 유지). REQ-036 portfolio_ledger 스키마 변경 하에서도 레거시 흐름이 정상 동작하는지 회귀로 확인한다(레거시 write는 order_id/execution_id NULL append-only). -- CLI(`pfhub portfolio buy/sell`) 동작은 변경하지 않는다(호출자 전환은 후속). - -## Acceptance Criteria -1. Given `/docs`(OpenAPI), When `POST /portfolios/{id}/buy`·`/sell` 스펙 확인, Then deprecated=true로 표시된다. -2. Given `POST /portfolios/{id}/buy` 정상 호출, When 응답 확인, Then HTTP 200(기존 동작 보존) + 응답 헤더에 `Deprecation: true` 포함. -3. Given REQ-036 portfolio_ledger 신 스키마 적용 후, When 레거시 buy/sell 호출, Then portfolio_ledger에 order_id/execution_id NULL + side 매핑된 row가 정상 append되고 회계 거부 정책(잔고 부족 시 거부)이 유지된다. -4. Given codebase grep `grep -n "deprecated=True" app/api/portfolios.py`, When 본 REQ 구현 후, Then buy/sell 라우트에 매치된다. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — `POST /portfolios/{id}/buy`·`/sell` deprecated 표기 + 신 path로의 이전 안내 -- Modules: - - `app/api/portfolios.py:158,176` — 라우트 `deprecated=True` + 응답 헤더 `Deprecation: true` -- Tests: - - 갱신/신규 `tests/test_portfolios.py` 또는 해당 — AC-1~3 검증(deprecated 표기 + 동작 보존 + 레거시 append) - -## Notes -- 본 REQ는 dcy-006 FR-8 산출물. -- 실제 endpoint 제거는 본 REQ 범위 밖(dcy-006 Out of Scope). 후속 정리 batch 또는 별도 Discovery. -- REQ-036(portfolio_ledger 스키마 변경) 선행 — 레거시 흐름이 신 스키마에서 동작 보존되는지 AC-3로 확인. -- 별도 REL(REL-003 등)로 분리 배포(dcy-006 A-4). -- Source: dcy-006 FR-8, OQ-5. - -## Change Log - -### CHG-20260605-03 -- Date: 2026-06-05 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-016 confirm-batch-verification 완료. 레거시 buy/sell `deprecated=True` + `Deprecation: true` 헤더 구현, 동작 보존. AC-1~4 검증(`tests/test_auth.py::test_legacy_buy_sell_routes_emit_deprecation_header` 포함). 전체 376 passed. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-016 (release-candidate) -- Releases: - - REL-003 (예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: 실제 endpoint 제거는 후속 정리 트랙 - -### CHG-20260605-02 -- Date: 2026-06-05 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드 모두 충족, AC-1~4 구체성(OpenAPI deprecated + 헤더 + 레거시 append 보존) 확인 완료, blocker 없음. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-003 등 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입; 실제 endpoint 제거는 후속 트랙 - -### CHG-20260605-01 -- Date: 2026-06-05 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-006 confirm-discovery 후 draft-req 실행. dcy-006 FR-8 산출물. - -#### Intent -- 레거시 buy/sell endpoint deprecated 표기로 신 path 단일화 통지. - -#### Acceptance Criteria Delta -- Added: - - AC-1~4 최초 작성 - -#### Impacted Area Delta -- Docs: docs/interface-contract.md — 최초 지정 -- Modules: app/api/portfolios.py — 최초 지정 -- Tests: tests/test_portfolios.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-003 등 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; 실제 endpoint 제거는 후속 트랙 - -#### Validation Plan -- pytest로 AC-1~4 검증(OpenAPI deprecated + 헤더 + 레거시 append 보존) diff --git a/docs/srs/Interface/req-039_trade-history-query-endpoint.md b/docs/srs/Interface/req-039_trade-history-query-endpoint.md deleted file mode 100644 index 2f95452..0000000 --- a/docs/srs/Interface/req-039_trade-history-query-endpoint.md +++ /dev/null @@ -1,96 +0,0 @@ -# REQ-039: GET /v1/portfolios/{id}/trades 체결 내역 조회 엔드포인트 신설 - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- portfolio_ledger(TimescaleDB hypertable)에 이미 적재된 체결 기록(side, quantity, price, fee_amount, tax_amount, executed_at 등)을 날짜/범위 필터로 외부에 노출하는 GET 엔드포인트를 신설한다. -- 현재 `list_recent_trades(portfolio_id, limit=5)` 내부 메서드만 존재하며 날짜 필터 없이 최근 5건만 반환하는 한계를 해소한다. -- 운영자 및 프론트엔드가 trex_core ledger_outbox 수기 조회 없이 portfolio-hub API 단독으로 체결 내역을 조회할 수 있게 한다(dcy-007 FR-1, S-1). - -## Requirement -- 시스템은 `GET /v1/portfolios/{portfolio_id}/trades` 엔드포인트를 제공해야 한다. -- `date`(YYYY-MM-DD) 단일 날짜 파라미터 또는 `from`/`to` 날짜 범위 파라미터로 portfolio_ledger를 필터링해 체결 기록 목록을 반환해야 한다. -- 응답 각 항목은 side, quantity, price, fee_amount, tax_amount, executed_at, order_id, execution_id 필드를 포함해야 한다. -- 인증은 기존 read_token 이상 권한(DB 토큰)으로 접근 가능해야 하며, 별도 권한 추가는 없다(dcy-007 FR-4, A-3). -- 호출자 소유권 확인: 해당 portfolio_id가 호출 토큰 소유자의 portfolio인지 확인하고, 비소유자 비관리자 호출은 HTTP 403을 반환해야 한다. - -## Acceptance Criteria -1. Given read_token 이상 권한 보유 사용자, portfolio_id에 2026-06-15 체결 기록 3건 존재, When `GET /v1/portfolios/{id}/trades?date=2026-06-15`, Then HTTP 200 + items 배열에 3건 반환, 각 항목에 side/quantity/price/fee_amount/tax_amount/executed_at/order_id/execution_id 포함. -2. Given 동일 포트폴리오, 2026-06-01~2026-06-15 기간에 체결 기록 7건 존재, When `GET /v1/portfolios/{id}/trades?from=2026-06-01&to=2026-06-15`, Then HTTP 200 + items 7건 반환, from/to 범위 경계 날짜 포함. -3. Given `date` 파라미터 누락 AND `from`/`to` 모두 누락, When 호출, Then HTTP 422 + 날짜 파라미터 필요 오류 메시지 반환. -4. Given `date` 형식이 YYYY-MM-DD가 아닌 값(예: "20260615", "2026/06/15"), When 호출, Then HTTP 422 반환. -5. Given 해당 날짜 범위에 체결 기록 0건, When 호출, Then HTTP 200 + `{"items": [], "total": 0}` 반환(404 아님). -6. Given 비소유자 사용자 token(admin 아님), When 타인 portfolio_id 조회, Then HTTP 403 반환. -7. Given 존재하지 않는 portfolio_id, When 호출, Then HTTP 404 반환. -8. Given 엔드포인트 구현 완료, When OpenAPI `/docs` 또는 `openapi.json` 확인, Then `GET /v1/portfolios/{portfolio_id}/trades` 경로가 파라미터 설명과 함께 문서화되어 있음(NFR-2 반영). -9. Given 30일 범위(`from`=D-30, `to`=today) 쿼리, When portfolio_ledger idx_portfolio_ledger_portfolio_executed 인덱스 사용 환경에서 호출, Then p95 응답 시간 500ms 이하(NFR-1 반영 — 통합/성능 테스트 환경에서 검증). - -## Impacted Area -- Docs: - - `docs/runtime-flows.md` — `GET /v1/portfolios/{id}/trades` 흐름 추가 필요 - - `docs/project-structure.md` — app/api/v1_portfolios.py 책임 갱신, app/schemas/v1.py TradeHistoryResponse 추가 -- Modules: - - `app/api/v1_portfolios.py` — GET 엔드포인트 추가(날짜 파라미터 파싱, 소유권 확인, repository 위임) - - `app/repositories/postgres.py` — `list_trades_by_date(portfolio_id, date, from_, to_)` 메서드 추가 - - `app/repositories/portfolio_repository.py` — abstract method 추가 - - `app/schemas/v1.py` — `TradeHistoryItem`, `TradeHistoryResponse` 스키마 추가 -- Tests: - - `tests/test_v1_portfolios_trades.py` 신규 — AC-1~9 커버 - -## Notes -- `date` vs `from`/`to` 파라미터 중 하나만 허용하거나 `date`가 `from`=`to`의 단축형으로 동작하는지 구현 단계에서 결정. 단, AC-3(둘 다 없으면 422)는 확정. -- portfolio_ledger TimescaleDB hypertable의 파티션 키는 `recorded_at`(또는 `executed_at`)이며, 날짜 범위 쿼리는 기존 `idx_portfolio_ledger_portfolio_executed` 인덱스를 활용해야 한다(NFR-1, dcy-007 R-2 완화). -- 페이지네이션 요구사항은 이번 범위에 명시되지 않음. 구현 단계에서 대용량 결과 처리 방식 결정 필요(NFR 미정 — 현재 범위 밖으로 이연). -- DB 마이그레이션 불필요 — 기존 portfolio_ledger 스키마로 조회 가능(dcy-007 영향 범위 확인). -- Source: dcy-007 FR-1, FR-4, NFR-1, NFR-2, S-1. - -## Change Log - -### CHG-20260618-01 -- Date: 2026-06-18 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-007 confirm-discovery 후 draft-req 실행. 일일 거래내역 조회 API 산출물. - -#### Intent -- dcy-007 FR-1(체결 내역 조회 엔드포인트 신설)을 구현 가능한 Interface REQ로 정규화. - -#### Acceptance Criteria Delta -- Added: - - AC-1~9 최초 작성 - -#### Impacted Area Delta -- Docs: docs/runtime-flows.md, docs/project-structure.md — 최초 지정 -- Modules: app/api/v1_portfolios.py, app/repositories/postgres.py, app/repositories/portfolio_repository.py, app/schemas/v1.py — 최초 지정 -- Tests: tests/test_v1_portfolios_trades.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입 - -#### Validation Plan -- pytest TestClient로 AC-1~8 검증; AC-9 성능은 통합/성능 테스트 환경에서 검증 - -### CHG-20260618-02 -- Date: 2026-06-18 -- Author: 팀장 (Claude) - -#### Change Summary -- confirm-req: `Proposed` → `Approved`. dcy-007 FR-1/FR-4 정합, AC 구체적·test 가능, blocker 없음. `date` vs `from`/`to` 상호작용은 구현 단계 결정(AC-3 422 확정). M1 fast-track — REQ-040~042와 동일 batch. - -### CHG-20260618-03 -- Date: 2026-06-18 -- Author: 팀장 (Claude) - -#### Change Summary -- confirm-req-implemented: `Approved` → `Implemented`. Evidence: BAT-017 release-candidate, 독립 QC PASS-WITH-NOTES, test_v1_portfolios_trades.py 12 passed(AC-1~8), 성능 AC-9는 환경제약 미검증. metrics/DB 마이그레이션 무. diff --git a/docs/srs/Interface/req-040_realized-pnl-query-endpoint.md b/docs/srs/Interface/req-040_realized-pnl-query-endpoint.md deleted file mode 100644 index fdbe0e1..0000000 --- a/docs/srs/Interface/req-040_realized-pnl-query-endpoint.md +++ /dev/null @@ -1,102 +0,0 @@ -# REQ-040: GET /v1/portfolios/{id}/pnl/realized 실현 PnL 집계 엔드포인트 신설 - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- 지정 기간 내 매도(side='sell') 체결에 대해 수수료·세금을 반영한 실현 PnL 합계 및 건별 내역을 반환하는 GET 엔드포인트를 신설한다. -- WAC(weighted average cost, 가중평균 취득단가) 방식으로 산출된 cost_basis 기반 실현 손익을 API 단일 호출로 제공하여 trex_core ledger_outbox 수기 계산 의존을 제거한다(dcy-007 FR-2, S-2). -- R-1(이력 완전성) 리스크를 완화하기 위해, cost_basis 산출 시작 시점을 응답에 포함하고 이력이 불완전할 경우 경고 필드(`data_completeness_warning`)를 응답에 포함한다(dcy-007 R-1 완화 방안). - -## Requirement -- 시스템은 `GET /v1/portfolios/{portfolio_id}/pnl/realized` 엔드포인트를 제공해야 한다. -- `from`/`to` 날짜 범위 파라미터(YYYY-MM-DD)로 집계 기간을 지정하며, 두 파라미터 모두 필수다. -- 선택적 `symbol` 파라미터로 특정 종목 필터링을 지원한다. -- 응답은 다음을 포함해야 한다: 기간 합계 실현 PnL(`total_realized_pnl`), 건별 매도 내역 배열(`items`), cost_basis 산출 시작 시점(`cost_basis_from`), 이력 불완전 경고 필드(`data_completeness_warning: str | null`). -- 건별 항목은 symbol(또는 portfolio 식별 가능 필드), executed_at, quantity, sell_price, cost_basis_per_unit, fee_amount, tax_amount, realized_pnl을 포함해야 한다. -- 인증은 기존 read_token 이상 권한(DB 토큰)으로 접근 가능해야 하며, 별도 권한 추가는 없다(dcy-007 FR-4, A-3). -- 호출자 소유권 확인: 비소유자 비관리자 호출은 HTTP 403을 반환해야 한다. -- PnL 산출은 REQ-041(WAC 산출 로직)에 위임하며, 이 엔드포인트는 service 계층을 통해 산출 결과를 수신하고 HTTP 응답으로 직렬화한다. - -## Acceptance Criteria -1. Given read_token 이상 권한, portfolio에 buy 100주@10,000 + buy 100주@12,000 이력 존재 후 sell 50주@15,000 fee=500 tax=200 실행, When `GET /v1/portfolios/{id}/pnl/realized?from=2026-06-01&to=2026-06-30`, Then HTTP 200 + `total_realized_pnl`이 WAC 공식((15,000 - 11,000) × 50 - 500 - 200 = 199,300)과 일치 + `items` 배열에 해당 매도 1건 포함 + 각 항목에 cost_basis_per_unit=11,000 포함(WAC = (100×10,000 + 100×12,000) / 200 = 11,000). -2. Given 동일 포트폴리오, When `GET /v1/portfolios/{id}/pnl/realized?from=2026-06-01&to=2026-06-30&symbol=005930.KS`, Then symbol 필터가 적용되어 해당 symbol의 매도 건만 집계. -3. Given `from` 파라미터 누락 또는 `to` 파라미터 누락, When 호출, Then HTTP 422 반환. -4. Given 지정 기간에 매도 체결 0건, When 호출, Then HTTP 200 + `total_realized_pnl=0` + `items=[]` 반환(404 아님). -5. Given portfolio_ledger 이력이 포트폴리오 생성 초기부터 불완전한 경우(buy 이력 일부 누락 감지), When 호출, Then 응답 `data_completeness_warning` 필드에 경고 메시지 포함 + `cost_basis_from`에 집계 시작 시점 명시(R-1 완화 반영). -6. Given portfolio_ledger 이력이 완전한 경우, When 호출, Then 응답 `data_completeness_warning` 필드가 null. -7. Given 비소유자 사용자 token(admin 아님), When 타인 portfolio_id 조회, Then HTTP 403 반환. -8. Given 존재하지 않는 portfolio_id, When 호출, Then HTTP 404 반환. -9. Given 엔드포인트 구현 완료, When OpenAPI `/docs` 또는 `openapi.json` 확인, Then `GET /v1/portfolios/{portfolio_id}/pnl/realized` 경로가 파라미터 설명 및 응답 스키마(`data_completeness_warning` 포함)와 함께 문서화되어 있음(NFR-2 반영). -10. Given 30일 범위 쿼리, When portfolio_ledger 인덱스 활용 환경에서 호출, Then p95 응답 시간 500ms 이하(NFR-1 반영 — 통합/성능 테스트 환경에서 검증). - -## Impacted Area -- Docs: - - `docs/runtime-flows.md` — `GET /v1/portfolios/{id}/pnl/realized` 흐름 추가 필요 - - `docs/project-structure.md` — app/api/v1_portfolios.py 책임 갱신, app/schemas/v1.py RealizedPnlResponse 추가 -- Modules: - - `app/api/v1_portfolios.py` — GET 엔드포인트 추가(날짜/symbol 파라미터 파싱, 소유권 확인, service 계층 위임) - - `app/services/portfolio_service.py` 또는 신규 — REQ-041 WAC 산출 로직 호출 + data_completeness_warning 판단 - - `app/repositories/postgres.py` — `aggregate_realized_pnl(portfolio_id, from_, to_, symbol)` 메서드 추가 (매도 이력 + WAC 산출용 buy 이력 조회) - - `app/repositories/portfolio_repository.py` — abstract method 추가 - - `app/schemas/v1.py` — `RealizedPnlItem`, `RealizedPnlResponse` 스키마 추가(`data_completeness_warning: str | None`, `cost_basis_from: date | None` 포함) -- Tests: - - `tests/test_v1_portfolios_pnl.py` 신규 — AC-1~10 커버 - -## Notes -- AC-1의 수치 근거: WAC = (100×10,000 + 0 + 100×12,000 + 0) / 200 = 11,000(buy fee=0이므로 기존과 동일). 실현 PnL = (15,000 - 11,000) × 50 - 500 - 200 = 200,000 - 700 = 199,300. -- **WAC 정의 변경(2026-06-26)**: REQ-041 CHG-20260626-01에 따라 WAC 공식에 매수 수수료가 편입됨. `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. 이 엔드포인트가 반환하는 `cost_basis_per_unit` 및 `realized_pnl`은 해당 정의를 따른다. AC-1 수치는 buy fee=0인 케이스라 기댓값 불변. -- `data_completeness_warning` 트리거 조건(이력 불완전 감지 기준)은 REQ-041 구현 단계에서 구체화. 현재 가정: portfolio 생성 시점보다 이른 buy 이력이 없거나, cost_basis 산출 대상 buy 합산 수량이 매도 수량보다 적을 경우. -- 미실현 PnL(현재가 연동)은 Out of Scope(dcy-007 범위 경계 확인). -- 전체 portfolio 집계 PnL 뷰(OQ-2 결정: Out of Scope) — 단일 portfolio 단위만. -- Source: dcy-007 FR-2, FR-3, FR-4, NFR-1, NFR-2, R-1, S-2. - -## Change Log - -### CHG-20260618-01 -- Date: 2026-06-18 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-007 confirm-discovery 후 draft-req 실행. 실현 PnL 집계 API 산출물. - -#### Intent -- dcy-007 FR-2/FR-4(실현 PnL 엔드포인트 신설 + read_token 인증)를 구현 가능한 Interface REQ로 정규화. R-1 완화 방안(경고 필드 + cost_basis_from)을 AC-5/AC-6에 반드시 반영. - -#### Acceptance Criteria Delta -- Added: - - AC-1~10 최초 작성 - -#### Impacted Area Delta -- Docs: docs/runtime-flows.md, docs/project-structure.md — 최초 지정 -- Modules: app/api/v1_portfolios.py, app/services/portfolio_service.py, app/repositories/postgres.py, app/repositories/portfolio_repository.py, app/schemas/v1.py — 최초 지정 -- Tests: tests/test_v1_portfolios_pnl.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-041 의존성 확인 - -#### Validation Plan -- pytest TestClient로 AC-1~9 검증; AC-10 성능은 통합/성능 테스트 환경에서 검증 - -### CHG-20260618-02 -- Date: 2026-06-18 -- Author: 팀장 (Claude) - -#### Change Summary -- confirm-req: `Proposed` → `Approved`. WAC 수치 AC-1(199,300)·R-1 경고 필드 AC-5/AC-6 확인, OQ-2 OoS 반영, blocker 없음. REQ-041 의존(동일 batch). M1 fast-track. - -### CHG-20260618-03 -- Date: 2026-06-18 -- Author: 팀장 (Claude) - -#### Change Summary -- confirm-req-implemented: `Approved` → `Implemented`. Evidence: BAT-017 release-candidate, 독립 QC PASS-WITH-NOTES, WAC 199,300 단위+e2e 검증, R-1 경고필드 정상, AC-1~9 PASS(AC-10 환경제약). 잔여(비차단): symbol 필터 edge 테스트 공백. diff --git a/docs/srs/Interface/req-043_holdings-cost-basis-read-endpoint.md b/docs/srs/Interface/req-043_holdings-cost-basis-read-endpoint.md deleted file mode 100644 index f04ee99..0000000 --- a/docs/srs/Interface/req-043_holdings-cost-basis-read-endpoint.md +++ /dev/null @@ -1,160 +0,0 @@ -# REQ-043: GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단(WAC) read 엔드포인트 신설 - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- 지정 포트폴리오의 **현재 미매도 보유분에 대한 WAC(weighted average cost, 가중평균 취득단가)** 와 보유수량을 반환하는 GET 엔드포인트를 신설한다. -- trex-core dcy-034 cold-start onboarding(FR-7)에서, 실계좌에 이미 보유 중인 종목을 슬롯에 등록할 때 슬롯의 평단(cost basis)을 seed하기 위해 이 엔드포인트를 소비한다. -- WAC 산출은 기존 `calculate_realized_pnl`의 running_wac 로직(REQ-041)을 재사용하며, 데이터 불완전 시 `data_completeness_warning` 경고 패턴(REQ-040 관례)도 동일 적용한다. -- 이 엔드포인트는 **trex-core dcy-034 FR-7의 선행 의존**이다. trex-core cold-start가 이 엔드포인트의 구현 완료 전에는 평단 seed를 수행할 수 없다. - -## Requirement -- 시스템은 `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` 엔드포인트를 제공해야 한다. -- 응답은 다음 필드를 포함해야 한다: - - `portfolio_id` (str): 요청한 포트폴리오 ID - - `symbol` (str | None): 포트폴리오에 연결된 종목 심볼 - - `current_quantity` (float): 현재 보유수량 (portfolios.asset_quantity 기준) - - `cost_basis_per_unit` (float | None): WAC로 산출한 현재 평단 (보유수량이 0인 경우 None) - - `total_cost_basis` (float | None): cost_basis_per_unit × current_quantity (보유수량이 0인 경우 None) - - `cost_basis_from` (datetime | None): WAC 산출에 사용된 최초 buy 기록 시점 (REQ-041 관례) - - `data_completeness_warning` (str | None): 이력 불완전 감지 시 경고 문자열, 없으면 None (REQ-040/041 관례) -- WAC 산출 방식: 해당 portfolio_id의 전체 buy/sell 원장 이력(portfolio_ledger)을 시간 순으로 replay하여 현재 running_wac를 도출한다. 이는 `calculate_realized_pnl` 함수(app/domain/pnl.py)의 WAC 누적 로직을 재사용하거나, 동일 규칙을 확장한 신규 domain 함수로 구현한다. -- 인증/역할: `require_read` 의존성(Permission.READ 이상) 적용. **service 토큰(`token_type="service"`)도 허용한다** — trex-core daemon이 DB service token으로 호출한다(OQ-1 참조). -- 소유권 확인: admin이 아닌 user token 호출자는 자신이 소유한 portfolio만 조회 가능(비소유자 HTTP 403). service token(daemon)은 자신의 daemon_id가 `portfolio.managed_by_daemon_id`와 일치하는 portfolio만 조회 가능(불일치 HTTP 403). -- 존재하지 않는 portfolio_id 요청은 HTTP 404를 반환해야 한다. -- 보유수량이 0인 portfolio를 요청한 경우 HTTP 200 + `current_quantity=0.0`, `cost_basis_per_unit=null`, `total_cost_basis=null`을 반환한다(404 아님). - -## Acceptance Criteria -1. Given service token(daemon_id=D, portfolio.managed_by_daemon_id=D)으로 인증, portfolio에 buy 100주@10,000 + buy 100주@12,000 이력 존재(sell 없음), When `GET /v1/portfolios/{id}/holdings/cost-basis`, Then HTTP 200 + `current_quantity=200.0` + `cost_basis_per_unit=11000.0` (WAC = (100×10,000 + 100×12,000) / 200) + `total_cost_basis=2,200,000.0` + `data_completeness_warning=null`. -2. Given service token(daemon_id=D, portfolio.managed_by_daemon_id=D)으로 인증, portfolio에 buy 200주@10,000 후 sell 150주 이력 존재, When 호출, Then HTTP 200 + `current_quantity=50.0` + `cost_basis_per_unit=10000.0` (WAC 불변) + `total_cost_basis=500,000.0`. -3. Given user read_token(portfolio 소유자), When 호출, Then HTTP 200 + 정상 응답. -4. Given user read_token(비소유자, admin 아님), When 타인 portfolio_id 조회, Then HTTP 403 반환. -5. Given service token(daemon_id=D1, portfolio.managed_by_daemon_id=D2, D1≠D2), When 호출, Then HTTP 403 반환. -6. Given service token(managed_by_daemon_id=null인 portfolio), When 호출, Then HTTP 403 반환 (daemon 미배정 portfolio에 daemon 접근 불가). -7. Given 존재하지 않는 portfolio_id, When 호출, Then HTTP 404 반환. -8. Given portfolio에 buy/sell 이력이 전혀 없음(current_quantity=0), When 호출, Then HTTP 200 + `current_quantity=0.0` + `cost_basis_per_unit=null` + `total_cost_basis=null` + `data_completeness_warning=null`. -9. Given portfolio에 buy 이력 없이 sell 기록이 존재하는 이력 불완전 상황, When 호출, Then HTTP 200 + `data_completeness_warning` 필드에 비어 있지 않은 경고 문자열 포함. -10. Given 인증 토큰 없음, When 호출, Then HTTP 401 반환. -11. Given 구현 완료, When OpenAPI `/docs` 또는 `openapi.json` 확인, Then `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` 경로가 응답 스키마(`data_completeness_warning`, `cost_basis_per_unit`, `total_cost_basis` 포함)와 함께 문서화되어 있음. - -## Impacted Area -- Docs: - - `docs/runtime-flows.md` — `GET /v1/portfolios/{id}/holdings/cost-basis` 흐름 추가 - - `docs/project-structure.md` — app/api/v1_portfolios.py 및 app/schemas/v1.py 책임 갱신 -- Modules: - - `app/api/v1_portfolios.py` — GET 엔드포인트 추가 (소유권 확인 분기: user token vs service token) - - `app/domain/pnl.py` — WAC-only(매도 결과 수집 없이 running_wac만 반환하는) 신규 함수 추가, 또는 `calculate_realized_pnl` 반환값에서 running_wac 직접 추출하도록 확장 (구현 단계에서 결정) - - `app/repositories/postgres.py` — `get_all_trades_for_pnl` 재사용 (신규 SQL 메서드 불필요할 수 있음 — 구현 단계 확인) - - `app/repositories/portfolio_repository.py` — 신규 abstract method 추가 여부 구현 단계 판단 - - `app/schemas/v1.py` — `HoldingsCostBasisResponse` 스키마 신규 추가 -- Tests: - - `tests/test_v1_portfolios_holdings.py` 신규 — AC-1~11 커버 - -## Notes -- **근거 Discovery**: [dcy-008_20260625_current-holding-wac-cost-basis-read](../../discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md) — 사장님 confirmed 2026-06-25. OQ-1/OQ-2/OQ-3 전부 Answered. 본 REQ는 dcy-008의 파생 요구사항이며 양방향 링크(dcy-008 "생성된 REQ 참조: REQ-043" ↔ 본 REQ "근거 Discovery: dcy-008"). -- **선행 의존(upstream blocker)**: trex-core dcy-034 FR-7. 이 REQ가 Implemented가 되기 전에 dcy-034 구현이 완료될 수 없다. batch 계획 시 trex-core 팀에 의존 관계 고지 필요. -- **OQ-1(service token 소유권 확인 정책) — 해소(팀장 confirm-req, 2026-06-25)**: service token 소유권 정책을 `daemon_id == portfolio.managed_by_daemon_id` 일치 시만 허용, 불일치/null은 HTTP 403으로 **확정**한다. 해소 근거: trex-core cold-start는 `snapshot_scheduler.run_once`에서 `whoami → get_portfolios(daemon_id)` 순으로 이미 자기 daemon에 배정된 포트폴리오만 조회한다. 그 포트폴리오들은 `managed_by_daemon_id == 호출 daemon`이 성립하므로 소유권 정책(REQ-029 선례)이 그대로 적용된다. `managed_by_daemon_id=null` 미배정 케이스는 cold-start 경로에 없어 blocker 아님. 구현 시 추가 완화 불필요. -- **OQ-2(domain 함수 확장 vs 신규) — 구현 단계 위임(팀장 confirm-req, 2026-06-25)**: `calculate_current_wac` 신규 함수 분리(단일책임 원칙) 를 **권고하되**, 구현 옵션((a) 기존 함수 파라미터 확장 vs (b) 신규 함수 분리) 선택은 **구현 단계(design/implementation) 재량으로 위임**한다. REQ에서 강제하지 않는다. domain 계층 순수성 유지는 필수. -- **current_quantity 출처**: `portfolios.asset_quantity`(테이블 컬럼)를 직접 읽는다. 이 값은 ledger_trade 기록 시 `asset_quantity_after`로 동기화되므로 신뢰 가능하다. WAC는 ledger replay로 독립 계산하므로, 두 값의 수량 불일치 시 `data_completeness_warning` 조건이 트리거된다. -- **GET /pnl/realized와의 차이**: REQ-040은 지정 기간 내 **매도 완료된** 실현 손익을 반환. 이 REQ는 **현재 보유 중인** 미매도분의 현재 평단을 반환. 개념적으로 보완 관계이며 상호 독립적이다. -- **WAC 정의 변경(2026-06-26)**: REQ-041 CHG-20260626-01에 따라 WAC 공식에 매수 수수료가 편입됨. `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. `calculate_current_wac` 함수도 동일 정의를 따르므로 이 엔드포인트의 `cost_basis_per_unit` 역시 매수 fee 포함값이다. AC-1/AC-2의 기댓값은 buy fee=0인 케이스라 불변. -- Source: trex-core dcy-034 FR-7 (cold-start cost basis seed 선행 의존). 근거 Discovery: dcy-008 (portfolio-hub측 정식화). - -## Change Log - -### CHG-20260626-01 -- Date: 2026-06-26 -- Author: Justin Ko (portfolio-hub-dev) - -#### Change Summary -- 상태 `Approved` → `Implemented` — BAT-018 구현 완료, PR #14 squash-merge(d93c0bc) 후 팀장 QC PASS 및 origin/main 머지 확인. - -#### Intent -- BAT-018(batch-lite) 구현·검증 완료: AC-1~11 전부 PASS, 신규 테스트 20개 + 기존 회귀 427개 0 실패(총 447 passed). `calculate_current_wac` 신규 domain 함수, `HoldingsCostBasisResponse` 스키마, GET 엔드포인트 additive 추가. 배포는 별도(미배포 — release-candidate 상태). - -#### Acceptance Criteria Delta -- No change (AC-1~11 유지, 전부 검증 완료). - -#### Impacted Area Delta -- No change. - -#### Delivery Trace Delta -- Batches: BAT-018 → release-candidate -- Releases: none (미배포) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: Implemented -- Follow-up Action: 배포 시 release 문서 작성. - -#### Validation Plan -- BAT-018 verification.md 참조 (AC-1~11 PASS, 447 passed / 0 failed). - ---- - -### CHG-20260625-02 -- Date: 2026-06-25 -- Author: Justin Ko (portfolio-hub-dev) - -#### Change Summary -- 팀장 confirm-req 결정 반영 — 상태 `Proposed` → `Approved`. - -#### Intent -- dcy-008 근거 Discovery 양방향 링크 명시. OQ-1(service token 소유권 blocker) 해소 확정(trex-core cold-start 경로 분석으로 `managed_by_daemon_id=null` 케이스 cold-start 경로에 없음 확인, daemon scope 소유권 정책 유지). OQ-2(domain 함수) 구현 단계 재량 위임 확정. - -#### Acceptance Criteria Delta -- No change (AC-1~11 유지). - -#### Impacted Area Delta -- No change. - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: Approved -- Follow-up Action: suggest-batch-reqs → batch 계획 편입. - -#### Validation Plan -- 이전 CHG-20260625-01 validation plan 유지 (pytest TestClient AC-1~10, AC-11 OpenAPI 확인). - ---- - -### CHG-20260625-01 -- Date: 2026-06-25 -- Author: Justin Ko (portfolio-hub-dev) - -#### Change Summary -- 최초 생성 — trex-core dcy-034 cold-start onboarding(FR-7) 선행 의존으로 draft-req 실행. 현재 미매도 보유 평단(WAC) read 엔드포인트 신설. - -#### Intent -- trex-core가 실계좌 보유 종목을 슬롯 onboarding 시 portfolio-hub에서 평단을 seed받기 위한 Interface REQ 신설. REQ-040/041의 WAC·경고 패턴 재사용. service token 소유권 정책은 REQ-029 선례 따름. - -#### Acceptance Criteria Delta -- Added: - - AC-1~11 최초 작성 - -#### Impacted Area Delta -- Docs: docs/runtime-flows.md, docs/project-structure.md — 최초 지정 -- Modules: app/api/v1_portfolios.py, app/domain/pnl.py, app/repositories/postgres.py, app/schemas/v1.py — 최초 지정 -- Tests: tests/test_v1_portfolios_holdings.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입. OQ-1(service token 정책) 팀장 확인 필요. dcy-034 trex-core 팀 의존 관계 고지 필요. - -#### Validation Plan -- pytest TestClient로 AC-1~10 검증(DB 불필요 단위 + mock repo). AC-11 OpenAPI 확인. diff --git a/docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md b/docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md deleted file mode 100644 index 154f8af..0000000 --- a/docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md +++ /dev/null @@ -1,119 +0,0 @@ -# REQ-044: GET /v1/assets 권한 완화 — require_admin → require_read - -- Status: Implemented -- Approved By: 팀장 (Claude), 2026-06-27 (confirm-req) -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- `GET /v1/assets`(asset 카탈로그 목록 조회) 엔드포인트의 의존성을 `require_admin`에서 `require_read`로 완화해, 일반 인증 사용자가 active asset 카탈로그를 조회할 수 있도록 한다. -- trex-web 포트폴리오 관리 UI(dcy-007)에서 포트폴리오 생성·변경 시 종목 선택 드롭다운/검색이 이 엔드포인트를 소비한다. 현재 `require_admin`으로 막혀 있어 일반 사용자가 접근 불가한 상태다. -- asset 쓰기(POST 생성, PATCH 수정/비활성, POST/DELETE 태그 적용/해제)는 `require_admin`을 유지한다 — 이번 완화는 읽기 하나에 한정한다. - -## Requirement -- 시스템은 `GET /v1/assets` 엔드포인트의 인증 의존성을 `require_admin`에서 `require_read`로 변경해야 한다. -- 기존 동작(status 필터·market 필터·`AssetCatalogListResponse` 응답 스키마)은 변경 없이 유지해야 한다. -- asset 쓰기 엔드포인트(`POST /v1/assets`, `PATCH /v1/assets/{asset_id}`, `PATCH /v1/assets/{asset_id}/deactivate`, `POST /v1/assets/{asset_id}/tags`, `DELETE /v1/assets/{asset_id}/tags/{tag_id}`)는 `require_admin`을 유지해야 한다. -- 유효한 read 권한 이상 토큰(session, read service token, admin)으로 `GET /v1/assets`를 호출하면 HTTP 200과 카탈로그 목록을 반환해야 한다. -- 인증 토큰 없는 요청은 HTTP 401, 권한이 없는 토큰은 기존 동작대로 HTTP 403을 반환해야 한다. - -## Acceptance Criteria -1. Given read 권한 세션 토큰(`permission="read"`)을 소지한 사용자, When `GET /v1/assets`를 호출, Then HTTP 200 + `AssetCatalogListResponse` 반환. -2. Given admin 토큰, When `GET /v1/assets`를 호출, Then 기존과 동일하게 HTTP 200 반환(회귀 없음). -3. Given read 권한 사용자, When `GET /v1/assets?status=active`·`?status=inactive`·`?status=all`로 호출, Then status 필터가 적용된 응답 반환(필터 동작 유지). -4. Given read 권한 사용자, When `GET /v1/assets?market=KRX`로 호출, Then market 필터가 적용된 응답 반환(필터 동작 유지). -5. Given read 권한 사용자, When `POST /v1/assets`(asset 생성)를 호출, Then HTTP 403(쓰기는 admin 유지). -6. Given read 권한 사용자, When `PATCH /v1/assets/{asset_id}/deactivate`를 호출, Then HTTP 403. -7. Given read 권한 사용자, When `POST /v1/assets/{asset_id}/tags`를 호출, Then HTTP 403. -8. Given read 권한 사용자, When `DELETE /v1/assets/{asset_id}/tags/{tag_id}`를 호출, Then HTTP 403. -9. Given 인증 토큰 없는 요청, When `GET /v1/assets`를 호출, Then HTTP 401. - -## Impacted Area -- Docs: `docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md` (신규) -- Modules: - - `app/api/v1_assets.py`: `list_catalog_assets` 함수의 `auth: AuthContext = Depends(require_admin)` → `Depends(require_read)` 변경. import에 `require_read` 추가. -- Tests: 기존 `tests/`의 `GET /v1/assets` 관련 테스트에서 read 토큰으로 200 반환하는 케이스 추가. 쓰기 엔드포인트 403 회귀 확인. - -## Notes -- asset 카탈로그(거래 가능 종목 메타 — symbol·display_name·markets·status·태그)는 전 인증 사용자에게 read 공개해도 무방하다고 사장님이 확인(dcy-009 OQ-1 Answered 2026-06-27). -- 코드 변경 범위는 `app/api/v1_assets.py` 단일 파일의 `Depends(require_admin)` → `Depends(require_read)` 한 줄이다. 응답 로직·필터·스키마는 변경 없다. -- `require_read`는 `app/core/auth.py`에 이미 정의(`require_read = permission_dependency(Permission.READ)`)되어 있다. -- NFR-1(기존 admin asset 관리·토큰 daemon 바인딩 회귀 0)은 AC-2, AC-5~8의 쓰기 403 확인으로 흡수한다. 별도 NFR REQ 미생성. -- NFR-2(데몬 목록 caller 스코프)는 REQ-045에서 다룬다. - -## Change Log - -### CHG-20260627-02 -- Date: 2026-06-27 -- Author: 팀장 (Claude) - -#### Change Summary -- 상태 `Approved` → `Implemented` (confirm-req-implemented). - -#### Intent -- BAT-019 PR #16이 main(`7f15c6b`)으로 머지 완료됨에 따라 구현·검증 완료를 공식화한다. - -#### Acceptance Criteria Delta -- Added: 없음 -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: 상태 전환 기록만. -- Modules: 변경 없음. -- Tests: 변경 없음. - -#### Delivery Trace Delta -- Batches: BAT-019 -- PR: #16 (main 머지, HEAD `7f15c6b`) -- QC: PASS (독립 qc-reviewer) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: Implemented -- Follow-up Action: 없음 - -#### Validation Plan -- BAT-019 verification.md Evidence — 472 passed, 0 failed (2026-06-27). AC-1~9 전부 PASS. - ---- - -### CHG-20260627-01 -- Date: 2026-06-27 -- Author: Justin Ko - -#### Change Summary -- 신규 생성 (Proposed) — dcy-009 FR-1 구현을 위한 첫 번째 REQ 초안. - -#### Intent -- dcy-009 confirmed Discovery의 FR-1을 REQ로 정규화한다. 코드·테스트 구현의 기준 문서로 사용. - -#### Acceptance Criteria Delta -- Added: - - AC-1 ~ AC-9 전체 (신규 초안) -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: - - `docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md` 신규 생성 -- Modules: - - `app/api/v1_assets.py` (require_admin → require_read 1행) -- Tests: - - read 권한 GET 200 케이스 추가, 쓰기 403 회귀 케이스 확인 - -#### Delivery Trace Delta -- Batches: 미정 (suggest-batch-reqs 대기) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 배정 - -#### Validation Plan -- pytest로 read 토큰 GET /v1/assets 200 확인, admin 쓰기 403 회귀 확인. diff --git a/docs/srs/Interface/req-045_user-daemon-list-endpoint.md b/docs/srs/Interface/req-045_user-daemon-list-endpoint.md deleted file mode 100644 index 328e7e4..0000000 --- a/docs/srs/Interface/req-045_user-daemon-list-endpoint.md +++ /dev/null @@ -1,124 +0,0 @@ -# REQ-045: 사용자 가용 데몬 목록 read 엔드포인트 신설 - -- Status: Implemented -- Approved By: 팀장 (Claude), 2026-06-27 (confirm-req) -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- caller(인증 사용자)가 자신이 소유한 데몬 목록을 조회할 수 있는 HTTP 엔드포인트를 신설한다. -- trex-web 포트폴리오 관리 UI(dcy-007)에서 포트폴리오를 생성하거나 관리 데몬(`managed_by_daemon_id`)을 지정할 때, 사용자가 선택 가능한 데몬 후보를 이 엔드포인트로 불러온다. -- 현재 도메인 `Daemon` 모델과 `list_daemons_for_user(user_id)` 서비스/리포지토리가 존재하나 HTTP 라우트로 노출되지 않는 상태다. 이 REQ는 그 노출 경로를 추가한다. - -## Requirement -- 시스템은 caller(인증 사용자) 소유 데몬 목록을 반환하는 GET 엔드포인트를 `require_read` 권한으로 제공해야 한다. -- 응답은 caller(`auth.user_id`) 소유 데몬만 포함해야 한다 — 타 사용자 데몬은 절대 반환하지 않는다. -- 각 응답 항목은 다음 필드를 포함해야 한다: `id`(str), `daemon_slug`(str), `display_name`(str | None), `is_active`(bool). -- 엔드포인트 경로와 응답 스키마(`DaemonResponse`) 구체 형태는 draft-batch-design에서 기존 라우팅 컨벤션에 맞춰 확정한다(OQ-2 Deferred). -- 인증 토큰 없는 요청은 HTTP 401을 반환해야 한다. -- 해당 사용자에게 데몬이 없는 경우 빈 목록(`[]`)으로 HTTP 200을 반환해야 한다(404 아님). -- 기존 서비스 메서드 `find_candidate_daemons_for_user(user_id)`(`AuthService`)를 활용한다. 이 메서드는 내부적으로 `repository.list_daemons_for_user(user_id)`를 호출한다. - -## Acceptance Criteria -1. Given read 권한 토큰을 소지한 사용자 A(데몬 2개 소유), When 데몬 목록 엔드포인트를 호출, Then HTTP 200 + 사용자 A의 데몬 2개만 포함된 목록 반환. -2. Given read 권한 토큰을 소지한 사용자 A, When 데몬 목록 엔드포인트를 호출, Then 응답 항목이 `id`, `daemon_slug`, `display_name`, `is_active` 필드를 모두 포함. -3. Given read 권한 토큰을 소지한 사용자 A, When 데몬 목록 엔드포인트를 호출, Then 사용자 B의 데몬이 응답에 포함되지 않음(타 사용자 비노출). -4. Given read 권한 사용자이고 자신에게 데몬이 없을 때, When 데몬 목록 엔드포인트를 호출, Then HTTP 200 + 빈 목록(`items: []`). -5. Given 인증 토큰 없는 요청, When 데몬 목록 엔드포인트를 호출, Then HTTP 401. -6. Given admin 토큰을 소지한 사용자, When 데몬 목록 엔드포인트를 호출, Then HTTP 200 + 해당 admin 사용자 소유 데몬만 반환(admin 권한이어도 caller 스코프 적용). -7. Given read 권한 토큰으로 기존 `GET /v1/assets`(REQ-044)를 호출, Then 회귀 없음(데몬 라우터 추가가 기존 assets 라우터에 영향 없음). - -## Impacted Area -- Docs: `docs/srs/Interface/req-045_user-daemon-list-endpoint.md` (신규) -- Modules: - - 신규 라우터 파일 또는 기존 라우터에 엔드포인트 추가(경로·파일 위치는 design 확정 — OQ-2). - - `app/schemas/v1.py` 또는 별도 스키마 파일: `DaemonResponse` Pydantic 모델 신규 정의. - - `app/services/auth_service.py`: `find_candidate_daemons_for_user` 기존 활용(변경 없거나 최소). - - `app/main.py` 또는 라우터 등록: 신규 라우터 include. -- Tests: 2사용자 fixture로 caller 스코프 격리 테스트(AC-3). 빈 목록·401 케이스. 기존 assets/auth 회귀. - -## Notes -- OQ-2(엔드포인트 경로·응답 스키마 형태): design 단계에서 기존 라우팅 컨벤션(`/v1/daemons` vs `/v1/users/me/daemons` 등)을 분석해 확정. 이 REQ에서는 경로를 확정하지 않으며 design Deferred. confirm-req 시 blocker 아님 — design 단계 위임 가능. -- OQ-3("가용 데몬" 의미): 현재 기본안은 사용자 소유 데몬 전체(`find_candidate_daemons_for_user`). active 여부 필터링이 필요한지(소유 active 전체 vs 미바인딩 후보) design에서 확정. 이 REQ에서는 "caller 소유 데몬"으로만 명시하며 active 필터는 design 위임. -- env 기반 token(`token_source="env"`)은 `user_id`가 None이다. 이 경우 `auth.user_id`가 없어 caller 스코프 적용이 불가. **구현 확정(BAT-019 design/CHG-20260627-02): user_id=None이면 빈 목록 `[]` HTTP 200 반환.** 403 거부 대신 빈 목록으로 안전하게 처리하며, env토큰 호출자는 소유 데몬이 없는 것으로 간주한다. 초안의 "기본안: DB 토큰만 허용(user_id 없으면 HTTP 403)"은 폐기한다. 근거: BAT-019 design.md OQ-2 결정 + AC-4/verification evidence `test_daemons_admin_token_with_user_id_none_returns_empty` PASSED. -- NFR-1(기존 admin asset 관리·토큰 daemon 바인딩 회귀 0)은 AC-7 회귀 확인으로 흡수한다. -- NFR-2(caller 스코프 정확)는 AC-1/3/6으로 흡수한다. - -## Change Log - -### CHG-20260627-02 -- Date: 2026-06-27 -- Author: 팀장 (Claude) - -#### Change Summary -- 상태 `Approved` → `Implemented` (confirm-req-implemented) + Notes env토큰 처리 정정(change-req: "403 거부" → "빈 목록 200"). - -#### Intent -- BAT-019 PR #16이 main(`7f15c6b`)으로 머지 완료됨에 따라 구현·검증 완료를 공식화한다. -- QC가 지적한 문서(Notes: "user_id 없으면 HTTP 403")-구현(빈 목록 200) 불일치를 해소한다. - -#### Acceptance Criteria Delta -- Added: 없음 -- Updated: AC-4(데몬 없으면 빈 목록 200)는 env토큰(user_id=None) 케이스에도 적용됨을 명확히 함. AC 본문 변경 없음(이미 "해당 사용자에게 데몬이 없는 경우 빈 목록"으로 커버). Notes의 env토큰 처리 방침 기술 정정. -- Removed: Notes의 "기본안: DB 토큰만 허용(user_id 없으면 HTTP 403)" 폐기. - -#### Impacted Area Delta -- Docs: REQ-045 Notes 내 env토큰 처리 기술 정정. -- Modules: 변경 없음 (구현은 이미 빈 목록 200으로 배포됨). -- Tests: 변경 없음 (`test_daemons_admin_token_with_user_id_none_returns_empty` PASSED — 이미 커버). - -#### Delivery Trace Delta -- Batches: BAT-019 -- PR: #16 (main 머지, HEAD `7f15c6b`) -- QC: PASS (독립 qc-reviewer, env토큰 문서-구현 불일치 지적 → 이번 change-req로 해소) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no (구현은 이미 빈 목록 200, 문서만 맞춤) -- Status Recommendation: Implemented -- Follow-up Action: 없음 - -#### Validation Plan -- BAT-019 verification.md Evidence — 472 passed, 0 failed (2026-06-27). AC-1~7 전부 PASS. -- env토큰 케이스: `test_daemons_admin_token_with_user_id_none_returns_empty` PASSED. - ---- - -### CHG-20260627-01 -- Date: 2026-06-27 -- Author: Justin Ko - -#### Change Summary -- 신규 생성 (Proposed) — dcy-009 FR-2 구현을 위한 REQ 초안. - -#### Intent -- dcy-009 confirmed Discovery의 FR-2를 REQ로 정규화한다. 코드·테스트 구현의 기준 문서로 사용. - -#### Acceptance Criteria Delta -- Added: - - AC-1 ~ AC-7 전체 (신규 초안) -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: - - `docs/srs/Interface/req-045_user-daemon-list-endpoint.md` 신규 생성 -- Modules: - - 신규 라우터 파일(경로 design 확정 후), `DaemonResponse` 스키마 신규, `app/main.py` 라우터 등록 -- Tests: - - 2사용자 스코프 격리 테스트, 빈 목록·401 케이스 신규 - -#### Delivery Trace Delta -- Batches: 미정 (suggest-batch-reqs 대기) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 배정. OQ-2/OQ-3는 design 단계에서 해소. - -#### Validation Plan -- pytest로 AC-1~7 각 케이스 확인. 특히 AC-3(2사용자 fixture 사용해 타 사용자 데몬 비노출) 강제 검증. diff --git a/docs/srs/Interface/req-049_user-info-daemon-scoped-broker-delivery.md b/docs/srs/Interface/req-049_user-info-daemon-scoped-broker-delivery.md deleted file mode 100644 index 51d1dd4..0000000 --- a/docs/srs/Interface/req-049_user-info-daemon-scoped-broker-delivery.md +++ /dev/null @@ -1,124 +0,0 @@ -# REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달로 확장 - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- 기존 `GET /v1/user_info`가 `user_id` 기반으로 `brokers: list[BrokerItem]`을 반환한다. 이를 service token의 `auth.daemon_id`로 매칭된 단일 broker 인증정보를 반환하는 daemon-scoped 전달로 확장한다. -- "1 데몬 = 1 계좌" 불변식(dcy-010 A-2 confirmed)에 따라 응답을 `broker: BrokerItem | None`(단일)으로 정규화하여, 같은 user의 두 데몬이 서로 다른 KIS 계좌를 각각 수신할 수 있게 한다. -- 유일 소비자인 trex-core `hub_read.whoami()`는 `daemon_id`만 읽으며 `brokers` 필드를 참조하지 않으므로, 응답 형태 변경이 소비자에게 무영향임이 확인된 안전한 변경이다(dcy-010 OQ-2 Answered). - -## Requirement -- `GET /v1/user_info`의 응답 스키마를 다음과 같이 변경한다: - - `brokers: list[BrokerItem]` 필드를 제거하고 `broker: BrokerItem | None` 단일 필드로 대체한다. - - `broker`는 service token의 `auth.daemon_id`에 매칭된 인증정보 1건을 복호화하여 반환한다. `daemon_id`에 매칭된 인증정보가 없으면 `broker: null`을 반환한다(graceful degrade — NFR-3). -- service token에 `daemon_id`가 없는 경우(None), 엔드포인트는 HTTP 403 Forbidden을 반환한다. 기존 "daemon_id 없어도 200 반환" 동작을 `broker` 필드에 대해서는 적용하지 않으며, `daemon_id` 자체가 없으면 403으로 거부한다. -- `app/schemas/v1.py`의 `UserInfoResponse` 모델을 `broker: BrokerItem | None = None` 필드로 변경하고 `brokers: list[BrokerItem]`를 제거한다. -- `app/api/v1_user_info.py`의 엔드포인트 구현이 `auth.daemon_id`로 `list_broker_credentials(daemon_id=auth.daemon_id)` 호출 후 결과에서 단일 항목을 복호화·반환하도록 변경한다. -- 인증정보 복호화 실패(잘못된 키, 페이로드 손상)는 기존 graceful degrade 정책을 유지한다: 경고 로깅 후 `credential_payload: null`을 포함한 `BrokerItem`을 반환한다(응답을 크래시시키지 않음). - -## Acceptance Criteria -1. Given daemon_id가 바인딩된 service token으로 GET /v1/user_info 호출, 해당 daemon_id에 매칭된 broker 인증정보가 존재하면, When 호출, Then HTTP 200 + `broker` 필드에 단일 `BrokerItem`(account_ref, credential_payload, credential_version, last_rotated_at 포함) 반환. -2. Given daemon_id가 바인딩된 service token으로 호출, 해당 daemon_id에 매칭된 인증정보가 없으면, When 호출, Then HTTP 200 + `broker: null` 반환 (graceful degrade, 크래시 없음). -3. Given daemon_id가 없는(None) service token으로 GET /v1/user_info 호출, When 호출, Then HTTP 403 Forbidden 반환. -4. Given 같은 user 소유의 daemon A, daemon B에 각각 다른 KIS account_ref가 등록된 상황, When daemon A의 service token으로 호출, Then `broker.account_ref`가 daemon A에 등록된 account_ref와 일치함. -5. Given 같은 상황에서 daemon B의 service token으로 호출, When 호출, Then `broker.account_ref`가 daemon B에 등록된 account_ref와 일치함 (daemon A의 계좌 반환하지 않음). -6. Given `UserInfoResponse` 응답 스키마, When 응답 JSON 확인, Then `brokers` 키가 존재하지 않고 `broker` 단일 키가 존재한다. -7. Given 인증정보 복호화 실패(잘못된 key), When daemon_id에 매칭된 인증정보가 있으나 복호화 실패, Then HTTP 200 + `broker.credential_payload: null` 반환 (경고 로그 기록, 크래시 없음). -8. Given service 토큰이 아닌 user 토큰(web 토큰)으로 호출, When 호출, Then HTTP 403 Forbidden 반환 (기존 service-token gate 유지). -9. Given `runtime_id`, `daemon_id`, `trading_enabled`, `user_id`, `username`, `permission` 필드, When GET /v1/user_info 호출, Then 이 필드들은 기존과 동일하게 반환된다 (broker 변경이 나머지 필드에 영향 없음, 회귀 없음). - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — `GET /v1/user_info` 섹션: `brokers: list` → `broker: object|null` 응답 스키마 갱신, daemon_id 없음 → 403 정책 명시. - - `docs/data-model.md` — `BrokerCredential` State Rules 갱신: 전달 경로에 daemon-scoped 언급 추가. -- Modules: - - `app/schemas/v1.py` — `UserInfoResponse` 모델: `brokers: list[BrokerItem]` → `broker: BrokerItem | None = None`. - - `app/api/v1_user_info.py` — 엔드포인트 구현: user_id 기반 list → daemon_id 기반 단일 조회, daemon_id None → 403. -- Tests: - - `GET /v1/user_info` 단위/통합 테스트 갱신: daemon_id 있음/없음, broker 있음/없음, 2데몬 격리(AC-4/5), 복호화 실패(AC-7), 회귀(AC-9). - - 기존 `brokers` 필드 참조 테스트 → `broker` 단일 필드로 변경. - -## Notes -- **파괴적 변경(breaking change) 해당 여부**: `brokers: list` → `broker: object|null`는 API 응답 스키마 변경이다. 그러나 dcy-010 OQ-2에서 유일 소비자 trex-core `hub_read.whoami()`가 `daemon_id`만 읽고 `brokers` 필드를 참조하지 않음이 확인되었으므로, **실질적 소비자 영향은 없다**. 다만 이 REQ 구현 전 trex-core 소비자 코드 재확인을 권장한다. -- daemon_id 없음 → 403 정책은 dcy-010 FR-2 및 universe 인가(REQ-028 `managed_by_daemon_id` 일치 요구)와 동일한 규율이다. -- `broker: null` graceful degrade(AC-2)는 dcy-010 NFR-3(인증정보 부재가 `daemon_id`, `trading_enabled` 필수 필드를 크래시시키지 않음)을 구현한다. `broker: null`은 그날 거래 개시 차단 신호로 소비자(trex-core)가 해석한다. 이 해석 로직은 trex-core Discovery(별도)에서 구현한다. -- OQ-3(부트스트랩 통합 — universe+인증정보 단일 응답): dcy-010 Deferred. 이 REQ는 인증정보 전달 형태만 확정하며, 부트스트랩 통합은 다루지 않는다. -- Source: dcy-010 FR-2, OQ-2 Answered, A-2, NFR-3. - -## Change Log - -### CHG-20260628-01 -- Date: 2026-06-28 -- Author: Justin Ko - -#### Change Summary -- 신규 생성 (Proposed) — dcy-010 FR-2 산출물. - -#### Intent -- dcy-010 FR-2(기존 GET /v1/user_info daemon-scoped 확장, 단일 broker 정규화)를 Interface REQ로 정규화한다. - -#### Acceptance Criteria Delta -- Added: - - AC-1 ~ AC-9 전체 (신규 초안) -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: - - `docs/srs/Interface/req-049_user-info-daemon-scoped-broker-delivery.md` 신규 생성 - - `docs/interface-contract.md`, `docs/data-model.md` 갱신 예고 -- Modules: - - `app/schemas/v1.py` (`UserInfoResponse` 변경), `app/api/v1_user_info.py` (로직 변경) -- Tests: - - user_info 엔드포인트 테스트 전면 갱신 신규 - -#### Delivery Trace Delta -- Batches: 미정 (suggest-batch-reqs 대기) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no (기존 `brokers` 필드 구현은 이 REQ 구현 시 대체됨) -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 배정. REQ-046(모델), REQ-047(스키마) 선행 의존. trex-core 소비자 코드 재확인. - -#### Validation Plan -- pytest로 AC-1~9 각 케이스 확인. 2데몬 격리(AC-4/5) 특히 중요. - -### CHG-20260628-02 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req: `Proposed` → `Approved`. dcy-010 FR-2/OQ-2/A-2/NFR-3 추적. brokers→단일 broker reshape, daemon_id 없음 403, 2데몬 격리(AC-4/5), graceful degrade(AC-2) 적절. 구현 전 trex-core 소비자 재확인 권고(Notes) 유효. blocker 없음. - -### CHG-20260628-03 -- Date: 2026-06-28 -- Author: 팀장(Claude) - -#### Change Summary -- confirm-req-implemented: `Approved` → `Implemented`. PR #17 머지(mergeCommit d4acfd4), BAT-020, QC PASS-WITH-NOTES(blocker 0), 테스트 통과. - -#### Intent -- BAT-020 delivery(PR #17 머지)에서 GET /v1/user_info daemon-scoped 단일 broker 전달 구현 및 AC-1~9 테스트 통과가 확인되어 Implemented로 전환한다. - -#### Acceptance Criteria Delta -- Added: 없음 -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- 없음 (상태 전환만) - -#### Delivery Trace Delta -- Batches: BAT-020 (PR #17, mergeCommit d4acfd4) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented - -#### Validation Plan -- BAT-020 verification 문서 및 QC PASS-WITH-NOTES 결과(blocker 0)로 AC-1~9 근거 확보됨. diff --git a/docs/srs/Interface/req-050_credential-cli-daemon-id-option.md b/docs/srs/Interface/req-050_credential-cli-daemon-id-option.md deleted file mode 100644 index 75f314a..0000000 --- a/docs/srs/Interface/req-050_credential-cli-daemon-id-option.md +++ /dev/null @@ -1,120 +0,0 @@ -# REQ-050: credential CLI --daemon-id 옵션 추가 - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- 현재 `pfhub credential set / list / remove` CLI는 `--user` 옵션만으로 인증정보를 식별한다. daemon-scoped 소유 전환 이후 운영자가 daemon_id를 지정하여 특정 데몬의 인증정보를 등록/조회/삭제할 수 있도록 `--daemon-id` 옵션을 추가한다. -- REQ-047 PK 전환 이후 `broker_credentials` PK가 `(daemon_id, broker)`로 변경되므로, 인증정보 등록/삭제 시 `daemon_id`가 필수 식별자가 된다. - -## Requirement -- `pfhub credential set` 커맨드에 `--daemon-id` 옵션(required, `--daemon-id `)을 추가한다. 이 옵션의 값을 `daemon_id`로 사용하여 `upsert_broker_credential(daemon_id=..., ...)` 호출 시 전달한다. -- `pfhub credential list` 커맨드에 `--daemon-id` 옵션(optional)을 추가한다. 값이 제공되면 해당 daemon_id의 인증정보만 반환한다. 미제공 시 `--user` 소유 전체 인증정보를 반환한다(기존 동작 유지). -- `pfhub credential remove` 커맨드에 `--daemon-id` 옵션(required)을 추가한다. 기존 `(user_id, broker)` 기반 삭제에서 `(daemon_id, broker)` 기반 삭제로 변경한다. -- `--daemon-id` 옵션 값은 daemon_slug 또는 daemon UUID 중 하나를 허용한다. 내부에서 `Daemon` 조회로 daemon_id(UUID)를 해석한다. daemon이 존재하지 않으면 오류 메시지 출력 후 exit 1. -- CLI 출력에 `daemon_id` 필드를 포함한다(기존 `user_id` 출력 유지, 추가). - -## Acceptance Criteria -1. Given `pfhub credential set --user u1 --daemon-id d1 --broker kis --account-ref "123" --app-key "k" --app-secret "s"` 실행, When 명령 성공, Then `broker_credentials`에 `daemon_id=d1.id, broker="kis"` 행이 upsert된다. -2. Given `pfhub credential list --user u1 --daemon-id d1` 실행, When 명령 성공, Then daemon_id=d1인 인증정보만 출력된다(daemon_id=d2 인증정보 미출력). -3. Given `pfhub credential list --user u1` (daemon-id 미지정) 실행, When 명령 성공, Then 해당 user의 모든 인증정보가 출력된다(기존 동작 유지). -4. Given `pfhub credential remove --user u1 --daemon-id d1 --broker kis` 실행, When 명령 성공, Then `broker_credentials`에서 `daemon_id=d1.id, broker="kis"` 행이 삭제된다. -5. Given 존재하지 않는 daemon_slug를 `--daemon-id`에 지정, When 명령 실행, Then 오류 메시지("데몬을 찾을 수 없습니다") 출력 후 exit 1. -6. Given `pfhub credential set` 실행 시 `--daemon-id` 옵션 미제공, When 명령 실행, Then 오류 메시지(`--daemon-id` required) 출력 후 exit 1. (REQ-047 PK 전환 이후 daemon_id 없이 등록 불가) -7. Given `pfhub credential set` 실행 성공, When CLI 출력 확인, Then `daemon_id`, `broker`, `account_ref`, `credential_version`, `last_rotated_at` 필드가 모두 포함된다. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — CLI credential surface 섹션: `--daemon-id` 옵션 추가, daemon-scoped 등록/조회/삭제 예시. -- Modules: - - `app/cli/commands/credential.py` — `set_credential`, `list_credentials`, `remove_credential` 함수에 `--daemon-id` 파라미터 추가. daemon 조회 로직 추가. - - `app/repositories/postgres.py` — `get_daemon_by_slug(daemon_slug)` 또는 동등한 메서드가 필요(기존 존재 여부 확인 후 추가 또는 재사용). -- Tests: - - `set_credential --daemon-id` 단위 테스트(mock repo). - - `list_credentials --daemon-id` 필터 단위 테스트. - - `remove_credential --daemon-id` 단위 테스트. - - daemon 미존재 오류 케이스(AC-5). - - `--daemon-id` 미제공 시 오류(AC-6). - -## Notes -- `--daemon-id` 옵션은 daemon_slug(사람이 읽기 쉬운 식별자) 또는 daemon UUID를 허용할지 여부는 batch design 단계에서 기존 `daemons` 테이블 스키마를 확인하여 확정한다. 기존 CLI의 `--user` 옵션이 username을 받아 내부에서 user_id로 변환하는 패턴과 동일하게 설계한다. -- REQ-047 PK 전환 이전 단계(daemon_id 컬럼은 있으나 nullable)에서는 `--daemon-id` 미제공 시에도 set/remove를 허용할지 결정이 필요하다. 단계적 마이그레이션 기간 중 호환성 정책은 batch design에서 확정한다. 이 REQ의 AC-6은 PK 전환(REQ-047 Step 2) 완료 이후 상태를 기준으로 한다. -- `pfhub credential list` daemon_id 미지정 동작(전체 조회, AC-3)은 PK 전환 이후 daemon_id 컬럼이 NOT NULL이 되어도 유지된다(admin 관리 목적). -- Source: dcy-010 FR-3, §4 C3. - -## Change Log - -### CHG-20260628-01 -- Date: 2026-06-28 -- Author: Justin Ko - -#### Change Summary -- 신규 생성 (Proposed) — dcy-010 FR-3 산출물. - -#### Intent -- dcy-010 FR-3(인증정보 관리 경로에 daemon_id 바인딩 추가)을 CLI Interface REQ로 정규화한다. - -#### Acceptance Criteria Delta -- Added: - - AC-1 ~ AC-7 전체 (신규 초안) -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: - - `docs/srs/Interface/req-050_credential-cli-daemon-id-option.md` 신규 생성 - - `docs/interface-contract.md` 갱신 예고 -- Modules: - - `app/cli/commands/credential.py` (옵션 추가), `app/repositories/postgres.py` (daemon 조회 메서드) -- Tests: - - credential CLI daemon-id 옵션 단위 테스트 신규 - -#### Delivery Trace Delta -- Batches: 미정 (suggest-batch-reqs 대기) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 배정. REQ-046(모델), REQ-047(스키마) 선행 의존. --daemon-id 필수/선택 단계 정책은 design에서 확정. - -#### Validation Plan -- pytest mock repo 기반 단위 테스트 AC-1~7. daemon 미존재 오류 케이스(AC-5) 포함. - -### CHG-20260628-02 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req: `Proposed` → `Approved`. dcy-010 FR-3 추적. set/list/remove --daemon-id 적절. slug vs UUID 해석 + REQ-047 과도기(daemon_id nullable 구간) --daemon-id 필수/선택 정책은 batch design 위임 — AC 무효화 아님(비차단). - -### CHG-20260628-03 -- Date: 2026-06-28 -- Author: 팀장(Claude) - -#### Change Summary -- confirm-req-implemented: `Approved` → `Implemented`. PR #17 머지(mergeCommit d4acfd4), BAT-020, QC PASS-WITH-NOTES(blocker 0), 테스트 통과. - -#### Intent -- BAT-020 delivery(PR #17 머지)에서 credential CLI --daemon-id 옵션(set/list/remove) 구현 및 AC-1~7 테스트 통과가 확인되어 Implemented로 전환한다. - -#### Acceptance Criteria Delta -- Added: 없음 -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- 없음 (상태 전환만) - -#### Delivery Trace Delta -- Batches: BAT-020 (PR #17, mergeCommit d4acfd4) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented - -#### Validation Plan -- BAT-020 verification 문서 및 QC PASS-WITH-NOTES 결과(blocker 0)로 AC-1~7 근거 확보됨. diff --git a/docs/srs/Interface/req-052_user-owned-credential-management-api.md b/docs/srs/Interface/req-052_user-owned-credential-management-api.md deleted file mode 100644 index 9fa1dd9..0000000 --- a/docs/srs/Interface/req-052_user-owned-credential-management-api.md +++ /dev/null @@ -1,104 +0,0 @@ -# REQ-052: 사용자 소유 스코프 인증정보 관리 HTTP API - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- 현재 BrokerCredential 관리는 CLI(`pfhub credential`)와 service-token read 경로(REQ-051/REQ-049)만 존재하며, HTTP user-token 기반 관리 표면이 없다. -- trex-web dcy-009 사용자 self-service UI가 소비하는 백엔드 경로를 신설한다: 사용자가 자기 소유 데몬의 KIS 인증정보를 HTTP API로 등록·회전·삭제하고, 메타데이터(secret 미포함)를 조회할 수 있도록 한다. -- dcy-010 자산(BrokerCredential 모델, Fernet 암호화, `CREDENTIAL_ENCRYPTION_KEY`, PK `(daemon_id, broker)`)을 재사용하여 CLI와 동일 repository·암호화 경로를 공유한다. - -## Requirement -- 시스템은 user-token으로 인증된 사용자가 **자기 소유 데몬**의 broker 인증정보를 등록·회전·삭제할 수 있는 write 엔드포인트를 제공해야 한다. - - 등록/회전: `app_key` + `app_secret` + `account_ref` 입력을 Fernet 암호화하여 `BrokerCredential`에 upsert. 회전 시 `credential_version` 증가, `last_rotated_at` 갱신. - - 삭제: 해당 `(daemon_id, broker)` 행 제거. -- 시스템은 사용자가 자기 소유 데몬의 인증정보 **메타데이터**를 조회할 수 있는 read 엔드포인트를 제공해야 한다. 응답에는 `broker`, `account_ref`, `credential_version`, `last_rotated_at`, 보유 여부(`has_credential`)만 포함하며 **복호화된 `app_key`/`app_secret`은 절대 포함하지 않는다**. -- **소유 스코프 인가**를 모든 관리 경로에 강제해야 한다: 요청자 `user_id`가 대상 데몬의 `owner_user_id`와 일치하지 않으면 HTTP 403 Forbidden을 반환한다. 인증 토큰이 없는 요청은 HTTP 401을 반환한다. -- 인증정보 write 요청이 성공하면 응답에는 메타데이터(`broker`, `account_ref`, `credential_version`, `last_rotated_at`)만 반환하고 `app_key`/`app_secret`은 포함하지 않는다. -- 엔드포인트 경로·HTTP 메서드의 구체 형태는 draft-batch-design에서 v1 컨벤션(`/v1/daemons/{daemon_id}/credential` 방향 권고, dcy-011 OQ-1 Deferred)에 맞춰 확정한다. -- 기존 CLI(`pfhub credential`)와 동일한 `BrokerCredentialRepository` 메서드 및 `app/core/encryption.py` Fernet 자산을 재사용해야 한다. - -## Acceptance Criteria -1. Given user-token(소유자 A)으로 자기 소유 daemon D에 `app_key`/`app_secret`/`account_ref` 를 등록, When write 엔드포인트를 호출, Then HTTP 200/201 + 메타데이터 응답(`broker`, `account_ref`, `credential_version`, `last_rotated_at`)이 반환되고 응답 JSON에 `app_key`/`app_secret` 필드가 존재하지 않는다. -2. Given 같은 소유자 A가 동일 daemon D 인증정보를 재등록(회전), When write 엔드포인트를 호출, Then 응답의 `credential_version`이 이전보다 1 증가하고 `last_rotated_at`이 갱신된다. -3. Given 소유자 A가 daemon D 인증정보를 등록한 뒤, When service-token `/v1/user_info`(REQ-049)로 해당 daemon D의 인증정보를 fetch하면, Then `broker.account_ref`가 방금 등록한 값과 일치한다(CLI·API·service-token 경로 정합). -4. Given user-token(소유자 A)으로 자기 소유 daemon D 인증정보 메타데이터를 GET, When 조회 엔드포인트를 호출, Then HTTP 200 + `broker`, `account_ref`, `credential_version`, `last_rotated_at`, `has_credential` 반환되고 `app_key`/`app_secret` 필드는 응답에 없다. -5. Given user-token(소유자 A)으로 자기 소유 daemon D 인증정보를 삭제, When delete 엔드포인트를 호출, Then HTTP 200/204 반환 후 해당 daemon D 메타데이터 조회 시 `has_credential: false`(또는 인증정보 없음 응답). -6. Given user-token(사용자 B)으로 **사용자 A 소유** daemon D의 인증정보에 대한 write/read/delete 요청, When 엔드포인트를 호출, Then HTTP 403 Forbidden 반환(소유 스코프 위반). -7. Given 인증 토큰 없는 요청으로 인증정보 관리 엔드포인트 호출, When 호출, Then HTTP 401 반환. -8. Given 존재하지 않는 daemon_id를 대상으로 인증정보 관리 요청, When 호출, Then HTTP 404 반환. -9. Given admin 토큰으로 자기 소유 데몬이 아닌 daemon D의 인증정보 write 엔드포인트 호출, When 호출, Then HTTP 403 반환(관리 API는 소유 스코프 전용 — admin role로 타인 인증정보 조작 불가). -10. Given service-token으로 인증정보 관리 엔드포인트 호출, When 호출, Then HTTP 403 반환(service-token은 read 전용 `/v1/user_info` 경로만 허용). - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — 인증정보 관리 엔드포인트 경로·권한·응답 스키마(secret 미포함 정책) 신규 섹션 추가. -- Modules: - - 신규 라우터 파일(경로·파일명은 design 확정) — 인증정보 관리 write/read/delete 엔드포인트. - - `app/schemas/` — `CredentialSetRequest`(app_key/app_secret/account_ref), `CredentialMetaResponse`(메타데이터 only, secret 미포함) 스키마 신규 정의. - - `app/services/` — 소유 스코프 인가 검사 + Fernet 암호화/upsert/삭제 서비스 메서드 신규 또는 확장. - - `app/repositories/broker_credential_repository.py` — 메타데이터 조회·write 메서드 확장(CLI와 공유). - - `app/core/encryption.py` — 재사용 (변경 없음). - - `app/main.py` 또는 라우터 등록 — 신규 라우터 include. -- Tests: - - AC-1~10 커버 pytest 케이스. 특히 AC-6(소유 스코프 음성), AC-9(admin 소유 스코프 음성), AC-10(service-token 음성), AC-3(CLI·API·service-token 정합) 강제 검증. - -## Notes -- OQ-1(엔드포인트 경로): draft-batch-design 위임(dcy-011 Deferred). 팀장 권고 = daemon-scoped(`/v1/daemons/{daemon_id}/credential`). v1 컨벤션·dcy-009 UI 흐름 정합 설계 단계 확정. -- **복호화 secret 미반환 불변**: 이 REQ의 어떤 응답도 `app_secret`/`app_key` 평문을 포함하지 않는다. 유일 decrypt-return 경로는 service-token `GET /v1/user_info`(REQ-051/REQ-049) — 본 REQ로 변경하지 않는다. -- AC-9(admin 소유 스코프): admin은 데몬 CRUD(REQ-053)를 담당하며, admin role로 타인 인증정보 write/read를 허용하지 않는다. 인증정보는 **사용자 본인 소유 스코프만**. 이 경계가 REQ-052(user-token, 소유 스코프)와 REQ-053(admin, 데몬 CRUD)의 책임 분리이다. -- NFR-1(보안 — secret 미반환·로그 미노출)은 REQ-054에서 독립 Non-Functional REQ로 추적한다. 이 REQ AC는 응답 schema 단의 secret 부재 확인을 포함하며, REQ-054가 전체 보안 격리를 측정한다. -- NFR-3(일관성 — CLI·API 정합): AC-3이 흡수한다. 구현 시 동일 repository 메서드 재사용 강제. -- Source: dcy-011 FR-1, FR-2, FR-3, NFR-2, NFR-3. - -## Change Log - -### CHG-20260628-01 -- Date: 2026-06-28 -- Author: Justin Ko - -#### Change Summary -- 신규 생성 (Proposed) — dcy-011 FR-1/FR-2/FR-3 산출물. - -#### Intent -- dcy-011 confirmed Discovery의 FR-1(인증정보 등록·회전·삭제), FR-2(메타데이터 조회, secret 미반환), FR-3(소유 스코프 인가 강제)를 단일 Interface REQ로 정규화한다. 세 FR이 동일 관리 표면(user-token + 소유 스코프 인증정보 관리)에 속하므로 하나의 REQ로 묶는다. - -#### Acceptance Criteria Delta -- Added: - - AC-1 ~ AC-10 전체 (신규 초안) -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: - - `docs/srs/Interface/req-052_user-owned-credential-management-api.md` 신규 생성 - - `docs/interface-contract.md` 갱신 예고 -- Modules: - - 인증정보 관리 라우터(신규), 스키마(신규), 서비스·repository 확장 -- Tests: - - 소유 스코프 음성(AC-6/9/10), 메타데이터 secret 부재(AC-1/4), CLI 정합(AC-3) 케이스 신규 - -#### Delivery Trace Delta -- Batches: 미정 (suggest-batch-reqs 대기) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 suggest-batch-reqs. REQ-053/REQ-054와 동일 batch 묶기 권고. OQ-1(경로)은 design 단계 해소. - -#### Validation Plan -- pytest로 AC-1~10 각 케이스 확인. AC-6(타 사용자 소유 스코프 음성), AC-9(admin 소유 스코프 음성), AC-10(service-token 음성)은 음성 테스트로 강제 검증. - -### CHG-20260628-02 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req: `Proposed` → `Approved`. dcy-011 FR-1/2/3 추적. 소유 스코프 음성(AC-6/9/10)·메타데이터 secret 부재(AC-1/4)·CLI·API·service-token 정합(AC-3) 적절. **AC-9(admin도 타인 인증정보 write→403)** = 사장님 "self-service 전용·admin 감독 없음" 결정과 정합 확인(admin은 데몬 CRUD만, 인증정보는 본인 소유 스코프). 경로(OQ-1) design 위임. blocker 없음. - -### CHG-20260628-03 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req-implemented: `Approved` → `Implemented`. 근거: BAT-022 PR #19 mergeCommit 1b78c18, QC PASS-WITH-NOTES(opus, blocker 0). AC-1~10 전건 테스트 통과(565 passed). 음성 케이스(AC-6 소유 스코프·AC-9 admin 소유 스코프·AC-10 service-token) 및 secret 미반환(AC-1/4) 검증 PASS. Major 보안 #3(owner 재할당 credential leak) 원자화 수정 83e6dde — 단일 트랜잭션+선삭제+leak 방지 테스트 3종 포함하여 전체 통과. 잔여 리스크 없음. diff --git a/docs/srs/Interface/req-053_admin-daemon-crud-api.md b/docs/srs/Interface/req-053_admin-daemon-crud-api.md deleted file mode 100644 index 725e573..0000000 --- a/docs/srs/Interface/req-053_admin-daemon-crud-api.md +++ /dev/null @@ -1,142 +0,0 @@ -# REQ-053: admin 데몬 CRUD HTTP API - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent -- 현재 `Daemon` 엔티티는 `GET /v1/daemons`(read 전용, REQ-045)만 HTTP로 노출되며, 생성·변경·(비)활성화 HTTP API가 없다. 관리자(admin)가 HTTP API로 데몬을 provisioning하고 owner를 할당·변경·(비)활성화할 수 있어야 한다. -- trex-web dcy-009 admin UI가 소비하는 백엔드 경로를 신설한다. -- 데몬은 운영 런타임 식별자(service-token daemon_id 바인딩, KIS 계좌 연결)로 하드 삭제는 제공하지 않으며 **비활성화로 대체**한다(dcy-011 FR-4 결정). owner 재할당은 허용하되 **owner 변경 시 해당 데몬의 기존 인증정보를 자동 무효화**한다(이전 owner의 KIS 계좌 이관 금지, dcy-011 OQ-3 결정). - -## Requirement -- 시스템은 admin role로 인증된 요청자가 데몬을 **생성**하고 `owner_user_id`를 할당할 수 있는 POST 엔드포인트를 제공해야 한다. - - 입력: `daemon_slug`(고유 식별자), `display_name`(선택), `owner_user_id`. - - 성공 시 생성된 Daemon의 `id`, `daemon_slug`, `display_name`, `owner_user_id`, `is_active` 반환. -- 시스템은 admin이 기존 데몬의 `display_name` 또는 `owner_user_id`를 **변경**할 수 있는 PATCH 엔드포인트를 제공해야 한다. - - **owner 변경 시 해당 데몬의 기존 `BrokerCredential` 전체를 자동 삭제(무효화)** 해야 한다 — 이전 owner의 KIS 계좌 정보가 새 owner에게 이관되는 것을 방지한다. -- 시스템은 admin이 데몬을 **비활성화**(`is_active=false`) 및 **재활성화**(`is_active=true`)할 수 있는 엔드포인트를 제공해야 한다. - - 비활성화 시 해당 데몬에 바인딩된 service token을 자동 revoke해야 한다(기존 REQ-022 정책 재사용). -- **하드 삭제(DELETE) 엔드포인트는 제공하지 않는다.** 비활성화가 유일한 종료 경로이다. -- admin role이 아닌 요청(user-token, service-token 포함)은 HTTP 403 Forbidden을 반환해야 한다. -- 엔드포인트 경로·HTTP 메서드의 구체 형태는 draft-batch-design에서 v1 admin 컨벤션(dcy-011 OQ-2 Deferred, dcy-006 admin 경로 참고)에 맞춰 확정한다. - -## Acceptance Criteria -1. Given admin 토큰으로 daemon_slug, display_name, owner_user_id를 포함한 생성 요청, When POST 엔드포인트를 호출, Then HTTP 201 + 생성된 Daemon(`id`, `daemon_slug`, `display_name`, `owner_user_id`, `is_active=true`) 반환. -2. Given admin 토큰으로 중복 `daemon_slug`로 생성 요청, When POST 엔드포인트를 호출, Then HTTP 409(또는 422) Conflict 반환(중복 슬러그 거부). -3. Given admin 토큰으로 존재하는 daemon D의 `display_name` 변경 요청(owner 변경 없음), When PATCH 엔드포인트를 호출, Then HTTP 200 + 갱신된 Daemon 반환. 기존 BrokerCredential은 유지된다. -4. Given admin 토큰으로 daemon D의 `owner_user_id`를 사용자 B로 재할당 요청(원래 소유자 A), When PATCH 엔드포인트를 호출, Then HTTP 200 + 갱신된 Daemon 반환 + 해당 daemon D의 BrokerCredential이 모두 삭제(무효화)된다. -5. Given daemon D의 owner가 변경된 직후, When 변경 전 소유자 A의 service-token으로 `/v1/user_info`를 호출, Then `broker: null` 반환(이전 owner 인증정보 이관 차단). -6. Given admin 토큰으로 활성 daemon D 비활성화 요청, When 비활성화 엔드포인트를 호출, Then HTTP 200 + `is_active: false` 반환 + daemon D에 바인딩된 service token 자동 revoke(REQ-022 정책). -7. Given admin 토큰으로 비활성 daemon D 재활성화 요청, When 재활성화 엔드포인트를 호출, Then HTTP 200 + `is_active: true` 반환. -8. Given admin 토큰으로 daemon D에 DELETE 요청(하드 삭제), When 호출, Then HTTP 405 Method Not Allowed(또는 404) 반환 — 하드 삭제 엔드포인트 미제공. -9. Given user-token(admin role 없음)으로 데몬 생성·변경·비활성화 엔드포인트 호출, When 호출, Then HTTP 403 Forbidden 반환. -10. Given service-token으로 데몬 CRUD 엔드포인트 호출, When 호출, Then HTTP 403 Forbidden 반환. -11. Given 인증 토큰 없는 요청으로 데몬 CRUD 엔드포인트 호출, When 호출, Then HTTP 401 반환. -12. Given admin 토큰으로 존재하지 않는 daemon_id에 PATCH/비활성화 요청, When 호출, Then HTTP 404 반환. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — admin 데몬 CRUD 엔드포인트 경로·권한·owner 재할당 시 인증정보 무효화 정책 신규 섹션 추가. -- Modules: - - 신규 또는 확장 admin 데몬 라우터(경로·파일명은 design 확정). - - `app/schemas/` — `DaemonCreateRequest`, `DaemonUpdateRequest`, `DaemonAdminResponse` 스키마 신규 정의. - - `app/services/` — 데몬 생성·변경·비활성화 서비스 메서드 신규. owner 재할당 시 `BrokerCredential` 무효화 로직 포함. - - `app/repositories/` — `DaemonRepository` create/update/deactivate/reactivate 메서드 신규. - - `app/repositories/broker_credential_repository.py` — owner 재할당 시 credential 삭제 메서드(신규 또는 확장). - - REQ-022(`daemon deactivation service token auto-revoke`) 서비스 로직 재사용 — 비활성화 경로에서 호출. -- Tests: - - AC-1~12 커버 pytest 케이스. AC-4/5(owner 재할당 + 인증정보 무효화), AC-8(DELETE 405 음성), AC-9/10/11(권한 음성) 강제 검증. - -## Notes -- OQ-2(엔드포인트 경로): draft-batch-design 위임(dcy-011 Deferred). `/v1/admin/daemons` vs `/v1/daemons`(admin 분기) — dcy-006 admin 경로 컨벤션 참고. -- **owner 재할당 + 인증정보 자동 무효화(AC-4/5)**: dcy-011 OQ-3 Answered(2026-06-28 결정). 새 owner는 자기 KIS 인증정보를 REQ-052 API로 재등록해야 한다. 이 정책은 이전 owner의 KIS 계좌 정보가 데몬에 잔류하는 보안 위험을 제거한다. -- **하드 삭제 미제공(AC-8)**: 데몬은 운영 런타임 식별자(service-token daemon_id 바인딩)이므로 참조 무결성 보호를 위해 비활성화가 유일 종료 경로. dcy-011 FR-4 결정. -- **비활성화 → service token revoke(AC-6)**: 기존 REQ-022 정책(`daemon deactivation auto-revoke`)을 이 경로에서도 호출. REQ-022 구현 재사용. -- NFR-2(권한 — admin role 강제): AC-9/10/11로 흡수한다. -- daemon_slug 중복 처리(AC-2): HTTP 409 vs 422는 design 단계에서 v1 컨벤션에 맞춰 확정. -- Source: dcy-011 FR-4, NFR-2, OQ-3 Answered. - -## Change Log - -### CHG-20260628-01 -- Date: 2026-06-28 -- Author: Justin Ko - -#### Change Summary -- 신규 생성 (Proposed) — dcy-011 FR-4 산출물. - -#### Intent -- dcy-011 confirmed Discovery의 FR-4(admin 데몬 생성·owner 할당·변경·(비)활성화, 삭제 제외) 및 OQ-3 결정(owner 재할당 허용 + 인증정보 자동 무효화)을 Interface REQ로 정규화한다. - -#### Acceptance Criteria Delta -- Added: - - AC-1 ~ AC-12 전체 (신규 초안) -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: - - `docs/srs/Interface/req-053_admin-daemon-crud-api.md` 신규 생성 - - `docs/interface-contract.md` 갱신 예고 -- Modules: - - admin 데몬 라우터(신규), DaemonCreate/UpdateRequest/AdminResponse 스키마(신규), 서비스·repository 신규 -- Tests: - - owner 재할당+인증정보 무효화(AC-4/5), DELETE 음성(AC-8), 권한 음성(AC-9~11) 케이스 신규 - -#### Delivery Trace Delta -- Batches: 미정 (suggest-batch-reqs 대기) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 suggest-batch-reqs. REQ-052/REQ-054와 동일 batch 묶기 권고. OQ-2(경로)은 design 단계 해소. - -#### Validation Plan -- pytest로 AC-1~12 각 케이스 확인. AC-4/5(owner 재할당 + 인증정보 무효화) 통합 시나리오, AC-8(DELETE 음성), AC-9(admin role 강제 음성) 강제 검증. - -### CHG-20260628-02 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req: `Proposed` → `Approved`. dcy-011 FR-4/OQ-3 추적. 데몬 생성·할당·(비)활성화, **owner 재할당 시 인증정보 자동 무효화(AC-4/5)** 보안정합, **하드 삭제 미제공(AC-8)**·비활성화→service token revoke(REQ-022 재사용, AC-6)·admin role 강제(AC-9/10/11) 적절. 경로(OQ-2)·409/422·405/404 design 위임. blocker 없음. - -### CHG-20260628-03 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req-implemented: `Approved` → `Implemented`. 근거: BAT-022 PR #19 mergeCommit 1b78c18, QC PASS-WITH-NOTES(opus, blocker 0). AC-1~12 전건 테스트 통과(565 passed). owner 재할당 원자화 수정(83e6dde) — 단일 트랜잭션+선삭제 방식으로 기존 BrokerCredential 삭제 후 owner 변경을 원자적으로 처리, leak 방지 테스트 3종 PASS. AC-4/5(owner 재할당+인증정보 무효화), AC-8(DELETE 405 음성), AC-9/10/11(권한 음성) 전건 통과. 잔여 리스크 없음. - -### CHG-20260628-04 -- Date: 2026-06-28 -- Author: 팀장(Claude) — change-req (핫픽스 백필 / 스코프 갭 정정) - -#### Change Summary -- **GET `/v1/admin/daemons`(전체 데몬 목록) 엔드포인트 누락 갭을 정정**한다. Status `Implemented` 유지(이번 변경으로 스코프 완결). - -#### Intent / 증상·영향·근본원인 (핫픽스 예외 경로) -- **증상**: trex-web admin "데몬 관리" 화면이 `adminListDaemons`(REQ-035)로 `GET /v1/admin/daemons` 호출 시 **405 Method Not Allowed** → "데몬 목록을 불러오지 못했습니다" 에러. -- **영향**: admin 데몬 목록 열람 불가(생성·변경 자체는 가능하나 목록 미표시). 라이브 통합테스트 전까지 미발견. -- **근본원인**: REQ-053 최초 스코프가 POST/PATCH/activate/deactivate(+DELETE 405 음성)만 AC로 잡고 **목록 조회(GET-list)를 AC로 누락**. 목록 수요는 trex-web REQ-035(admin API 클라/훅)에서 발생했으나 백엔드 계약(REQ-053)에 반영되지 않음. mock 기반 UI 단위테스트는 실제 405를 포착하지 못함(실연동 갭). - -#### Acceptance Criteria Delta -- Added: - - AC-13: Given admin 인증, When `GET /v1/admin/daemons`, Then 전체 데몬 목록(`{daemons:[{id,daemon_slug,display_name,is_active,owner_user_id}], total}`)을 200으로 반환한다. - - AC-14: Given 비admin 인증, When `GET /v1/admin/daemons`, Then 403으로 차단된다(admin role 강제 회귀). -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Modules: `app/api/v1_admin_daemons.py`(GET 핸들러 `list_all_daemons`), `app/schemas/v1.py`(`DaemonDetail`, `DaemonDetailListResponse`), `app/repositories/postgres.py`(`list_daemons_all()`) -- Tests: `tests/test_bat022_req053_admin_daemon_crud.py`(GET 목록 케이스 추가) - -#### Delivery Trace Delta -- 핫픽스 커밋 `2dcbafb`(로컬 main 직접 커밋 — worktree/PR/QC 정식 게이트 대신 핫픽스 fast-path). 정식 QC 게이트는 생략했고, 대신 **라이브 통합검증(실행 중 portfolio-hub :8000 상대 API E2E 18항목 PASS, GET 목록에 신규 데몬 포함 확인)**으로 검증을 대체했다. - -#### Revalidation Impact -- Reverification Needed: no (라이브 E2E로 검증 완료) -- Existing Implementation Invalidated: no (순수 추가, 기존 핸들러 무변경) -- Status Recommendation: implemented -- Rollback: `git revert 2dcbafb`(순수 추가라 안전). 롤백 시 목록 화면만 405로 회귀, 생성/변경 영향 없음. -- Follow-up Action: 후속 batch에서 GET 목록 케이스를 정식 회귀 테스트 스위트에 편입. 동종 갭 예방 위해 UI API 클라(REQ-035)와 백엔드 계약(REQ-053) 간 엔드포인트 교차대조 체크리스트화. diff --git a/docs/srs/Interface/req-055_v1-portfolios-strategy-market-meta-exposure.md b/docs/srs/Interface/req-055_v1-portfolios-strategy-market-meta-exposure.md deleted file mode 100644 index 1b19d1c..0000000 --- a/docs/srs/Interface/req-055_v1-portfolios-strategy-market-meta-exposure.md +++ /dev/null @@ -1,152 +0,0 @@ -# REQ-055: GET /v1/portfolios 응답에 전략·시장 메타데이터 노출 (additive) - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent - -- `GET /v1/portfolios`(및 단건 `GET /v1/portfolios/{id}` 존재 시 동일)의 `PortfolioItem` 응답에 전략 셋 바인딩 정보(`active_strategy_id`, `strategy_name`, `strategy_params`)와 거래 가능 시장 목록(`markets`)을 additive 필드로 노출한다. -- trex-core `hub_read.get_portfolios()`가 응답 dict에서 `active_strategy_id` · `strategy_params`를 파싱 준비(hub_read.py:168-169) 중이나, `PortfolioItem` 스키마에 해당 필드가 미노출되어 항상 `None`/`KeyError` 위험 상태다. 본 REQ가 이 갭을 닫는다. -- tvr-v1(dcy-001) 전략 설정의 단일 진실원천이 `portfolio-hub.strategy_sets`로 확정(사장님 확정)된 데 따라, 소비자가 전략 이름·파라미터를 별도 조회 없이 획득할 수 있도록 한다. -- 기존 필드·정렬·인증·페이지네이션은 불변(하위호환 additive 확장). - -## Requirement - -- **additive 필드 4개**를 `PortfolioItem` 스키마와 `GET /v1/portfolios` 응답(및 단건 조회 엔드포인트 응답)에 추가한다. - - | 필드 | 타입 | 설명 | - |---|---|---| - | `active_strategy_id` | `str \| null` | `portfolios.active_strategy_id` 참조 ID. 미바인딩 시 `null`. | - | `strategy_name` | `str \| null` | `strategy_sets.name`(JOIN 해석). 미바인딩 또는 strategy_set row 없으면 `null`. | - | `strategy_params` | `dict` | `strategy_sets.config`(JSON 문자열) 파싱 결과. 미바인딩·빈 config·파싱 불가 시 `{}`. 파싱 불가 시 서버 WARNING 로그 1건 출력(500 금지). | - | `markets` | `list[str]` | `portfolios → assets.markets` 조인 결과. asset 미연결이거나 markets 빈 배열이면 `[]`. | - -- **N+1 금지**: strategy_sets는 list 조회 시 일괄 조인(LEFT JOIN strategy_sets on active_strategy_id) 또는 일괄 IN-fetch 방식으로 처리한다. 단건 조회도 동일 접근 적용. -- **파싱 에러 격리**: `strategy_sets.config`가 유효하지 않은 JSON이면 `strategy_params = {}`를 반환하고, 서버 측에서 WARNING 수준 로그를 출력한다. HTTP 응답은 200을 유지한다(500 금지). -- **기존 필드 불변**: `portfolio_id`, `asset_id`, `symbol`, `display_name`, `owner_user_id`, `broker`, `status`, `name`, `cash_balance`, `asset_quantity`, `price`, `profit_buffer`, `tag_ids`, `managed_by_daemon_id`, `created_at`, `updated_at` 필드는 변경 없이 유지된다. -- **권한 모델 불변**: 토큰 분기(service/session/admin) 및 필터(`?market`, `?include_disabled`, `?user_id`) 동작은 REQ-028 계약을 그대로 따른다. - -## Acceptance Criteria - -1. **Given** `active_strategy_id`가 바인딩된 포트폴리오, **When** `GET /v1/portfolios`, **Then** 해당 item에 `active_strategy_id`(비null), `strategy_name`(strategy_sets.name 값), `strategy_params`(config 파싱 dict) 세 필드가 정상 값으로 반환된다. - -2. **Given** `active_strategy_id`가 `null`인 포트폴리오, **When** `GET /v1/portfolios`, **Then** `active_strategy_id=null`, `strategy_name=null`, `strategy_params={}` 로 반환된다. - -3. **Given** `active_strategy_id`가 존재하나 해당 strategy_set row가 삭제된 포트폴리오(dangling FK), **When** `GET /v1/portfolios`, **Then** `active_strategy_id`는 원래 값, `strategy_name=null`, `strategy_params={}` 로 반환되며 500이 발생하지 않는다. - -4. **Given** strategy_sets.config가 유효하지 않은 JSON 문자열인 포트폴리오, **When** `GET /v1/portfolios`, **Then** `strategy_params={}` 로 반환되고, 서버 로그에 WARNING 1건이 기록되며 HTTP 응답은 200이다. - -5. **Given** `assets.markets = ['KRX', 'NXT']`인 asset에 연결된 포트폴리오, **When** `GET /v1/portfolios`, **Then** `markets=['KRX', 'NXT']` 로 반환된다. - -6. **Given** `assets.markets = []`(빈 배열)인 asset에 연결된 포트폴리오, **When** `GET /v1/portfolios`, **Then** `markets=[]` 로 반환된다. - -7. **Given** 임의 유효 토큰으로 `GET /v1/portfolios`, **When** 응답의 기존 필드(`portfolio_id`, `broker`, `status`, `name`, `symbol`, `display_name`, `owner_user_id`, `cash_balance`, `asset_quantity`, `price`, `profit_buffer`, `tag_ids`, `managed_by_daemon_id`, `created_at`, `updated_at`)를 확인, **Then** 기존 값이 변경 없이 그대로 반환된다(회귀 없음). - -8. **Given** service token(daemon-bound) 또는 session/admin token, **When** `GET /v1/portfolios`, **Then** REQ-028의 토큰 분기 필터(daemon_id·owner_user_id·admin `?user_id`)가 4개 신규 필드 추가 이후에도 동일하게 동작한다. - -9. **Given** strategy_name="tvr_v1", config=`{"target_weight": 0.5, "rebalance_band_pct": 0.05, "min_trade_krw": 10000}`인 strategy_set이 바인딩된 포트폴리오, **When** `GET /v1/portfolios`, **Then** `strategy_params` 응답 dict가 원본 JSON과 동일 키·값을 갖는다. - -10. **Given** 복수의 포트폴리오를 가진 사용자(일부는 strategy 바인딩, 일부는 미바인딩), **When** `GET /v1/portfolios`, **Then** 각 포트폴리오의 `active_strategy_id`·`strategy_name`·`strategy_params`·`markets`가 개별적으로 정확하게 반환된다(리스트 조회 정합성). - -## Impacted Area - -- Docs: - - `docs/interface-contract.md` — `GET /v1/portfolios` `PortfolioItem` 응답 필드 정의에 4개 신규 필드 추가 명세 - -- Modules: - - `app/schemas/v1.py` — `PortfolioItem`에 `active_strategy_id: str | None = None`, `strategy_name: str | None = None`, `strategy_params: dict[str, Any] = Field(default_factory=dict)`, `markets: list[str] = Field(default_factory=list)` 추가 - - `app/domain/models.py` — `Portfolio` dataclass에 `strategy_name: str | None = None`, `strategy_params: dict[str, Any] = Field(default_factory=dict)`, `markets: list[str]` 필드 추가 (strategy_sets JOIN 결과 수용) - - `app/repositories/postgres.py` — `_SELECT_PORTFOLIO` 쿼리에 `LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id` 추가 및 `ss.name`, `ss.config`, `a.markets` 컬럼 포함. `_row_to_portfolio` 파싱 보강(config JSON 파싱 + 오류 격리). 단건 get/list 경로 모두 반영. - - `app/api/v1_portfolios.py` — `_portfolio_to_item` 매핑에 4개 신규 필드 포함 - -- Tests: - - `tests/test_v1_portfolios_list.py` — AC-1~10 검증 케이스 추가(전략 바인딩/미바인딩/dangling/파싱불가/markets/기존필드 회귀) - -## Notes - -- **Discovery 참조**: - - `trex-tvr-v1 docs/discovery/dcy-001_20260702_target-value-rebalancing.md` (confirmed): 전략 설정 단일 진실원천 = `portfolio-hub.strategy_sets`(name="tvr_v1", config=JSON 문자열). 사장님 확정. - - `trex-tvr-v1 docs/discovery/dcy-002` v3 FR-8: `assets.markets`를 거래 가능 시장 목록으로 정의(["KRX","NXT"]; `[]`=KRX only 보수 해석). - -- **소비자 정합 (필드명 고정)**: - - trex-core `hub_read.py:168-169`가 응답 dict에서 `active_strategy_id` · `strategy_params` 키를 파싱 준비 중. 본 REQ에서 정의하는 필드명은 이 키 이름과 정확히 일치해야 한다. 이름 변경은 소비자 코드 변경을 유발하므로 본 REQ 범위 밖. - -- **배포 순서**: 본 REQ(①포트폴리오 응답에 전략·시장 메타 실어주기) → trex-core slots 소비 확장(②흘려주기) → tvr e2e 검증. 본 REQ가 배포되지 않으면 trex-core가 읽을 데이터 없음. - -- **레거시(OQ-1, 비차단)**: 기존 `strategy_sets`에 name="target_value", config=`{}` 레코드 및 이를 참조하는 포트폴리오 2개가 존재. name="tvr_v1" 확정에 따른 레거시 마이그레이션(rename/backfill) 여부는 사장님 확인 후 별도 처리. 본 REQ 노출 로직은 레거시 존재와 무관하게 동작(config `{}` → `strategy_params={}` 정상 처리). - -- **DB 마이그레이션 불필요**: `strategy_sets` 테이블과 `portfolios.active_strategy_id` 컬럼은 이미 존재. `assets.markets` 컬럼도 존재. 본 REQ는 쿼리 JOIN 확장과 응답 스키마 변경만 필요하며 alembic 마이그레이션은 발생하지 않는다. - -- **OQ-1(미결)**: `strategy_sets.config` 컬럼 타입이 `TEXT`(JSON 문자열). 응답 `strategy_params`는 서버 측에서 파싱해 dict로 직렬화. 향후 `JSONB` 타입 전환 고려 가능하나 본 REQ 범위 밖. - -## Change Log - -### CHG-20260704-03 -- Date: 2026-07-04 -- Author: 팀장(Claude) — confirm-req-implemented - -#### Change Summary -- Approved → **Implemented**: bat-023 구현 완료. 독립 QC PASS-WITH-NOTES(blocker 0) — AC 10/10, 필드명 잠금 정확, N+1 없음, 기존 16필드·토큰분기 회귀 0. PR #20 사장님 결재·머지(522a10f, 583 passed) = confirm-batch-verification 완료. -- 잔여(비차단): dangling FK LEFT JOIN NULL 실DB 확인은 배포 후 통합검증. - -#### Delivery Trace Delta -- Batches: bat-023_20260704_portfolios-strategy-market-meta (merged, PR #20) - -#### Revalidation Impact -- Reverification Needed: no / Existing Implementation Invalidated: no -- Status Recommendation: Implemented - -### CHG-20260704-02 -- Date: 2026-07-04 -- Author: 팀장(Claude) — confirm-req - -#### Change Summary -- Proposed → Approved: 팀장 검토·승인. tvr e2e 배관 ①(실어주기). 배포 순서 ①→trex-core②. OQ-1(필드명 고정)·OQ-2(레거시 target_value, 비차단) 인지. - -#### Acceptance Criteria Delta -- Added/Updated/Removed: 없음 - -#### Revalidation Impact -- Reverification Needed: no / Existing Implementation Invalidated: no -- Status Recommendation: Approved - -### CHG-20260704-01 - -- Date: 2026-07-04 -- Author: portfolio-hub-dev (Claude) - -#### Change Summary - -- 최초 생성 — trex-tvr-v1 dcy-001 confirmed Discovery 근거로 draft-req 실행. 상태 Proposed. - -#### Intent - -- `GET /v1/portfolios` 응답에 전략·시장 메타데이터를 additive 노출해 trex-core 소비자의 전략 파라미터 단일 조회 경로를 확보한다. - -#### Acceptance Criteria Delta - -- Added: AC-1~10 최초 작성 - -#### Impacted Area Delta - -- Docs: docs/interface-contract.md — PortfolioItem 필드 추가 명세 -- Modules: app/schemas/v1.py, app/domain/models.py, app/repositories/postgres.py, app/api/v1_portfolios.py -- Tests: tests/test_v1_portfolios_list.py - -#### Delivery Trace Delta - -- Batches: none (미정, confirm-req 이후 suggest-batch-reqs) -- Releases: none (미정) - -#### Revalidation Impact - -- Reverification Needed: no (신규 REQ, 기존 구현 없음) -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current (Proposed) -- Follow-up Action: confirm-req(팀장 승인) → suggest-batch-reqs → batch 계획 편입 - -#### Validation Plan - -- pytest TestClient로 AC-1~10 검증. 전략 바인딩·미바인딩·dangling FK·파싱불가·markets 유무·기존 필드 회귀·토큰 분기 시나리오 포함. diff --git a/docs/srs/Interface/req-056_v1-portfolios-manual-ledger-api.md b/docs/srs/Interface/req-056_v1-portfolios-manual-ledger-api.md deleted file mode 100644 index 69a5107..0000000 --- a/docs/srs/Interface/req-056_v1-portfolios-manual-ledger-api.md +++ /dev/null @@ -1,227 +0,0 @@ -# REQ-056: owner-scoped 수동 장부 입력 HTTP API - -- Status: Implemented -- Type: Interface -- Priority: High -- Owner: Justin Ko - -## Intent - -- trex-web이 user/session token으로 자기 소유 portfolio에 수동 장부 entry를 append할 수 있는 portfolio-hub v1 API 계약을 신설한다. -- 기존 `POST /v1/portfolios/{portfolio_id}/ledger/trades`는 trex-core/daemon/service-token broker 체결 보고 전용 계약으로 유지하고, 사용자 수동 입력은 신규 `POST /v1/portfolios/{portfolio_id}/ledger/manual`로 분리한다. -- manual write는 portfolio snapshot(`cash_balance`, `asset_quantity`, `profit_buffer`)과 `portfolio_ledger` append를 단일 트랜잭션으로 처리하고, trex-web이 장부 이력/후속 상태/수익 실현액을 표시할 수 있는 응답 shape을 제공한다. - -## Requirement - -- 신규 라우트 `POST /v1/portfolios/{portfolio_id}/ledger/manual`을 제공한다. - - Bearer user/session token을 요구한다. - - 요청자는 대상 portfolio의 `owner_user_id`와 일치해야 한다. owner mismatch는 HTTP 403 fail-closed로 거부한다. - - service-token/daemon-token은 본 endpoint에서 HTTP 403 fail-closed로 거부한다. service-token broker trade write는 기존 `/ledger/trades`만 사용한다. - - `status='disabled'` portfolio write는 HTTP 409로 거부한다. -- request body는 단일 action-dispatch shape을 사용하며 `action` enum을 포함한다. - - | action | 필수 입력 | 선택 입력 | snapshot effect | - |---|---|---|---| - | `INVEST` | `amount` | `note` | `profit_buffer += amount` | - | `BUFFER_OUT` | `amount` | `note` | `profit_buffer -= amount`, `cash_balance += amount` | - | `BUFFER_IN` | `amount` | `note` | `cash_balance -= amount`, `profit_buffer += amount` | - | `BUY` | `quantity`, `price` | `fee`, `tax`, `note` | `cash_balance -= quantity * price + fee + tax`, `asset_quantity += quantity` | - | `SELL` | `quantity`, `price` | `fee`, `tax`, `note` | `asset_quantity -= quantity`, `cash_balance += quantity * price - fee - tax` | - | `WITHDRAW` | `amount` | `note` | `profit_buffer -= amount`; 순입금액 계산에서 `sum(INVEST) - sum(WITHDRAW)`의 차감 항목 | - | `PROFIT` | `amount` | `note` | `profit_buffer -= amount`; 순수 수익 실현 인출이며 순입금액 계산에는 미포함 | - | `REVERSAL` | `target_ledger_entry_id` | `note` | target entry의 반대 effect를 서버가 계산해 append | - -- `QUANTITY_ADJUSTMENT`는 1차 범위에서 제외한다. manual endpoint enum/schema/test는 해당 action을 허용하지 않아야 한다. -- 입력 값 검증: - - `amount`, `quantity`, `price`는 0보다 커야 한다. - - `fee`, `tax`는 생략 시 0으로 취급하며 음수를 허용하지 않는다. - - action에 필요하지 않은 핵심 수치 필드가 포함되면 422로 거부하거나 schema에서 명시적으로 무시하지 말고 계약상 실패로 처리한다. -- manual write는 새 음수 상태를 만드는 operation을 1차 범위에서 거부한다. - - `BUY`: `quantity * price + fee + tax > cash_balance`이면 HTTP 409. - - `SELL`: `quantity > asset_quantity`이면 HTTP 409. - - `BUFFER_IN`: `amount > cash_balance`이면 HTTP 409. - - `BUFFER_OUT`, `WITHDRAW`, `PROFIT`: `amount > profit_buffer`이면 HTTP 409. - - `INVEST`: `amount > 0`이면 잔고 부족 검증 없이 허용한다. -- daemon-managed portfolio(`managed_by_daemon_id IS NOT NULL`)에는 API-level explicit confirmation을 강제한다. - - confirmation 없는 manual write는 HTTP 409 + error code `MANUAL_WRITE_REQUIRES_CONFIRMATION`으로 거부한다. - - 사용자가 경고를 확인한 뒤 request body에 confirmation flag/evidence를 포함하면 write를 허용한다. - - 허용된 write는 actor user, `managed_by_daemon_id`, confirmation evidence를 ledger/audit metadata 또는 동등한 추적 가능한 저장 위치에 남긴다. -- `REVERSAL` 정책: - - `target_ledger_entry_id`는 필수이며 같은 portfolio 소속 ledger entry여야 한다. - - target entry를 직접 수정/삭제하지 않고 반대 effect의 신규 `REVERSAL` entry를 append한다. - - target을 찾을 수 없거나 owner scope를 벗어나면 fail-closed(404 또는 403)한다. - - 동일 target entry의 중복 reversal은 HTTP 409로 거부한다. -- manual write 성공 응답은 HTTP 201을 기본으로 하며 다음 필드를 포함한다. - - `ledger_entry_id`, `portfolio_id`, `action`, 입력 수치(`amount` 또는 `quantity`/`price`/`fee`/`tax`), `note`, `recorded_at` - - `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after` - - `target_ledger_entry_id`(REVERSAL인 경우), `source="manual"` 또는 동등한 source 구분 필드 - - daemon-managed confirmation이 적용된 경우 confirmation metadata 또는 audit reference -- manual ledger history/read 계약은 trex-web 표시를 위해 portfolio별 최근 ledger entries를 반환해야 한다. - - 기존 history endpoint를 확장하거나 신규 `GET /v1/portfolios/{portfolio_id}/ledger/manual`/동등 endpoint를 설계할 수 있으나, 응답 item에는 `action`, `amount`, `quantity`, `price`, `fee`, `tax`, `note`, `recorded_at`, `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`가 포함되어야 한다. - - `total_value_after`는 ledger entry 기록 시점의 portfolio-hub snapshot 값이며 실시간 mark-to-market 값으로 해석하지 않는다. -- 수익률/요약 응답은 `cumulative_profit_withdrawn`을 별도 지표로 노출해야 한다. - - 값은 `PROFIT` action 누적액이다. - - 순입금액은 `sum(INVEST) - sum(WITHDRAW)`로 계산하며 `PROFIT`은 이 계산에 포함하지 않는다. -- ledger append와 portfolio snapshot update는 같은 DB 트랜잭션에서 수행하고, 같은 portfolio의 동시 manual write는 row lock 또는 동등한 concurrency control로 lost update 없이 직렬화한다. -- 기존 `/ledger/trades` service-token broker write, `GET /v1/portfolios`, CLI portfolio invest/transfer/buy/sell 흐름은 본 endpoint 추가로 회귀하지 않아야 한다. - -## Acceptance Criteria - -1. Given owner user/session token + active portfolio, When `POST /v1/portfolios/{id}/ledger/manual` with `action="INVEST"`, `amount=100000`, Then HTTP 201 + `profit_buffer_after`가 100000 증가하고 `cash_balance_after`/`asset_quantity_after`는 기존 값이며 `portfolio_ledger` row가 append된다. -2. Given owner token + active portfolio(`profit_buffer=100000`, `cash_balance=0`), When `BUFFER_OUT amount=40000`, Then HTTP 201 + `profit_buffer_after=60000`, `cash_balance_after=40000`. -3. Given owner token + active portfolio(`cash_balance=40000`, `profit_buffer=60000`), When `BUFFER_IN amount=10000`, Then HTTP 201 + `cash_balance_after=30000`, `profit_buffer_after=70000`. -4. Given owner token + active portfolio(`cash_balance=1000000`, `asset_quantity=0`), When `BUY quantity=10 price=80000 fee=100 tax=0`, Then HTTP 201 + `cash_balance_after=199900`, `asset_quantity_after=10`. -5. Given owner token + active portfolio(`cash_balance=199900`, `asset_quantity=10`), When `SELL quantity=3 price=90000 fee=100 tax=0`, Then HTTP 201 + `asset_quantity_after=7`, `cash_balance_after=469800`. -6. Given owner token + active portfolio(`profit_buffer=70000`), When `WITHDRAW amount=20000`, Then HTTP 201 + `profit_buffer_after=50000` and the portfolio return/net-contribution calculation treats the amount as `sum(INVEST) - sum(WITHDRAW)` reduction. -7. Given owner token + active portfolio(`profit_buffer=50000`), When `PROFIT amount=10000`, Then HTTP 201 + `profit_buffer_after=40000`, `cumulative_profit_withdrawn` increases by 10000, and net contributed capital is unchanged. -8. Given owner token + existing ledger entry in the same portfolio, When `REVERSAL target_ledger_entry_id=` is posted, Then HTTP 201 + a new `REVERSAL` row is appended with the target's opposite snapshot effect and the original target row remains unchanged. -9. Given a target ledger entry was already reversed, When the same `target_ledger_entry_id` is reversed again, Then HTTP 409 and no additional ledger row/snapshot update occurs. -10. Given non-owner user/session token, When manual write or manual history/read is requested for another user's portfolio, Then HTTP 403 fail-closed and no ledger/snapshot mutation occurs. -11. Given service-token/daemon-token, When `POST /v1/portfolios/{id}/ledger/manual` is called, Then HTTP 403 fail-closed; When existing `POST /v1/portfolios/{id}/ledger/trades` is called with valid daemon assignment, Then existing broker trade contract remains unchanged. -12. Given disabled portfolio, When owner token posts any manual action, Then HTTP 409 and no ledger/snapshot mutation occurs. -13. Given daemon-managed portfolio and no confirmation flag/evidence, When owner token posts any manual action, Then HTTP 409 with error code `MANUAL_WRITE_REQUIRES_CONFIRMATION`; Given confirmation evidence is included, Then the write succeeds and actor/daemon/confirmation audit metadata is stored. -14. Given insufficient balances, When `BUY` exceeds cash, `SELL` exceeds quantity, `BUFFER_IN` exceeds cash, or `BUFFER_OUT`/`WITHDRAW`/`PROFIT` exceeds `profit_buffer`, Then HTTP 409 and ledger/snapshot remain unchanged for each case. -15. Given invalid action `QUANTITY_ADJUSTMENT` or missing/zero/negative required numeric fields, When manual write is posted, Then HTTP 422 and no ledger/snapshot mutation occurs. -16. Given successful manual writes, When manual ledger history/read is requested, Then each returned entry includes `action`, numeric inputs, `note`, `recorded_at`, `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`. -17. Given `PROFIT` entries exist, When return/summary response is requested, Then `cumulative_profit_withdrawn` is returned separately and net contribution uses `INVEST - WITHDRAW` without `PROFIT`. -18. Given two concurrent manual writes for the same portfolio, When both complete, Then row lock or equivalent control serializes updates without lost update; final snapshot equals sequential application of both accepted entries. -19. Given existing regression tests for `/ledger/trades`, `GET /v1/portfolios`, and CLI portfolio invest/transfer/buy/sell, When this REQ is implemented, Then those contracts still pass without semantic regression. - -## Impacted Area - -- Docs: - - `docs/interface-contract.md` — 신규 manual ledger write/read endpoint, request/response schema, error codes, `cumulative_profit_withdrawn` 지표 명세. - - `docs/runtime-flows.md` — owner-scoped manual write flow, daemon-managed confirmation, transaction/row lock, reversal flow 추가. - - `docs/data-model.md` — `portfolio_ledger` manual source/action metadata, reversal target reference, after-snapshot/cumulative profit semantics 보강. -- Modules: - - `app/api/v1_portfolios.py` 또는 신규 v1 router — `POST /v1/portfolios/{portfolio_id}/ledger/manual` 및 manual ledger history/read endpoint. - - `app/schemas/v1.py` — manual request/response schemas, action enum, error response shape. - - `app/domain/services.py` — manual action/effect helper, negative-state validation, reversal calculation, `PROFIT` vs `WITHDRAW` classification. - - `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — transaction/row lock, ledger append, reversal target lookup/dedup, history/read, `cumulative_profit_withdrawn` aggregate. - - auth dependencies — user/session owner-scope write guard and service-token fail-closed guard. -- Tests: - - 신규/갱신 API tests for AC-1~17. - - repository/concurrency tests for AC-18. - - regression tests for existing `/ledger/trades`, portfolio list, CLI/domain flow compatibility (AC-19). - -## Notes - -- Source: `docs/discovery/dcy-012_20260714_manual-ledger-write-api-owner-scoped.md` confirmed by Justin Ko, Ready for REQ Drafting true. -- Project #7 item: `PVTI_lADOBCBKFM4BbUVHzgyvA9A` — Status Requirements. -- 본 REQ는 Interface 중심이다. Data 변경(예: reversal target reference 저장 위치, confirmation metadata 저장 위치)이 필요할 수 있으나, 현재는 API 계약과 수락 기준에서 추적하고 batch design에서 기존 `portfolio_ledger` metadata로 충분한지 또는 migration REQ 분리가 필요한지 판정한다. -- 기존 `/ledger/trades`는 daemon/service-token broker write 전용으로 보존한다. user/session token manual BUY/SELL은 `/ledger/trades`로 우회 허용하지 않는다. -- `PROFIT`과 `WITHDRAW`는 snapshot effect가 모두 `profit_buffer` 감소일 수 있으나 성과 계산 의미가 다르다. `WITHDRAW`는 원금/입금액 회수로 순입금액을 감소시키고, `PROFIT`은 순수 수익 실현 인출로 `cumulative_profit_withdrawn`에만 누적한다. -- `total_value_after`는 ledger 기록 시점의 snapshot 값이며, 실시간 가격 재평가 결과가 아니다. -- `QUANTITY_ADJUSTMENT`는 후속 Discovery/REQ 후보이며 본 REQ 구현 범위가 아니다. - -## Change Log - -### CHG-20260714-01 -- Date: 2026-07-14 -- Author: portfolio-hub-dev (Hermes) - -#### Change Summary -- 신규 생성 (Proposed) — dcy-012 confirmed Discovery 근거로 draft-req 실행. owner-scoped manual ledger write/read API 계약을 정의하고 기존 daemon/service-token `/ledger/trades` 계약 보존을 명시한다. - -#### Intent -- trex-web manual ledger UI가 소비할 portfolio-hub API surface를 `POST /v1/portfolios/{portfolio_id}/ledger/manual` + action enum으로 고정하고, action/effect/권한/409 validation/daemon-managed confirmation/history response/PROFIT 계산 의미를 수락 가능 요구사항으로 정규화한다. - -#### Acceptance Criteria Delta -- Added: - - AC-1~19 최초 작성 -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md` 갱신 예고 -- Modules: v1 portfolio router, v1 schemas, domain services, portfolio repository/postgres repository, auth dependency 확장 -- Tests: manual action/effect, owner/non-owner/service-token/disabled/daemon-confirmation, insufficient balance, reversal, history/read, cumulative profit, concurrency, existing ledger trade/CLI regression - -#### Delivery Trace Delta -- Batches: 미정 (confirm-req 이후 suggest-batch-reqs) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req(팀장 승인) → suggest-batch-reqs. Design 단계에서 reversal/confirmation metadata 저장 위치가 migration 필요 여부를 확정한다. - -#### Validation Plan -- pytest TestClient/API tests로 AC-1~17 검증. repository/integration 또는 concurrency harness로 AC-18 검증. 기존 `/ledger/trades`, `GET /v1/portfolios`, CLI/domain 흐름 regression으로 AC-19 검증. - - -### CHG-20260714-02 -- Date: 2026-07-14 -- Author: TREX lead (Hermes) - -#### Change Summary -- Status: Proposed → Approved (confirm-req). dcy-012 confirmed Discovery와 AC-1~19가 test 가능한 구현 기준으로 충분하다고 판정한다. - -#### Intent -- owner-scoped manual ledger API를 batch delivery 기준으로 확정한다. - -#### Acceptance Criteria Delta -- Added: 없음 -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- 변경 없음. 구현 단계에서 docs/interface-contract.md, docs/runtime-flows.md, docs/data-model.md, API/schema/domain/repository/auth/tests를 갱신한다. - -#### Delivery Trace Delta -- Batches: BAT-024 후보로 제안 -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: proceed-to-batch -- Follow-up Action: suggest-batch-reqs → draft-batch/planning/design/implementation/verification. - -#### Validation Plan -- AC-1~19를 batch verification에서 API/repository/concurrency/regression evidence로 검증한다. - -### CHG-20260715-03 -- Date: 2026-07-15 -- Author: TREX lead (Hermes) - -#### Change Summary -- 운영 적용 방식 보완을 REQ-057로 분리 추적한다. REQ-056의 기존 API 계약, AC-1~19, `Implemented` status 및 BAT-024 `release-candidate` 상태는 변경하거나 되돌리지 않는다. - -#### Intent -- REQ-056 구현에서 사용된 additive `portfolio_ledger` 컬럼의 운영 DB 적용을 runtime `ensure_schema()` 자동 DDL에만 의존하지 않도록 명시 Alembic migration과 운영 runbook 요구사항으로 후속 관리한다. - -#### Acceptance Criteria Delta -- Added: 없음 (REQ-056 AC 변경 없음) -- Updated: 없음 -- Removed: 없음 -- Follow-on: REQ-057 AC-1~11이 migration, schema/Alembic revision 정합, 운영 preflight/runbook, forward-only recovery, release 결재 게이트를 별도로 정의한다. - -#### Impacted Area Delta -- REQ-056 구현 영향 범위는 변경 없음. -- 후속 REQ-057 영향: `migrations/versions/`, runtime `ensure_schema()` 호환성, migration/runbook 및 persistence baseline 문서. 실제 변경은 BAT-025 standard에서 수행한다. - -#### Delivery Trace Delta -- Batches: BAT-024는 REQ-056 구현/검증 release-candidate 상태로 유지. BAT-025 standard가 REQ-057 migration implementation/test/QC/runbook을 담당한다. -- Releases: 운영 DB upgrade, 서비스 재시작 및 신규 manual ledger API 활성화는 별도 명시 release 결재 전 실행하지 않는다. - -#### Revalidation Impact -- Reverification Needed: no — REQ-056의 기존 implementation/QC evidence는 무효화하지 않는다. -- Existing Implementation Invalidated: no -- Status Recommendation: keep-Implemented -- Follow-up Action: REQ-057 Proposed 검토·승인 후 BAT-025에서 migration compatibility/data preservation/runbook evidence를 확보한다. - -#### Validation Plan -- BAT-025에서 fresh DB와 runtime-precreated-schema 환경의 Alembic upgrade, 기존 row/source 보존, `alembic current` 정합, broker/manual ledger regression, runbook preflight 및 service smoke를 검증한다. 운영 DB 실행은 별도 release 결재 범위다. - - -## Implementation Confirmation - -- Confirmed: 2026-07-14 KST -- Implementing Batch: BAT-024 — `docs/batches/bat-024_20260714_manual-ledger-api/` -- Implementation commit: `b3447f6 feat(api): add owner-scoped manual ledger endpoint` -- Developer verification: `594 passed, 12 warnings in 37.24s` -- Independent QC: PASS (`deleg_f2337a39`) — `594 passed, 12 warnings in 37.29s`; focused remediation tests `4 passed`. -- Lead gate: `confirm-req-implemented` accepted. -- Release caveat: production DB rollout needs release-gate handling for additive `portfolio_ledger` columns (`source`, `target_ledger_entry_id`, `confirmation_evidence`, `actor_user_id`), preferably Alembic migration/runbook instead of relying only on `ensure_schema()`. diff --git a/docs/srs/Migration/req-006_portfolio-tags-data-migration.md b/docs/srs/Migration/req-006_portfolio-tags-data-migration.md deleted file mode 100644 index 8d16490..0000000 --- a/docs/srs/Migration/req-006_portfolio-tags-data-migration.md +++ /dev/null @@ -1,135 +0,0 @@ -# REQ-006: portfolio_tags 데이터 마이그레이션 - -- Status: Implemented -- Type: Migration -- Priority: High -- Owner: Justin Ko - -## Intent -- 기존 portfolio_tags.tag_value 자유 문자열 데이터를 tags 마스터 테이블로 이전하고, portfolios.tag_ids UUID[] 배열을 채운 후 portfolio_tags 테이블을 제거한다. -- 소문자 정규화 후 UNIQUE 충돌을 방지하고, 마이그레이션 전 지정 admin 계정의 존재 여부를 사전 검증하여 데이터 무결성을 확보한다. -- 마이그레이션된 기존 태그는 사용자 포트폴리오에서 유래했으므로 is_admin_tag=FALSE로 설정한다. - -## Requirement -- 마이그레이션 스크립트는 실행 전 운영자가 지정한 admin 계정 ID가 users 테이블에 존재하는지 검증해야 한다. 존재하지 않으면 오류와 함께 중단한다. -- portfolio_tags.tag_value를 소문자 정규화(str.lower()) 후 tags 테이블에 INSERT해야 한다. ON CONFLICT DO NOTHING으로 중복을 무시한다. -- 모든 INSERT는 is_admin_tag=FALSE, created_by_user_id=운영자 지정 admin 계정 ID로 설정해야 한다. -- 각 portfolio의 portfolio_tags 행을 순회하여 해당 tag_value의 tag_id를 조회하고 portfolios.tag_ids 배열에 추가해야 한다. -- 데이터 이전 완료 후 portfolio_tags 테이블을 DROP해야 한다. -- 마이그레이션은 Alembic revision으로 관리하여 재실행 안전성(idempotent within revision)을 보장해야 한다. - -## Acceptance Criteria -1. Given portfolio_tags 행이 존재하고 유효한 admin 계정 ID 지정, When 마이그레이션 실행, Then tags 테이블에 소문자 정규화된 DISTINCT tag_value 수만큼 행이 생성되고 모두 is_admin_tag=FALSE, created_by_user_id=지정 admin ID이다. -2. Given portfolio_tags에 "TestTag"와 "testtag"가 존재, When 마이그레이션 실행, Then tags 테이블에 "testtag" 행이 하나만 생성된다 (ON CONFLICT DO NOTHING). -3. Given 마이그레이션 실행 후, When 각 portfolio의 tag_ids를 조회, Then 기존 portfolio_tags에서 해당 portfolio에 연결된 tag_value들의 tag_id가 모두 포함된다. -4. Given 마이그레이션 실행 후, When DB 스키마를 조회, Then portfolio_tags 테이블이 존재하지 않는다. -5. Given 유효하지 않은 admin 계정 ID(users 테이블에 없음) 지정, When 마이그레이션 실행, Then 데이터 변경 없이 오류와 함께 중단된다. -6. Given 마이그레이션 전 DISTINCT tag_value 수(소문자 정규화 기준) = N, When 마이그레이션 실행 후 tags 테이블 행 수 조회, Then N이다. - -## Impacted Area -- Docs: docs/data-model.md (portfolio_tags 테이블 제거 반영) -- Modules: - - migrations/versions/ — 데이터 마이그레이션 Alembic revision (태그 이전, portfolios.tag_ids 채우기, portfolio_tags DROP) -- Tests: - - 마이그레이션 전후 DB 상태 SQL 검증 - - 잘못된 admin 계정 ID 시 중단 확인 - - DISTINCT 수 일치 확인 - -## Notes -- 운영자는 마이그레이션 실행 전 admin 계정 ID를 확인하여 마이그레이션 스크립트에 명시한다. -- 마이그레이션된 태그의 is_admin_tag=FALSE는 의도적 설계: 기존 portfolio_tags는 사용자 포트폴리오에서 유래했으며 asset 태그 적용 대상이 아니다. -- REQ-003의 portfolios.tag_ids 컬럼 추가가 이 REQ 선행 조건이다. -- 마이그레이션 완료 후 별도 소유권 재지정 스크립트 제공은 R-1 완화 방안으로, 이 REQ 범위 밖이다. -- Source: dcy-002 FR-5, R-1, R-3, A-2 - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-003 confirm-batch-verification 완료. portfolio_tags → tags 마스터 데이터 마이그레이션 Alembic revision 구현. DB-integration AC(1~6)는 실 DB 의존 — CI에서 migration 파일 구조 검증으로 대체, release 배포 시 alembic upgrade head로 최종 확인 예정. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-003 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL-001 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인 - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-002 confirm-discovery 후 REQ 초안 작성 - -#### Intent -- dcy-002 FR-5를 Migration REQ로 분리하여 독립 구현·검증 단위로 정규화 - -#### Acceptance Criteria Delta -- Added: - - AC-1~6 최초 작성 - -#### Impacted Area Delta -- Docs: - - docs/data-model.md — portfolio_tags 제거 -- Modules: - - migrations/versions/ — 최초 지정 -- Tests: - - 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-001, REQ-003 선행 의존성 확인 diff --git a/docs/srs/Migration/req-014_portfolios-data-migration.md b/docs/srs/Migration/req-014_portfolios-data-migration.md deleted file mode 100644 index e3f93eb..0000000 --- a/docs/srs/Migration/req-014_portfolios-data-migration.md +++ /dev/null @@ -1,137 +0,0 @@ -# REQ-014: portfolios 데이터 migration - -- Status: Implemented -- Type: Migration -- Priority: High -- Owner: Justin Ko - -## Intent -- 기존 `portfolios` 테이블의 운영 데이터를 `assets` catalog + `portfolios` 포지션 구조로 유실 없이 이전한다. -- migration은 단일 트랜잭션 내에서 원자적으로 실행되어야 하며 실패 시 롤백 가능해야 한다(NFR-1). - -## Requirement -- Alembic revision으로 아래 데이터 이전을 수행한다. - - 기존 `portfolios` 행의 `symbol`을 기준으로 중복 없이 `assets` catalog 항목을 생성한다. 동일 symbol의 모든 `market` 값을 `ARRAY_AGG(DISTINCT market)`으로 집약하여 `assets.markets`에 저장한다. market 값이 NULL인 기존 행은 빈 배열로 처리한다. - - 각 portfolio 행에 생성된 asset의 `asset_id FK`를 연결한다. - - `portfolios.managed_by_daemon_id`는 기존 값을 그대로 유지한다. -- migration 전 `SELECT symbol, ARRAY_AGG(DISTINCT market) FROM portfolios GROUP BY symbol`로 symbol별 market 집합을 파악하는 사전 검증 쿼리를 migration script에 포함한다. -- 전체 이전 작업은 단일 트랜잭션으로 실행하며, 중간 실패 시 rollback되어 기존 상태로 복원된다. -- migration 완료 후 `portfolios.symbol`, `portfolios.market` 컬럼을 제거한다(REQ-011과 동일 revision 또는 순서 보장된 revision에서 처리). - -## Acceptance Criteria -1. Given migration 실행 전, When `SELECT COUNT(*) FROM portfolios`로 기존 행 수 확인, Then migration 후 동일 행 수가 유지된다. -2. Given migration 실행 후, When `SELECT COUNT(*) FROM assets`를 확인, Then 기존 portfolios의 고유 symbol 수와 일치하는 assets 행이 존재한다. -3. Given migration 실행 후, When 임의 portfolio 행의 `asset_id`로 `assets` 테이블을 JOIN 조회, Then 해당 asset의 symbol과 migration 전 portfolio의 symbol이 일치한다. -4. Given migration 실행 후, When `assets.markets`를 확인, Then 동일 symbol을 가졌던 모든 market 값이 배열에 포함되어 있다. -5. Given migration 실행 중 오류 발생 시뮬레이션, When rollback 후 상태 확인, Then 기존 portfolios 데이터가 원상태로 보존된다. -6. Given migration 완료 후, When `portfolios` 테이블 스키마 확인, Then `symbol`, `market` 컬럼이 존재하지 않는다. - -## Impacted Area -- Docs: 없음 (docs 갱신은 REQ-015에서 처리) -- Modules: - - `migrations/versions/` — portfolios 데이터 이전 + symbol/market 컬럼 제거 Alembic revision -- Tests: - - `tests/test_asset_catalog.py` (신규) — migration AC-1~6 검증 - - 기존 테스트 suite — migration 후 regression 검증 - -## Notes -- R-1(High): 동일 symbol을 가진 여러 portfolio 행의 market 값이 서로 다를 수 있다. `ARRAY_AGG(DISTINCT market)` 집약 규칙을 migration script에 명시적으로 구현한다. -- A-1(가정): 동일 symbol → 단일 assets 항목 병합, ARRAY_AGG(DISTINCT market) 집약, broker → 각 portfolio 행 유지. -- migration 전 `SELECT COUNT(*) FROM portfolios WHERE managed_by_daemon_id IS NULL`로 미배정 portfolio 현황을 파악하고, 필요한 경우 migration 전 daemon 배정 절차를 선행한다(R-3). -- REQ-009(assets 테이블), REQ-011(portfolios 재설계) 선행 필요. -- Source: dcy-001 FR-6, NFR-1, R-1, A-1 - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-001 confirm-batch-verification 완료. portfolios → assets catalog 데이터 이전 Alembic revision 구현. DB-integration ACs(1~6)는 실 DB 의존 — CI에서 migration 파일 구조 검증으로 대체, release 배포 시 alembic upgrade head로 최종 확인 예정. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-001 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL-001 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인 - -### CHG-20260519-02 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260519-01 -- Date: 2026-05-19 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행 - -#### Intent -- dcy-001 FR-6, NFR-1을 Migration REQ로 분리; portfolios 데이터 이전 및 원자적 실행 정의 - -#### Acceptance Criteria Delta -- Added: - - AC-1~6 최초 작성 - -#### Impacted Area Delta -- Docs: - - 없음 -- Modules: - - migrations/versions/ — 최초 지정 -- Tests: - - tests/test_asset_catalog.py, 기존 테스트 suite — 최초 지정 - -#### Delivery Trace Delta -- Batches: - - none (미정) -- Releases: - - none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-009, REQ-011 선행 의존성 확인 - -#### Validation Plan -- pytest로 AC-1~6 검증; 트랜잭션 rollback은 savepoint 또는 테스트 DB 격리로 시뮬레이션 diff --git a/docs/srs/Migration/req-017_service-token-rotate-unique-index-fix.md b/docs/srs/Migration/req-017_service-token-rotate-unique-index-fix.md deleted file mode 100644 index 6fd9758..0000000 --- a/docs/srs/Migration/req-017_service-token-rotate-unique-index-fix.md +++ /dev/null @@ -1,128 +0,0 @@ -# REQ-017: service-token rotate unique index 보강 - -- Status: Implemented -- Type: Migration -- Priority: High -- Owner: Justin Ko - -## Intent -- 현행 `idx_auth_tokens_service_name_unique` partial index가 `WHERE token_type='service'` 조건만 적용해 revoked row와 새 row가 같은 name으로 unique 충돌을 일으켜 `pfhub service-token rotate`가 실 DB에서 insert 실패하는 결함을 정상화한다(REL-006 verification 실패 기록). -- index 조건에 `AND revoked_at IS NULL`을 추가해 active service token name만 unique 유지하도록 보강한다. - -## Requirement -- Alembic migration 신규 작성: 기존 `idx_auth_tokens_service_name_unique`를 drop하고 `(name)` partial index를 `WHERE token_type='service' AND revoked_at IS NULL` 조건으로 재생성한다. -- migration은 `CREATE INDEX CONCURRENTLY` 적용으로 운영 lock 영향을 최소화한다(autocommit 외부 트랜잭션 필요 — Alembic op.execute로 직접 SQL 실행). -- `app/repositories/postgres.py:161-167` `ensure_schema()`의 `idx_auth_tokens_service_name_unique` 정의도 동일 조건으로 갱신한다. -- 기존 active service token 행 데이터에는 변경 없음. 데이터 backfill 불요. -- rotate 동작은 기존 코드(`app/services/auth_service.py:rotate_service_token`, `app/repositories/postgres.py:1502-1549`)를 변경하지 않으며, index 보강만으로 정상 작동해야 한다. - -## Acceptance Criteria -1. Given 신규 Alembic revision 적용 후, When `pfhub service-token issue NAME --user U --no-expiry` → `pfhub service-token rotate NAME --force` 시퀀스 실행, Then 두 명령 모두 unique 위반 없이 성공한다(실 DB `docker-compose.local-test.yml` 환경). -2. Given AC-1 시퀀스 실행 후, When `SELECT name, token_type, revoked_at FROM auth_tokens WHERE name = ''`, Then revoked row 1건 + active row 1건이 존재한다. -3. Given AC-1 rotate 결과, When 새 token의 `runtime_id`와 `daemon_id`를 확인, Then runtime_id는 갱신, daemon_id는 유지(rotate invariant 보존). -4. Given `ensure_schema()` 단독 실행, When 새 partial index 조건이 적용, Then 동일 조건(`WHERE token_type='service' AND revoked_at IS NULL`)이 PostgreSQL에 반영된다. -5. Given migration이 운영 환경에서 적용, When CREATE INDEX CONCURRENTLY가 실행, Then auth_tokens write 작업이 long-running lock 없이 진행된다(staging 환경 사전 검증). - -## Impacted Area -- Docs: - - `docs/data-model.md` — auth_tokens 인덱스 변경 반영 (REQ-019에서 처리) -- Modules: - - `migrations/versions/` — 신규 Alembic revision (예: `20260520_NNNN_service_token_unique_index_fix.py`). `op.execute("DROP INDEX IF EXISTS ...")` + `op.execute("CREATE UNIQUE INDEX CONCURRENTLY ... WHERE token_type='service' AND revoked_at IS NULL")` 패턴 - - `app/repositories/postgres.py:161-167` — `ensure_schema()`의 idx 정의 조건 갱신 -- Tests: - - 신규 `tests/test_service_token_rotate_integration.py` (실 DB 의존 마킹) — AC-1~3 검증 - - 기존 `tests/test_daemon_registry.py::test_rotate_*` — 신규 index 조건에서 통과 확인 - -## Notes -- 이 결함은 REL-006 verification에 "rotate insert 막힘" 검증 실패로 이미 기록됨([temp/20260519_old_sdlc/releases/rel-006_20260513_daemon-model-implementation.md:74](temp/20260519_old_sdlc/releases/rel-006_20260513_daemon-model-implementation.md#L74)). -- CREATE INDEX CONCURRENTLY는 transactional block 밖에서 실행 필요. Alembic revision에서 `autocommit_block`을 사용하거나 `op.execute()`로 직접 SQL 실행해야 한다. -- 기존 운영 환경에 revoked service token이 있다면 새 partial index 생성 시점에 자동 적용되며, 별도 데이터 정리 불요. -- Source: dcy-004 FR-2, dcy-004 NFR-2/NFR-3/NFR-6, dcy-004 OQ-2 결정(partial 조건 추가). - -## Change Log - -### CHG-20260521-01 -- Date: 2026-05-21 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-006 confirm-batch-verification 완료. idx_auth_tokens_service_name_unique partial index 조건 `AND revoked_at IS NULL` 보강 Alembic revision + ensure_schema() 갱신 구현. AC-1~3/5(실 DB issue/rotate 시퀀스 및 staging lock 검증)는 실 DB 의존 — release 배포 시 최종 확인 예정. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-006 -- Releases: - - REL-001 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL-001 배포 시 실 DB에서 issue → rotate 시퀀스로 AC-1~3 최종 확인 - -### CHG-20260520-02 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음 - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 - -### CHG-20260520-01 -- Date: 2026-05-20 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-2 (rotate unique index 보강) 산출물. - -#### Intent -- rotate 실 DB 실패 결함 해소 + R-2(migration lock) 완화 적용. - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: docs/data-model.md (REQ-019에서 처리) -- Modules: migrations/versions/, app/repositories/postgres.py — 최초 지정 -- Tests: tests/test_service_token_rotate_integration.py (신규), tests/test_daemon_registry.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; staging 환경에서 CONCURRENTLY migration dry-run으로 lock 시간 측정 - -#### Validation Plan -- 실 DB(`docker-compose.local-test.yml`) 환경에서 AC-1~3 시퀀스 검증; staging 환경에서 AC-5 lock 측정 diff --git a/docs/srs/Migration/req-033_ledger-trades-asset-id-to-portfolio-id-rename.md b/docs/srs/Migration/req-033_ledger-trades-asset-id-to-portfolio-id-rename.md deleted file mode 100644 index aced67a..0000000 --- a/docs/srs/Migration/req-033_ledger-trades-asset-id-to-portfolio-id-rename.md +++ /dev/null @@ -1,132 +0,0 @@ -# REQ-033: ledger_trades.asset_id → portfolio_id 컬럼명 정합화 - -- Status: Implemented -- Type: Migration -- Priority: Medium -- Owner: Justin Ko - -## Intent -- `ledger_trades` 테이블의 `asset_id` 컬럼이 실제로는 portfolio_id를 저장하지만 컬럼명이 의미와 불일치하여 schema reader가 assets 테이블 참조로 오해할 수 있다. -- REQ-029 구현(POST /v1/portfolios/{id}/ledger/trades 이전) 시점의 잔여 명명 정합화를 마무리한다. - -## Requirement -- Alembic 신규 revision을 작성해 `ledger_trades.asset_id` 컬럼을 `portfolio_id`로 rename한다(`ALTER TABLE ledger_trades RENAME COLUMN asset_id TO portfolio_id`). -- 기존 데이터는 동일 의미(portfolio_id 저장)이므로 backfill 불요. -- `app/repositories/postgres.py`의 ledger_trades INSERT/SELECT/RETURNING SQL에서 컬럼명을 `portfolio_id`로 갱신한다. -- `app/domain/models.py`의 `LedgerTrade` dataclass에 `asset_id` 필드가 있다면 `portfolio_id`로 rename한다(있는 경우). -- `app/api/v1_portfolios.py` ledger route handler에서 응답에 `asset_id` 노출이 있다면 `portfolio_id`로 정합화한다(있는 경우). -- 마이그레이션 lock 시간을 운영 영향 < 1초로 유지하기 위해 짧은 단일 `ALTER COLUMN RENAME` 트랜잭션으로 처리한다. - -## Acceptance Criteria -1. Given 신규 Alembic revision 적용 후, When `\d ledger_trades` 실행, Then `portfolio_id` 컬럼이 존재하고 `asset_id` 컬럼은 존재하지 않는다. -2. Given AC-1 환경, When 기존 `POST /v1/portfolios/{id}/ledger/trades` 호출, Then INSERT/SELECT 회귀 테스트가 모두 통과한다. -3. Given codebase grep `grep -rn "ledger_trades\..*asset_id\|asset_id.*ledger_trades" app/repositories/postgres.py app/domain/models.py app/api/v1_portfolios.py`, When 본 REQ 구현 후, Then 결과는 0건이다. -4. Given 마이그레이션 staging dry-run, When `ALTER COLUMN RENAME` 실행 시간 측정, Then 측정 시간 < 1초 (NFR-1 정합). -5. Given 본 REQ 구현 후 전체 pytest 회귀, When 실행, Then 기존 통과 케이스 모두 유지 + ledger_trades 의존 케이스 통과. - -## Impacted Area -- Docs: - - `docs/data-model.md` — ledger_trades 컬럼 명세에 `portfolio_id` 반영, `asset_id` 표기 제거 - - (선택) `docs/interface-contract.md` — ledger 응답에 `asset_id` 노출이 있다면 `portfolio_id`로 갱신 -- Modules: - - `migrations/versions/` — 신규 revision (예: `20260601_NNNN_ledger_trades_portfolio_id_rename.py`) - - `app/repositories/postgres.py` — ledger_trades INSERT/SELECT/RETURNING 컬럼명 갱신([app/repositories/postgres.py:1284-1314](../../app/repositories/postgres.py#L1284-L1314)) - - `app/domain/models.py` — `LedgerTrade` dataclass 필드명 (있다면) - - `app/api/v1_portfolios.py` — ledger route handler 응답 매핑 (있다면) -- Tests: - - 갱신 `tests/test_v1_portfolios_ledger.py` — 컬럼명 변경 회귀 검증 - - (선택) 신규 마이그레이션 dry-run 회귀 - -## Notes -- 본 REQ는 dcy-005 FR-1 산출물. 정합화 묶음 trigger. -- 외부 client(trex-basic 등)는 HTTP API(`POST /v1/portfolios/{id}/ledger/trades`)만 호출하므로 DB 컬럼명 의존 없음 추정. 마이그레이션 전 외부 query 의존성 확인. -- 본 REQ는 별도 REL-002로 분리 배포(dcy-005 OQ-3 결정, 2026-06-01). -- Source: dcy-005 FR-1, REQ-029 Notes follow-up. - -## Change Log - -### CHG-20260601-03 -- Date: 2026-06-01 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-015 confirm-batch-verification 완료(2026-06-05). AC-2/3/5 PASSED, AC-1/4 staging Deferred(blocker 아님). 마이그레이션 + 코드/스키마/도메인/라우트/테스트/문서 정합화 모두 evidence 확보. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-015 (release-candidate) -- Releases: - - REL-002 (예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL-002 staging 시점에 AC-1 (`\d ledger_trades` 컬럼 검증) + AC-4 (마이그레이션 lock < 1초) evidence 수집. 본 워크스페이스 ledger_trades 테이블 미생성 상태 자체는 별도 후속 항목(BAT-011 mock verification 후속) 등록 후보. - -### CHG-20260601-02 -- Date: 2026-06-01 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC-1~5 구체성 확인 완료, blocker 없음. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-002 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 (BAT-015 후보) - -### CHG-20260601-01 -- Date: 2026-06-01 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-005 confirm-discovery 후 draft-req 실행. dcy-005 FR-1 산출물. - -#### Intent -- ledger_trades.asset_id 컬럼명을 의미에 맞게 portfolio_id로 정합화. - -#### Acceptance Criteria Delta -- Added: - - AC-1~5 최초 작성 - -#### Impacted Area Delta -- Docs: docs/data-model.md, (선택) docs/interface-contract.md — 최초 지정 -- Modules: migrations/versions/, app/repositories/postgres.py, app/domain/models.py, app/api/v1_portfolios.py — 최초 지정 -- Tests: tests/test_v1_portfolios_ledger.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-002 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; staging 환경에서 ALTER COLUMN RENAME dry-run으로 lock 시간 측정 - -#### Validation Plan -- pytest로 AC-1~5 검증 + staging dry-run AC-4 측정 diff --git a/docs/srs/Migration/req-036_portfolio-ledger-consolidation-migration.md b/docs/srs/Migration/req-036_portfolio-ledger-consolidation-migration.md deleted file mode 100644 index 2e619f1..0000000 --- a/docs/srs/Migration/req-036_portfolio-ledger-consolidation-migration.md +++ /dev/null @@ -1,175 +0,0 @@ -# REQ-036: portfolio_ledger 회계 원장 통합 마이그레이션 (ledger_trades 흡수/DROP) - -- Status: Implemented -- Type: Migration -- Priority: High -- Owner: Justin Ko - -## Intent -- 두 ledger 테이블(`portfolio_ledger` 회계 원장 + `ledger_trades` broker audit)이 공존하는 구조를 `portfolio_ledger` 단일 회계 원장으로 통합한다(dcy-006 OQ-6 (iv)). -- `ledger_trades`의 broker idempotency/식별 필드를 `portfolio_ledger`에 흡수하고 `ledger_trades` 테이블을 삭제해, 신 path(`POST /v1/portfolios/{id}/ledger/trades`)가 단일 회계 원장에 기록할 수 있는 스키마 기반을 마련한다. - -## Requirement -- Alembic 신규 revision을 작성한다. (head는 dcy-005 `20260601_0024` 다음) -- `portfolio_ledger`에 컬럼을 추가한다: - - `ledger_entry_id BIGINT GENERATED BY DEFAULT AS IDENTITY` (CHG-20260605-03: hypertable 제약상 단독 PRIMARY KEY 불가 — Identity 컬럼, 비-PK) - - `order_id TEXT NULL`, `execution_id TEXT NULL` (dcy-006 OQ-7 (ii): NULL 허용) - - `side TEXT NOT NULL` - - `executed_at TIMESTAMPTZ NOT NULL` -- **idempotency는 DB partial UNIQUE index가 아니라 application layer(REQ-037)에서 보장한다** (CHG-20260605-03). `portfolio_ledger`는 TimescaleDB hypertable(파티션 컬럼 `recorded_at`)이라 `(portfolio_id, order_id, execution_id)` partial unique index/PK 생성이 불가능하다(`cannot create a unique index without the column recorded_at`). 따라서 본 마이그레이션은 broker 식별 컬럼만 추가하고 unique 제약은 만들지 않는다. broker write dedup은 REQ-037의 `SELECT ... FOR UPDATE` 락 하 SELECT-before-insert로 처리. -- 수치 컬럼 타입을 DOUBLE PRECISION → NUMERIC(20,8)로 변경한다: `price`, `quantity`, `fee_amount`, `tax_amount`, `cash_balance_after`, `asset_quantity_after`, `total_value_after`, `profit_buffer_after`. -- 기존 `action` 컬럼을 제거하고 `side`로 통일한다. backfill 매핑: `action='BUY'` → `side='buy'`, `action='SELL'` → `side='sell'`. 그 외 자유형식 action 값의 처리(R-6)는 마이그레이션 본문에서 분포 확인 후 결정(개발 워크스페이스는 분포 단순). -- 기존 데이터 backfill: `recorded_at` → `executed_at` 복사, `order_id`/`execution_id`는 NULL(synthetic 생성 불요). -- 추가 인덱스 `idx_portfolio_ledger_portfolio_executed (portfolio_id, executed_at)` 신설. 기존 `idx_portfolio_ledger_portfolio_time (portfolio_id, recorded_at DESC)`은 유지. -- `ledger_trades` 테이블을 DROP한다. (본 워크스페이스 미생성이라 `DROP TABLE IF EXISTS`로 안전 처리. prod 데이터 보존 필요 시 backfill은 release 단계 별도 검토.) -- `app/repositories/postgres.py`의 `ensure_schema()` 내 `portfolio_ledger` 정의도 신 스키마와 동일하게 갱신한다. - -## Acceptance Criteria -1. Given 신규 Alembic revision 적용 후, When `\d portfolio_ledger` 확인, Then `ledger_entry_id`(Identity, 비-PK — CHG-20260605-03), `order_id`(NULL 허용), `execution_id`(NULL 허용), `side`(NOT NULL), `executed_at`(NOT NULL) 컬럼이 존재한다. -2. Given AC-1 환경, When `information_schema`로 수치 컬럼 타입 확인, Then `price`/`quantity`/`fee_amount`/`tax_amount`/`cash_balance_after`/`asset_quantity_after`/`total_value_after`/`profit_buffer_after`가 모두 NUMERIC(20,8)이다. -3. (CHG-20260605-03 재정의) Given AC-1 환경, When `portfolio_ledger` 인덱스/제약 확인, Then `(portfolio_id, order_id, execution_id)` 기반 **partial unique index/PK는 존재하지 않는다**(hypertable 제약). broker write idempotency는 REQ-037 application layer에서 보장되며, 실 DB 회귀(`tests/test_ledger_consolidation_integration.py::test_duplicate_broker_write_is_idempotent`)로 검증한다. -4. Given AC-1 환경, When `action` 컬럼 존재 확인, Then `action` 컬럼은 존재하지 않고 `side` 컬럼으로 대체되어 있다. -5. Given AC-1 환경, When `\d ledger_trades` 또는 테이블 목록 조회, Then `ledger_trades` 테이블이 존재하지 않는다. -6. Given 기존 portfolio_ledger 데이터(있는 경우), When 마이그레이션 적용, Then `executed_at`은 `recorded_at` 값으로 채워지고 `order_id`/`execution_id`는 NULL이며 `side`는 기존 action에서 매핑된다. -7. Given `ensure_schema()` 단독 실행, When portfolio_ledger 스키마 확인, Then 마이그레이션과 동일한 컬럼/제약/타입이 반영된다. - -## Impacted Area -- Docs: - - `docs/data-model.md` — `portfolio_ledger` entity에 신규 컬럼(ledger_entry_id Identity 비-PK, order_id/execution_id NULL, side, executed_at) + 정밀도 NUMERIC(20,8) 명세 추가. **idempotency는 application layer(REQ-037)이며 DB partial unique index/PK는 hypertable 제약상 없음**을 명시. `ledger_trades` entity 섹션 제거. 두 ledger 테이블 → 단일 원장 통합 명시. -- Modules: - - `migrations/versions/` — 신규 revision (예: `20260605_NNNN_portfolio_ledger_consolidation.py`). down_revision = `20260601_0024`. - - `app/repositories/postgres.py:263-282` — `ensure_schema()` portfolio_ledger 정의 갱신 -- Tests: - - 신규/갱신 마이그레이션 회귀 (alembic upgrade head 후 schema 검증) - - 기존 `tests/test_v1_portfolios_ledger.py` 등 ledger 의존 테스트가 신 스키마와 정합하도록 갱신 (REQ-037과 연동) - -## Notes -- 본 REQ는 dcy-006 FR-9/FR-10/FR-11/FR-12 산출물 + FR-5(data-model.md) 일부 흡수. -- REQ-037(신 path 회계 책임 + 응답)의 선행 — 본 마이그레이션이 스키마 기반을 제공한다. -- idempotency(broker write만 dedup, 레거시 write append-only)는 dcy-006 OQ-7 (ii) 결정이나, hypertable 제약으로 DB partial unique가 불가하여 application layer(REQ-037)에서 구현(CHG-20260605-03). -- R-5(DOUBLE→NUMERIC 정밀도)/R-6(action→side 매핑 누락)/R-7(DROP 트랜잭션)는 prod 마이그레이션 시 별도 검토. 본 워크스페이스는 개발용이라 영향 미미(workspace_purpose 메모리). -- 별도 REL(REL-003 등)로 분리 배포(dcy-006 A-4). -- Source: dcy-006 FR-9~12, OQ-6 (iv), OQ-7 (ii). - -## Change Log - -### CHG-20260605-04 -- Date: 2026-06-05 -- Author: Justin Ko - -#### Change Summary -- Status: Approved → Implemented - -#### Intent -- BAT-016 confirm-batch-verification 완료. portfolio_ledger 통합 마이그레이션 + ensure_schema 갱신 + ledger_trades DROP 구현. AC-1/2/4/5/6/7 검증, AC-3는 CHG-20260605-03 재정의(application-layer idempotency)로 실 DB 통합 테스트(`test_duplicate_broker_write_is_idempotent`)로 검증. 전체 376 passed. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: - - BAT-016 (release-candidate) -- Releases: - - REL-003 (예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented -- Follow-up Action: REL-003 staging에서 fresh-DB alembic 적용 + DOUBLE→NUMERIC 정밀도/action 분포 최종 검증 - -#### Validation Plan -- 실 DB 통합 테스트 + alembic schema 검증 (완료); prod staging은 release 단계 - -### CHG-20260605-03 -- Date: 2026-06-05 -- Author: Justin Ko - -#### Change Summary -- AC/Requirement 재정의: `portfolio_ledger`가 TimescaleDB hypertable임이 BAT-016 verify에서 확인되어, dcy-006 OQ-7 (ii)의 "DB partial UNIQUE index + ledger_entry_id PK"가 물리적으로 불가능(`cannot create a unique index without the column recorded_at`). idempotency를 application layer(REQ-037 SELECT-before-insert under FOR UPDATE)로 이관. Status는 Approved 유지(구현 진행 중 설계 정합 보정). - -#### Intent -- 설계(DB 제약 idempotency)와 hypertable 현실의 충돌 해소. 마이그레이션은 broker 컬럼만 추가하고 unique 제약/PK는 생성하지 않으며, dedup은 application layer가 담당하도록 정합화. - -#### Acceptance Criteria Delta -- Updated: - - AC-1: `ledger_entry_id(PK)` → `ledger_entry_id(Identity, 비-PK)` - - AC-3: "partial UNIQUE index 존재" → "partial unique index/PK 미존재(hypertable 제약), idempotency는 REQ-037 application layer + 실 DB 통합 테스트로 검증" - -#### Impacted Area Delta -- Modules: 변경 없음(이미 신규 revision + ensure_schema 지정). 마이그레이션 본문에서 partial unique index 생성 제거 반영. -- Tests: `tests/test_ledger_consolidation_integration.py`(실 DB) 추가 — idempotency 검증 이관. - -#### Delivery Trace Delta -- Batches: BAT-016 (in-delivery) -- Releases: none (REL-003 등 분리 예정) - -#### Revalidation Impact -- Reverification Needed: yes (BAT-016 verification은 실 DB 통합 테스트로 재수행) -- Existing Implementation Invalidated: no (구현이 본 재정의에 맞춰 수정됨) -- Status Recommendation: keep-current (Approved 유지) -- Follow-up Action: BAT-016 verification 실 DB evidence 반영 - -#### Validation Plan -- alembic schema 검증(AC-1/2/4/5/6/7) + 실 DB idempotency 통합 테스트(AC-3 대체) - -### CHG-20260605-02 -- Date: 2026-06-05 -- Author: Justin Ko - -#### Change Summary -- Status: Proposed → Approved - -#### Intent -- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC-1~7 구체성(DB schema 검증) 확인 완료, blocker 없음. - -#### Acceptance Criteria Delta -- No change - -#### Impacted Area Delta -- No change - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-003 등 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 (BAT-016 후보, REQ-037/038 선행) - -### CHG-20260605-01 -- Date: 2026-06-05 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-006 confirm-discovery 후 draft-req 실행. dcy-006 FR-9~12 산출물. - -#### Intent -- portfolio_ledger/ledger_trades 두 ledger 테이블을 portfolio_ledger 단일 회계 원장으로 통합하는 스키마 마이그레이션 정의. - -#### Acceptance Criteria Delta -- Added: - - AC-1~7 최초 작성 - -#### Impacted Area Delta -- Docs: docs/data-model.md — 최초 지정 -- Modules: migrations/versions/, app/repositories/postgres.py — 최초 지정 -- Tests: 마이그레이션 회귀, tests/test_v1_portfolios_ledger.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정 — REL-003 등 분리 예정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 계획 편입; staging에서 DOUBLE→NUMERIC + action→side backfill dry-run - -#### Validation Plan -- alembic upgrade head 후 schema 검증(AC-1~7); 개발 DB에서 ensure_schema 정합 확인 diff --git a/docs/srs/Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md b/docs/srs/Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md deleted file mode 100644 index 4b2e6d5..0000000 --- a/docs/srs/Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md +++ /dev/null @@ -1,159 +0,0 @@ -# REQ-047: broker_credentials 테이블 daemon-scoped PK 전환 마이그레이션 - -- Status: Implemented -- Type: Migration -- Priority: High -- Owner: Justin Ko - -## Intent -- 현재 `broker_credentials` 테이블의 PK는 `(user_id, broker)`로, 같은 user의 두 데몬이 서로 다른 KIS 계좌를 가질 수 없다. PK를 `(daemon_id, broker)`로 전환하여 인증정보를 daemon-scoped로 소유하게 한다. -- dcy-010에서 결정된 단계적 마이그레이션 경로(nullable `daemon_id` 컬럼 추가 → 백필(REQ-048) → PK 전환)에서, 이 REQ는 스키마 변경 단계를 담당한다. PK 전환 전 데이터 백필은 REQ-048에서 별도로 수행한다. -- 이 마이그레이션은 비가역 PK 변경을 포함하는 고위험 작업으로, 팀장/사장님 결재 게이트(외부 비가역 작업)를 거쳐야 한다. - -## Requirement -- Alembic 신규 revision을 작성한다. (down_revision은 현재 최신 head에서 파악) -- **Step 1 (nullable 컬럼 추가)**: `broker_credentials`에 `daemon_id TEXT NULL` 컬럼을 추가한다. 기존 데이터는 `daemon_id=NULL` 상태로 남는다(백필은 REQ-048이 별도 처리). -- **Step 2 (PK 전환 준비)**: 백필 완료(REQ-048) 이후 적용할 별도 revision을 작성한다. 이 revision에서: - - 기존 PK 제약 `(user_id, broker)`를 삭제한다. - - `daemon_id NOT NULL` 제약을 추가한다. - - 새 PK 제약 `(daemon_id, broker)`를 추가한다. - - `user_id` 컬럼은 감사/이력 목적으로 유지한다(삭제 금지). -- `app/repositories/postgres.py`의 `ensure_schema()` 내 `broker_credentials` 정의를 최종 daemon-scoped 스키마(daemon_id NOT NULL, PK=(daemon_id, broker))와 정합하게 갱신한다. 단, `ensure_schema()`의 갱신은 PK 전환 revision 적용 이후 활성화한다. -- downgrade 함수는 PK 전환 이전 상태로 되돌릴 수 있도록 작성한다(daemon_id 컬럼 제거, 기존 PK 복원). 단, PK 전환 revision의 downgrade는 데이터 손실 위험이 있어 실 운영 적용 전 롤백 스크립트를 별도 제공한다. - -## Acceptance Criteria -1. Given Step 1 revision 적용 후, When `\d broker_credentials` 확인, Then `daemon_id TEXT NULL` 컬럼이 존재하고 기존 PK `(user_id, broker)` 제약이 그대로 유지된다. -2. Given Step 1 revision 적용 후, When 기존 행을 조회, Then `daemon_id`가 NULL인 상태로 데이터가 보존된다. -3. Given Step 2(PK 전환) revision 적용 후 (REQ-048 백필 완료 이후), When `\d broker_credentials` 확인, Then PK 제약이 `(daemon_id, broker)`로 변경되고 `daemon_id NOT NULL` 제약이 존재한다. -4. Given AC-3 환경, When `user_id` 컬럼 존재 확인, Then `user_id` 컬럼이 여전히 존재한다(삭제되지 않음). -5. Given AC-3 환경, When `ensure_schema()` 단독 실행, Then `broker_credentials` 스키마가 마이그레이션과 동일하다(daemon_id NOT NULL, PK=(daemon_id, broker), user_id 존재). -6. Given Step 1 revision의 downgrade 실행, When `\d broker_credentials` 확인, Then `daemon_id` 컬럼이 제거되고 기존 PK `(user_id, broker)` 제약이 복원된다. -7. Given Step 2 revision의 downgrade 실행, When 실행, Then downgrade가 실패하거나 데이터 손실 경고를 명시적으로 표시한다(안전 장치 존재). - -## Impacted Area -- Docs: - - `docs/data-model.md` — `BrokerCredential` 엔티티 PK 변경(`(user_id, broker)` → `(daemon_id, broker)`), `user_id` 유지 명시, daemon-scoped 소유 정책 기술. -- Modules: - - `migrations/versions/` — Step 1 revision (nullable daemon_id 추가), Step 2 revision (PK 전환). down_revision은 현재 최신 head. - - `app/repositories/postgres.py` — `ensure_schema()` 내 `broker_credentials` 정의 갱신(PK 전환 revision 이후). -- Tests: - - alembic upgrade head 후 schema 검증(AC-1~5). - - Step 1 downgrade 테스트(AC-6). - - 기존 broker credential CRUD 회귀 테스트(마이그레이션 전후 데이터 보존). - -## Notes -- 이 마이그레이션은 **PK 변경**이라는 비가역 작업을 포함한다. 팀장/사장님 결재 게이트 대상이다(dcy-010 R-1, §범위 경계 — "DB 마이그레이션"은 외부 비가역 작업). 실 운영 DB 적용 전 반드시 결재를 받는다. -- Step 1과 Step 2를 별도 revision으로 분리하는 이유: REQ-048 백필이 완료된 후에만 PK 전환이 가능하기 때문이다. Step 1만 먼저 적용하고, REQ-048 백필 → Step 2 순서로 진행한다. -- `user_id` 컬럼을 유지하는 이유: 기존 인증정보의 소유자 이력 보존 및 admin 조회 기능 유지. -- daemon-scoped 강제(user 폴백 없음): dcy-010 OQ-1 결정. `daemon_id=NULL`인 인증정보는 PK 전환 이후 INSERT 불가(NOT NULL 제약). 기존 NULL 데이터는 REQ-048 백필 이후만 PK 전환이 가능하다. -- Step 2 downgrade는 daemon_id → user_id PK 복원 시 데이터 무결성을 보장하기 어려우므로, 운영 환경에서는 rollback 전 별도 데이터 검증이 필요하다. -- Source: dcy-010 FR-1, R-1 완화 방안, §4 C1. - -## Change Log - -### CHG-20260628-01 -- Date: 2026-06-28 -- Author: Justin Ko - -#### Change Summary -- 신규 생성 (Proposed) — dcy-010 FR-1 산출물 (마이그레이션 계층 분리). - -#### Intent -- dcy-010 FR-1의 스키마 PK 전환 작업을 독립 마이그레이션 REQ로 정규화한다. Step 1(nullable 추가)과 Step 2(PK 전환)를 별도 revision으로 분리하는 설계를 반영한다. - -#### Acceptance Criteria Delta -- Added: - - AC-1 ~ AC-7 전체 (신규 초안) -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: - - `docs/srs/Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md` 신규 생성 - - `docs/data-model.md` 갱신 예고 -- Modules: - - `migrations/versions/` (Step 1, Step 2 revision 신규), `app/repositories/postgres.py` 갱신 예고 -- Tests: - - alembic 스키마 검증, downgrade, CRUD 회귀 신규 - -#### Delivery Trace Delta -- Batches: 미정 (suggest-batch-reqs 대기) -- Releases: 미정 (비가역 — 사장님 결재 필요) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 배정. REQ-048(백필)과 순서 의존 관계 명시. - -#### Validation Plan -- alembic upgrade head 후 schema 검증(AC-1~5). Step 1 downgrade 검증(AC-6). 개발 DB에서 dry-run. - -### CHG-20260628-02 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req: `Proposed` → `Approved`. dcy-010 FR-1/R-1 추적. Step1/Step2 분리 + REQ-048 백필 사이 순서 정합. **Step2 PK 전환(실DB 적용)은 비가역 → 사장님 결재 게이트 대상**(Notes 명시 확인). blocker 없음. - -### CHG-20260628-03 -- Date: 2026-06-28 -- Author: 팀장(Claude) - -#### Change Summary -- BAT-020 PR #17 머지(mergeCommit d4acfd4) — Step1/Step2 마이그레이션 revision 코드가 포함됨. 그러나 **실 운영 DB 적용 및 실 PostgreSQL 통합검증이 비가역 사장님 결재 게이트로 미완** → Implemented 보류, `Approved` 상태 유지. - -#### Intent -- AC-3/4/5(Step2 적용 후 schema)는 real-DB evidence 대기 중. 결재·적용 시 동반수정 TODO: `ensure_schema()`·upsert ON CONFLICT를 (daemon_id, broker)로 갱신. Implemented 전환은 실 DB 적용 결재 이후 별도 수행. - -#### Acceptance Criteria Delta -- Added: 없음 -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- 없음 (상태 전환 보류 기록) - -#### Delivery Trace Delta -- Batches: BAT-020 (PR #17, mergeCommit d4acfd4) — revision 코드 머지됨 -- Releases: 미정 (Step2 실DB 적용은 사장님 결재 후) - -#### Revalidation Impact -- Reverification Needed: yes — Step2 실DB 적용 후 AC-3/4/5 re-evidence 필요 -- Existing Implementation Invalidated: no -- Status Recommendation: keep-approved (Implemented 보류) - -#### Validation Plan -- 사장님 결재 → Step1 실DB 적용 → REQ-048 백필 → Step2 실DB 적용 → AC-1~7 전건 real-DB evidence 확보 후 Implemented 전환. - -### CHG-20260628-04 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req-implemented: `Approved` → `Implemented`. - -#### Change Summary -- BAT-021 PR #18 머지(mergeCommit 7164f9e) — ensure_schema() broker_credentials 최종 daemon-scoped 스키마 갱신, _row_to_broker_credential 컬럼순서 정정, upsert ON CONFLICT (user_id,broker) → (daemon_id,broker) 수정, daemon 폴백 트랜잭션 abort 이슈(fallback 제거) 코드가 포함됨. -- 실 dev PostgreSQL 통합검증: Step1 → backfill → Step2 → downgrade 순서 전건 실행, AC-1~7 PASS. -- QC(opus) PASS-WITH-NOTES(blocker 0) 확인됨. - -#### Intent -- Implemented 전환. AC-3/4/5(Step2 PK 전환 적용 후 schema) real-DB evidence 확보 완료. -- **주의**: Implemented는 코드 + dev DB 통합검증 완료 기준. **Step2(PK 전환) 실 운영 DB 적용은 별도 release 결재 게이트**(런북 `docs/runbooks/bat-021-req047-step2-migration-runbook.md`, 배포순서 Step1→backfill→Step2→코드배포 엄수, alembic stamp 사전확인). 운영 적용 시 AC-3/4/5 실운영 evidence 재확보. - -#### Acceptance Criteria Delta -- Added: 없음 -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- 없음 (상태 전환 기록) - -#### Delivery Trace Delta -- Batches: BAT-021 (PR #18, mergeCommit 7164f9e) — 동반 코드 수정 + 통합검증 완료 -- Releases: 미정 (Step2 실 운영 DB 적용은 사장님 결재 후) - -#### Revalidation Impact -- Reverification Needed: no (dev DB AC-1~7 전건 PASS, 운영 적용 시 재확보 별도 필요) -- Existing Implementation Invalidated: no -- Status Recommendation: Implemented - -#### Validation Plan -- BAT-021 verification.md의 AC-1~7 전건 real-DB evidence 참조. 운영 적용 시 런북 절차 준수. diff --git a/docs/srs/Migration/req-048_broker-credentials-daemon-id-backfill.md b/docs/srs/Migration/req-048_broker-credentials-daemon-id-backfill.md deleted file mode 100644 index 6cb1570..0000000 --- a/docs/srs/Migration/req-048_broker-credentials-daemon-id-backfill.md +++ /dev/null @@ -1,121 +0,0 @@ -# REQ-048: 기존 user-scoped 인증정보 daemon_id 백필 - -- Status: Implemented -- Type: Migration -- Priority: High -- Owner: Justin Ko - -## Intent -- REQ-047 Step 1 적용 이후 `broker_credentials.daemon_id`가 NULL인 기존 행들을 운영자가 지정하는 daemon_id로 백필하여, REQ-047 Step 2(PK 전환)가 적용 가능한 상태로 만든다. -- dcy-010 FR-4(기존 user-scoped 인증정보를 손실 없이 daemon-scoped로 이전)의 데이터 이전 경로를 제공한다. 백필은 CLI 명령 또는 one-shot 마이그레이션 스크립트로 수행한다. - -## Requirement -- `pfhub credential backfill-daemon-id` CLI 명령(또는 동등한 alembic data migration revision)을 제공한다. 이 명령은 다음을 수행한다: - - `broker_credentials`에서 `daemon_id IS NULL`인 행을 조회한다. - - 각 행의 `user_id`에 대해 운영자가 제공한 매핑(`--user --daemon-id `)으로 `daemon_id`를 UPDATE한다. - - 백필 대상 행이 없으면 "이미 완료됨" 메시지를 출력하고 정상 종료한다. - - `--dry-run` 옵션을 제공하여 실제 UPDATE 없이 대상 행과 매핑 결과를 출력한다. -- 백필 완료 후 `daemon_id IS NULL`인 행이 0건임을 검증하는 단계를 명령 결과 또는 별도 verify 서브커맨드로 제공한다. -- 기존 행의 암호화된 인증정보(`encrypted_payload`, `account_ref`, `credential_version`, `last_rotated_at`)는 백필 과정에서 변경되지 않는다. -- `--force` 옵션으로 확인 프롬프트를 생략할 수 있다. - -## Acceptance Criteria -1. Given `broker_credentials`에 `daemon_id=NULL`인 행이 있고 `--user u1 --daemon-id d1` 매핑을 제공하면, When `backfill-daemon-id` 명령 실행, Then 해당 `user_id=u1(user.id)`에 해당하는 행의 `daemon_id`가 `d1`로 UPDATE된다. -2. Given AC-1 실행 후, When `broker_credentials`에서 `daemon_id IS NULL` 행 조회, Then 0건이 반환된다. -3. Given `--dry-run` 옵션으로 실행, When 백필 대상 행이 있으면, Then DB UPDATE 없이 대상 행과 예상 매핑이 출력되고 실제 데이터는 변경되지 않는다. -4. Given 백필 실행 중, When `encrypted_payload`, `account_ref`, `credential_version`, `last_rotated_at` 컬럼 확인, Then 백필 전후 값이 동일하다(데이터 손실 없음). -5. Given `daemon_id IS NULL`인 행이 없을 때, When `backfill-daemon-id` 명령 실행, Then "이미 완료됨" 메시지와 함께 정상 종료(exit 0)된다. -6. Given 존재하지 않는 user를 `--user` 옵션으로 지정, When 명령 실행, Then 오류 메시지와 함께 비정상 종료(exit 1)된다. -7. Given 백필 완료 후 REQ-047 Step 2(PK 전환) revision 적용, When `\d broker_credentials` 확인, Then PK `(daemon_id, broker)` 제약이 적용되고 NOT NULL 위반 없이 성공한다. - -## Impacted Area -- Docs: - - `docs/data-model.md` — `BrokerCredential` 데이터 이전 절차(백필 단계) 명시. -- Modules: - - `app/cli/commands/credential.py` — `backfill-daemon-id` 서브커맨드 추가. - - `app/repositories/postgres.py` — `update_broker_credential_daemon_id(user_id, daemon_id)` 또는 동등한 메서드 추가(또는 CLI에서 직접 쿼리). -- Tests: - - `backfill-daemon-id` 명령 단위 테스트(mock repo 기반): 정상 백필, dry-run, 이미 완료, 사용자 미존재 케이스. - - 백필 전후 `encrypted_payload` 동등성 검증(AC-4). - -## Notes -- 이 REQ는 **1회성 운영 작업**이다. 개발 환경에서는 테스트 목적으로 반복 실행 가능하도록 설계한다. -- 백필 매핑은 운영자가 직접 제공한다. "어떤 user가 어떤 daemon_id에 매핑되는가"는 인프라/운영 도메인 지식이 필요하며 자동화할 수 없다. -- CLI 구현 방식(subcommand 추가 vs alembic data migration revision)은 batch design 단계에서 확정한다. 어느 쪽이든 AC-1~7을 만족해야 한다. -- `--user` 옵션은 username으로 받고 내부에서 `get_user_by_username()`으로 user_id를 해석한다(기존 CLI 패턴과 동일). -- Source: dcy-010 FR-4, §4 C1(마이그레이션 단계적 경로). - -## Change Log - -### CHG-20260628-01 -- Date: 2026-06-28 -- Author: Justin Ko - -#### Change Summary -- 신규 생성 (Proposed) — dcy-010 FR-4 산출물. - -#### Intent -- dcy-010 FR-4(기존 user-scoped 인증정보 손실 없는 daemon-scoped 이전)를 독립 REQ로 정규화한다. REQ-047 Step 1과 Step 2 사이의 데이터 이전 경로를 담당한다. - -#### Acceptance Criteria Delta -- Added: - - AC-1 ~ AC-7 전체 (신규 초안) -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: - - `docs/srs/Migration/req-048_broker-credentials-daemon-id-backfill.md` 신규 생성 - - `docs/data-model.md` 갱신 예고 -- Modules: - - `app/cli/commands/credential.py` (backfill subcommand), `app/repositories/postgres.py` (업데이트 메서드) -- Tests: - - backfill CLI 단위 테스트 신규 - -#### Delivery Trace Delta -- Batches: 미정 (suggest-batch-reqs 대기) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 배정. REQ-047 Step 1 이후, Step 2 이전 순서 의존 관계 준수. - -#### Validation Plan -- pytest mock repo 기반 단위 테스트 AC-1~6. 개발 DB 통합: backfill → REQ-047 Step 2 순서 검증(AC-7). - -### CHG-20260628-02 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req: `Proposed` → `Approved`. dcy-010 FR-4 추적. dry-run/verify·암호화 payload 불변(AC-4) 적절. 구현방식(CLI subcommand vs alembic data revision)은 batch design 위임 — AC 무효화 아님(비차단). - -### CHG-20260628-03 -- Date: 2026-06-28 -- Author: 팀장(Claude) - -#### Change Summary -- confirm-req-implemented: `Approved` → `Implemented`. PR #17 머지(mergeCommit d4acfd4), BAT-020, QC PASS-WITH-NOTES(blocker 0), 테스트 통과. - -#### Intent -- BAT-020 delivery(PR #17 머지)에서 백필 CLI(backfill-daemon-id) 구현 및 AC-1~7 테스트 통과가 확인되어 Implemented로 전환한다. - -#### Acceptance Criteria Delta -- Added: 없음 -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- 없음 (상태 전환만) - -#### Delivery Trace Delta -- Batches: BAT-020 (PR #17, mergeCommit d4acfd4) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented - -#### Validation Plan -- BAT-020 verification 문서 및 QC PASS-WITH-NOTES 결과(blocker 0)로 AC-1~7 근거 확보됨. diff --git a/docs/srs/Migration/req-057_portfolio-ledger-manual-metadata-alembic-migration.md b/docs/srs/Migration/req-057_portfolio-ledger-manual-metadata-alembic-migration.md deleted file mode 100644 index 823094a..0000000 --- a/docs/srs/Migration/req-057_portfolio-ledger-manual-metadata-alembic-migration.md +++ /dev/null @@ -1,124 +0,0 @@ -# REQ-057: portfolio_ledger 수동 장부 메타데이터 명시 Alembic 마이그레이션 - -- Status: Approved -- Type: Migration -- Priority: High -- Owner: Justin Ko - -## Intent - -- REQ-056 수동 장부 API가 사용하는 `portfolio_ledger`의 additive 메타데이터 컬럼을 runtime `ensure_schema()` 자동 DDL에만 의존하지 않고, 추적 가능한 명시적 Alembic revision으로 운영 적용 가능하게 한다. -- 운영 DB 스키마와 Alembic revision 상태를 일치시키고, migration 검증 전에는 해당 신규 API 코드를 활성화하지 않는 배포 선행조건을 정의한다. - -## Requirement - -- `portfolio_ledger`에 아래 네 컬럼을 적용하는 신규 Alembic revision을 작성한다. - - | 컬럼 | SQL 타입 | NULL 정책 | 기본값/의미 | - | --- | --- | --- | --- | - | `source` | `TEXT` | `NOT NULL` | `DEFAULT 'broker'`; broker 기존 행과 broker write의 출처 | - | `target_ledger_entry_id` | `BIGINT` | `NULL` 허용 | `REVERSAL` target ledger entry 참조 | - | `confirmation_evidence` | `TEXT` | `NULL` 허용 | daemon-managed manual write의 명시적 확인 근거 | - | `actor_user_id` | `TEXT` | `NULL` 허용 | manual write를 수행한 사용자 추적 값 | - -- revision의 `down_revision`은 구현 시점에 repository의 실제 Alembic head를 `alembic heads` 및 migration graph로 사전 확인하여 결정한다. 요구사항 문서에서 특정 revision ID를 하드코드 전제로 단정하지 않는다. -- upgrade는 기존 `portfolio_ledger` 행을 삭제·재작성하지 않고 보존해야 하며, 기존 행과 `source`가 비어 있는 선행 상태의 행은 `source='broker'`를 만족하도록 처리한 뒤 `source NOT NULL DEFAULT 'broker'`를 보장해야 한다. -- runtime `ensure_schema()` 자동 DDL로 위 컬럼이 이미 선행 생성된 DB에서도 migration이 안전하게 완료되어야 한다. 구현은 컬럼/제약 존재 여부, 기존 행의 `source` 값, Alembic version table을 확인해 중복 DDL 오류 없이 revision을 적용 또는 필요한 상태 정합 절차를 수행해야 한다. -- migration 완료 후 `alembic current`가 구현 시 확정한 revision/head와 일치해야 한다. 스키마만 존재하고 Alembic revision이 불일치한 상태는 완료로 간주하지 않는다. -- 이 migration의 검증 완료는 운영 배포에서 REQ-056 신규 manual ledger API 코드를 활성화하기 위한 선행조건이다. 서비스 재시작·신규 코드 활성화는 migration upgrade와 schema/current 검증이 성공한 뒤에만 허용한다. -- 운영 runbook을 작성한다. runbook은 최소한 백업, `alembic current`, 대상 테이블 schema/row count, 활성 쿼리·lock 확인, upgrade, schema/current 재검증, service smoke, 실패 대응을 포함해야 한다. -- rollback은 code rollback + additive schema 유지의 forward-only 정책으로 정의한다. 운영 환경에서 down migration 또는 컬럼 삭제를 자동 rollback 경로로 사용하지 않는다. -- 운영 DB 실행은 이 REQ/BAT 범위에 포함하지 않는다. 실제 upgrade·서비스 활성화는 증적 검토 후 별도 명시 release 결재가 있어야 한다. -- migration implementation, test, 독립 QC 및 batch verification은 표준 배치 `BAT-025`에서 수행한다. 이 REQ 초안에서는 BAT-025 batch artifact 또는 migration/code 파일을 생성하지 않는다. - -## Acceptance Criteria - -1. Given implementation preflight, When Alembic migration을 생성하기 전 `alembic heads`와 revision graph를 확인하면, Then 실제 단일/유효 head를 근거로 신규 revision의 `down_revision`을 결정하며 REQ 본문에 특정 기존 revision ID를 선결 조건으로 하드코드하지 않는다. -2. Given migration이 적용되지 않은 DB의 `portfolio_ledger`, When 신규 revision을 upgrade하면, Then `source TEXT NOT NULL DEFAULT 'broker'`, `target_ledger_entry_id BIGINT NULL`, `confirmation_evidence TEXT NULL`, `actor_user_id TEXT NULL` 네 컬럼이 모두 존재한다. -3. Given upgrade 전 기존 `portfolio_ledger` 행이 존재하면, When 신규 revision을 upgrade하면, Then 기존 행 수와 기존 business data가 보존되고 모든 기존 행의 `source`는 `'broker'`이다. -4. Given 기존 행 또는 runtime 선행 DDL 상태에서 `source`가 NULL 또는 미설정이면, When migration이 완료되면, Then NULL `source`가 남지 않고 `source`는 `'broker'`로 backfill되며 이후 컬럼은 `NOT NULL DEFAULT 'broker'` 제약을 만족한다. -5. Given runtime `ensure_schema()`가 네 컬럼을 이미 생성한 DB, When Alembic migration을 적용하면, Then 중복 컬럼/제약 오류 없이 안전하게 완료되고 schema와 Alembic version 상태가 revision/head와 일치한다. -6. Given migration upgrade가 완료되면, When `alembic current`와 information schema/row count 검증을 실행하면, Then current revision은 구현 시 확정한 head이고 네 컬럼의 타입·nullable/default 및 row count 보존 결과가 기대값과 일치한다. -7. Given migration 전 preflight, When 운영 runbook을 수행하면, Then DB backup 성공, `alembic current` 기록, `portfolio_ledger` schema/row count 기록, 대상 테이블 활성 쿼리·lock 부재 또는 승인된 maintenance window가 모두 확인되지 않으면 upgrade 전에 중단한다. -8. Given upgrade와 사후 schema/current 검증이 성공하면, When 서비스 smoke를 실행하면, Then DB-enabled health와 REQ-056 manual ledger API의 승인된 non-production/운영 smoke 시나리오가 성공한다. migration 검증 실패 시 신규 API 코드 활성화 또는 서비스 재시작을 진행하지 않는다. -9. Given migration 또는 service smoke 이후 코드 문제가 발생하면, When rollback 판단을 내리면, Then 직전 호환 코드로만 rollback하고 적용된 네 additive 컬럼과 Alembic revision은 유지하는 forward-only 복구 정책을 따른다. 운영 down migration/컬럼 삭제는 자동 조치가 아니다. -10. Given 운영 DB 적용을 요청할 때, When 별도 release 결재가 없으면, Then 실제 DB upgrade, 서비스 재시작, 신규 API 활성화를 실행하지 않는다. -11. Given `BAT-025` delivery, When implementation/test/QC를 완료하면, Then Alembic upgrade 및 runtime-precreated-schema 호환성, data preservation, runbook dry-run/검증, 기존 broker ledger 흐름 회귀를 evidence로 기록하고 독립 QC를 거친다. - -## Impacted Area - -- Docs: - - `docs/runbooks/` — BAT-025 운영 migration runbook 신규 작성 예정. - - `docs/data-model.md` — `portfolio_ledger` 컬럼과 revision 기록 갱신 예정. - - `docs/releases/` — 운영 적용 시 별도 release 결재와 실행 증적 기록 예정. -- Modules: - - `migrations/versions/` — 네 컬럼을 위한 신규 Alembic revision 예정. - - `app/repositories/postgres.py` 또는 `ensure_schema()` 소유 모듈 — runtime 자동 DDL과 Alembic migration의 idempotent 호환성 검증/보강 예정. - - migration bootstrap/CLI 경로 — `alembic current` 및 upgrade 사전·사후 검증 경로 확인 예정. -- Tests: - - fresh/upgraded DB schema, 기존 행 보존과 `source='broker'` backfill 검증. - - runtime-precreated-schema → Alembic upgrade → current/head 정합 integration 검증. - - 기존 broker ledger write 및 REQ-056 manual ledger smoke/regression 검증. - - runbook preflight/forward-only recovery 절차의 문서 검증. - -## Notes - -- Source: REQ-056 Implementation Confirmation 및 `docs/srs/index.md` 2026-07-14 release caveat. REQ-056은 이미 Implemented/BAT-024 release-candidate이므로 본 REQ는 REQ-056 AC 또는 status를 되돌리지 않고 운영 적용 방식만 별도 추적한다. -- Repository 기록에는 과거 Alembic head로 보이는 revision이 있으나, 새 migration의 parent는 구현 착수 시점의 실제 head로 재검증해야 한다. -- 기존 runtime `ensure_schema()` 자동 DDL은 선행 스키마 생성 가능성을 만들 수 있으므로, 그 DB에서도 Alembic revision 추적을 회복·일치시키는 것이 핵심이다. 임의 `stamp` 여부와 조건은 BAT-025 design/implementation에서 실제 schema와 migration graph 증거로 결정한다. -- 운영 DB 실행, 배포, 서비스 재시작, commit/push는 이번 요구사항 초안 범위에 포함하지 않는다. - -## Change Log - -### CHG-20260715-01 -- Date: 2026-07-15 -- Author: portfolio-hub-dev (Hermes) - -#### Change Summary -- 신규 생성 (Proposed) — REQ-056 운영 release caveat에 대한 change requirement. `portfolio_ledger` manual ledger 메타데이터 네 컬럼을 명시 Alembic migration과 운영 runbook으로 관리하는 범위를 정의한다. - -#### Intent -- runtime 자동 DDL만으로 생성될 수 있는 additive 컬럼을 Alembic revision 및 운영 증적으로 관리해, 스키마와 version history의 불일치를 방지한다. - -#### Acceptance Criteria Delta -- Added: - - AC-1~11 최초 작성 -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: BAT-025 runbook, data model/persistence baseline, release evidence 갱신 예정. -- Modules: 신규 Alembic revision, runtime `ensure_schema()` 호환 경로, migration bootstrap/CLI 검증 경로. -- Tests: fresh DB, runtime-precreated schema, data preservation, broker/manual ledger regression, runbook preflight 검증. - -#### Delivery Trace Delta -- Batches: BAT-025 standard 예정 — migration implementation/test/QC/runbook 작성. -- Releases: 운영 DB upgrade와 신규 API 활성화는 별도 release 결재 필요. - -#### Revalidation Impact -- Reverification Needed: yes — migration 구현 후 AC-1~11 evidence 및 운영 실행 시 별도 실운영 evidence 필요. -- Existing Implementation Invalidated: no — REQ-056 Implemented status와 BAT-024 release-candidate 상태는 유지한다. -- Status Recommendation: keep-proposed -- Follow-up Action: confirm-req → BAT-025 standard batch drafting/planning/design → implementation/QC. 운영 실행은 별도 release approval 후에만 검토한다. - -#### Validation Plan -- BAT-025에서 Alembic graph/head preflight, fresh/upgraded DB 및 runtime-precreated-schema integration 검증, data preservation/source backfill, `alembic current` 정합, broker/manual ledger smoke/regression을 실행한다. 운영 runbook은 backup·lock·schema/current·smoke·forward-only recovery 체크리스트로 검토하며, 실 운영 DB 실행은 별도 결재 전 수행하지 않는다. - -### CHG-20260715-02 -- Date: 2026-07-15 -- Author: TREX lead (Hermes) -- confirm-req: `Proposed` → `Approved`. - -#### Change Summary -- REQ-056의 이미 검증된 API 계약을 다시 열지 않고, `portfolio_ledger` 운영 스키마 적용을 독립 Migration REQ로 승인한다. - -#### Approval Basis -- dcy-012의 `source` 분리·REVERSAL target·daemon-managed confirmation audit 결정과 REQ-056 Implementation Confirmation의 운영 release caveat를 추적했다. -- 실제 `uv run alembic heads` 결과는 단일 head `20260628_0027`이었다. 단, 병렬 revision 가능성을 포함한 구현 시점 graph 재확인을 AC-1의 필수 preflight로 유지한다. -- AC-1~11은 fresh/기존 데이터/runtime-precreated schema, schema-version 정합, rollback 경계, 운영 runbook을 검증 가능하게 정의한다. - -#### Revalidation Impact -- Reverification Needed: yes — BAT-025에서 migration 및 runbook evidence를 확보해야 한다. -- Existing Implementation Invalidated: no — REQ-056 `Implemented` 및 BAT-024 `release-candidate`는 유지한다. -- Status Recommendation: proceed-to-batch. -- Follow-up Action: suggest-batch-reqs → BAT-025 standard drafting/planning/design/implementation/verification → independent QC. 운영 DB 실행은 별도 release 결재 후에만 가능하다. diff --git a/docs/srs/Non-Functional/req-051_broker-credential-security-isolation.md b/docs/srs/Non-Functional/req-051_broker-credential-security-isolation.md deleted file mode 100644 index 7c72e4b..0000000 --- a/docs/srs/Non-Functional/req-051_broker-credential-security-isolation.md +++ /dev/null @@ -1,126 +0,0 @@ -# REQ-051: 브로커 인증정보 보안 격리 — service-token 전용·web 토큰 차단 - -- Status: Implemented -- Type: Non-Functional -- Priority: High -- Owner: Justin Ko - -## Intent -- 복호화된 app_secret(KIS API 비밀키)은 실계좌 실자금에 직결되는 최민감 시크릿이다. 이 정보가 복호화된 형태로 service-token 경로 외 다른 경로(web user 토큰, admin 토큰, 비인증 접근)로 유출되지 않아야 한다. -- portfolio-hub가 M3에서 trex-web 프론트에 노출될 예정이므로, web 노출 표면과 인증정보 전달 경로의 격리를 명시적으로 요구사항으로 정의하고 테스트로 검증한다. -- dcy-010 NFR-1(보안 격리), NFR-2(일관성), NFR-3(가용성/degrade)의 독립 측정·검증 필요성을 근거로 별도 REQ로 승격한다. - -## Requirement -- `GET /v1/user_info`는 service token(token_source="db", token_type="service")으로만 접근 가능하다. web user 토큰(token_type="session" 또는 client 타입), 비인증 요청, admin 토큰(token_type이 service가 아닌 경우)은 HTTP 403을 반환해야 한다. -- 복호화된 `credential_payload`(`app_secret` 포함)는 오직 `GET /v1/user_info` 응답을 통해서만 반환되어야 하며, 다른 엔드포인트(portfolio, asset, health, admin 등)의 응답에 포함되어서는 안 된다. -- `GET /v1/user_info`에서 daemon_id가 없는(None) 토큰은 HTTP 403으로 거부한다(REQ-049와 동일 — 보안 정책으로도 명시). -- 인증정보 조회·복호화 실패(DB 오류, 복호화 실패)는 엔드포인트 전체를 크래시시키지 않는다. 실패 시 `broker: null`(또는 `credential_payload: null`)을 반환하고 경고 로그를 남긴다. -- 인증정보 관련 조회 로그에는 `credential_payload`의 내용(app_key, app_secret)이 포함되어서는 안 된다. 로그에는 broker 이름, daemon_id, 오류 유형만 기록한다. -- universe의 daemon-scoped 인가(`managed_by_daemon_id` 일치 검사, REQ-028)와 동일한 규율로, broker 인증정보 접근도 daemon_id 일치를 강제한다(daemon_id 불일치 → 403, 다른 데몬의 인증정보 반환 불가). - -## Acceptance Criteria -1. Given web user 토큰(token_type이 service가 아닌 token)으로 GET /v1/user_info 호출, When 호출, Then HTTP 403 반환(credential_payload 미노출). -2. Given 비인증 요청으로 GET /v1/user_info 호출, When 호출, Then HTTP 401 반환. -3. Given daemon_id=None인 service token으로 GET /v1/user_info 호출, When 호출, Then HTTP 403 반환. -4. Given daemon_id="d1"인 service token으로 GET /v1/user_info 호출, When DB에 daemon_id="d2"의 인증정보만 존재하면, Then `broker: null` 반환 (다른 데몬의 인증정보 절대 반환하지 않음). -5. Given GET /v1/user_info 응답 외 다른 엔드포인트(GET /v1/portfolios, GET /v1/assets, GET /health 등) 응답, When 응답 JSON 확인, Then `credential_payload`, `app_secret`, `app_key` 필드가 포함되지 않는다. -6. Given DB 오류로 `list_broker_credentials` 실패, When GET /v1/user_info 호출, Then HTTP 200 + `broker: null` 반환 (500 크래시 없음), 경고 로그 기록. -7. Given Fernet 복호화 실패(잘못된 `CREDENTIAL_ENCRYPTION_KEY`), When GET /v1/user_info 호출, Then HTTP 200 + `broker.credential_payload: null` 반환, 경고 로그 기록(app_secret 로그 미노출). -8. Given GET /v1/user_info 정상 처리 중 로그 검사, When 로그 출력 확인, Then `app_key`, `app_secret` 값이 로그에 출력되지 않는다. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — GET /v1/user_info 보안 정책 섹션: service-token 전용, daemon_id 없음 → 403, web 토큰 차단, credential 로그 비노출 명시. -- Modules: - - `app/api/v1_user_info.py` — `_require_service_token` 함수(기존 service-token gate, 변경 없거나 daemon_id 검사 보완). - - `app/api/v1_user_info.py` — 로깅 경고 메시지에서 credential 값 제외 확인. -- Tests: - - web 토큰 → 403 테스트(AC-1). - - 비인증 → 401 테스트(AC-2). - - daemon_id=None → 403 테스트(AC-3). - - 다른 데몬 인증정보 → null 테스트(AC-4). - - 타 엔드포인트 credential 미노출 테스트(AC-5): 주요 엔드포인트 응답 필드 검사. - - DB 오류 graceful degrade 테스트(AC-6). - - 복호화 실패 degrade 테스트(AC-7): 기존 테스트 확인/보완. - -## Notes -- dcy-010 NFR-1(보안 격리), NFR-2(일관성), NFR-3(degrade)가 모두 독립 측정 가능하고 테스트로 검증할 수 있어 별도 REQ로 승격한다. 이 세 NFR을 단일 REQ로 묶은 것은 모두 동일한 엔드포인트(GET /v1/user_info)의 보안 속성에 관한 것이기 때문이다. -- AC-4(다른 데몬 인증정보 반환 불가)는 REQ-049 AC-4/5와 overlap하지만, 보안 관점의 요구사항으로 이 REQ에서 독립적으로 추적한다. -- 로그 비노출(AC-8)은 현재 구현의 `logger.warning("credential decryption failed for broker %s: %s", cred.broker, exc)` 패턴이 app_secret을 exc에 포함할 가능성을 배제해야 함을 의미한다. 구현 시 확인이 필요하다. -- `CREDENTIAL_ENCRYPTION_KEY` 자체의 보안 관리(환경변수 주입, 키 회전 정책)는 dcy-010 A-3(외부 시크릿 매니저 Out of Scope)에 따라 이 REQ의 범위 밖이다. 현재 환경변수 기반 유지. -- Source: dcy-010 NFR-1, NFR-2, NFR-3. - -## Change Log - -### CHG-20260628-01 -- Date: 2026-06-28 -- Author: Justin Ko - -#### Change Summary -- 신규 생성 (Proposed) — dcy-010 NFR-1/2/3 산출물. - -#### Intent -- dcy-010 NFR-1(보안 격리)·NFR-2(일관성)·NFR-3(degrade) 세 NFR을 독립 측정·테스트 가능한 요구사항으로 묶어 Non-Functional REQ로 정규화한다. - -#### Acceptance Criteria Delta -- Added: - - AC-1 ~ AC-8 전체 (신규 초안) -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: - - `docs/srs/Non-Functional/req-051_broker-credential-security-isolation.md` 신규 생성 - - `docs/interface-contract.md` 갱신 예고 -- Modules: - - `app/api/v1_user_info.py` 보안 게이트 및 로깅 확인 -- Tests: - - 보안 격리 테스트 신규(AC-1~4, AC-8), degrade 테스트(AC-6/7) - -#### Delivery Trace Delta -- Batches: 미정 (suggest-batch-reqs 대기) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 batch 배정. REQ-049와 동일 batch에 묶어 전달하는 것이 자연스럽다. - -#### Validation Plan -- pytest로 AC-1~8 각 케이스 확인. AC-8(로그 비노출)은 caplog fixture로 검증. - -### CHG-20260628-02 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req: `Proposed` → `Approved`. dcy-010 NFR-1/2/3 흡수 적절(동일 엔드포인트 보안속성). service-token 전용·web 토큰 차단·타 엔드포인트 credential 미노출(AC-5)·로그 비노출(AC-8)·degrade(AC-6/7) testable. REQ-049와 동일 batch 권고. blocker 없음. - -### CHG-20260628-03 -- Date: 2026-06-28 -- Author: 팀장(Claude) - -#### Change Summary -- confirm-req-implemented: `Approved` → `Implemented`. PR #17 머지(mergeCommit d4acfd4), BAT-020, QC PASS-WITH-NOTES(blocker 0), 테스트 통과. - -#### Intent -- BAT-020 delivery(PR #17 머지)에서 보안 격리(service-token 전용, web 토큰 차단, 타 엔드포인트 credential 미노출, graceful degrade) 구현 및 AC-1~8 테스트 통과가 확인되어 Implemented로 전환한다. - -#### Acceptance Criteria Delta -- Added: 없음 -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- 없음 (상태 전환만) - -#### Delivery Trace Delta -- Batches: BAT-020 (PR #17, mergeCommit d4acfd4) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: implemented - -#### Validation Plan -- BAT-020 verification 문서 및 QC PASS-WITH-NOTES 결과(blocker 0)로 AC-1~8 근거 확보됨. diff --git a/docs/srs/Non-Functional/req-054_credential-management-security-write-only.md b/docs/srs/Non-Functional/req-054_credential-management-security-write-only.md deleted file mode 100644 index 66a0002..0000000 --- a/docs/srs/Non-Functional/req-054_credential-management-security-write-only.md +++ /dev/null @@ -1,96 +0,0 @@ -# REQ-054: 인증정보 관리 보안 격리 — write-only·secret 미반환·로그 미노출 - -- Status: Implemented -- Type: Non-Functional -- Priority: High -- Owner: Justin Ko - -## Intent -- dcy-011 NFR-1(보안 최우선): 복호화된 `app_secret`/`app_key`(KIS API 비밀키 — 실계좌·실자금 직결)는 user-token/admin 어떤 관리 API 응답·로그에도 포함되어서는 안 된다. -- REQ-052(사용자 소유 스코프 인증정보 관리 API)와 REQ-053(admin 데몬 CRUD API)이 신설됨으로써 응답/로그 노출 표면이 확대된다. 이 표면에서 복호화 secret이 유출되지 않음을 독립적으로 측정·검증한다. -- `GET /v1/user_info`(service-token 전용, REQ-051/REQ-049)가 유일 decrypt-return 경로라는 불변 정책을 신규 관리 API 추가 이후에도 유지한다. - -## Requirement -- 인증정보 관리 API(REQ-052: 소유 스코프 write/read/delete + 메타데이터, REQ-053: admin 데몬 CRUD) 응답 스키마에 `app_key` 및 `app_secret` 필드가 존재해서는 안 된다. -- 인증정보 관리 API 처리 중 생성되는 모든 로그(INFO, WARNING, ERROR 수준 포함)에 `app_secret` 또는 `app_key` 평문이 포함되어서는 안 된다. 로그에는 `broker` 이름, `daemon_id`, 오류 유형만 기록한다. -- 복호화된 `credential_payload`(`app_key`, `app_secret`)를 반환하는 유일 경로는 service-token 전용 `GET /v1/user_info`(REQ-051/REQ-049)이다. 이 REQ-052·REQ-053 신설로 인해 이 경로가 변경되어서는 안 된다. -- 인증정보 쓰기(등록/회전) 후 DB에 저장되는 값은 반드시 Fernet 암호화된 형태여야 한다. 평문 `app_secret`/`app_key`가 `broker_credentials` 테이블에 저장되어서는 안 된다. - -## Acceptance Criteria -1. Given REQ-052 인증정보 write(등록/회전) 엔드포인트 응답 JSON, When 응답 필드를 열거, Then `app_key`, `app_secret` 키가 최상위 및 중첩 객체에 포함되지 않는다. -2. Given REQ-052 인증정보 메타데이터 GET 엔드포인트 응답 JSON, When 응답 필드를 열거, Then `app_key`, `app_secret` 키가 포함되지 않는다. -3. Given REQ-053 admin 데몬 생성·변경·(비)활성화 엔드포인트 응답 JSON, When 응답 필드를 열거, Then `app_key`, `app_secret`, `credential_payload` 키가 포함되지 않는다. -4. Given 인증정보 write/rotate 처리 중 caplog 수집, When 로그 출력을 확인, Then 실제 `app_key`/`app_secret` 값이 로그 메시지에 포함되지 않는다. -5. Given 인증정보 write 요청의 `app_key`/`app_secret` 값으로 DB row를 직접 조회, When `broker_credentials.credential_payload` 컬럼을 확인, Then 평문이 아닌 Fernet 암호화 토큰(`gAAAA...`) 형태로 저장되어 있다. -6. Given REQ-052 인증정보 write 후 service-token `GET /v1/user_info`(REQ-049)로 조회, When 호출, Then `broker.credential_payload.app_secret`이 올바르게 복호화되어 반환된다 — 관리 API 추가가 유일 decrypt 경로(REQ-051)를 파괴하지 않음 확인. -7. Given REQ-052·REQ-053 신규 엔드포인트가 추가된 후 기존 `GET /v1/user_info` 보안 격리 테스트(REQ-051 AC-1~8), When 회귀 실행, Then 전체 PASS — 신규 API 추가가 기존 service-token 전용 경로에 영향 없음 확인. - -## Impacted Area -- Docs: - - `docs/interface-contract.md` — 인증정보 관리 API 보안 정책 섹션: 복호화 secret 미반환·로그 미노출·Fernet at-rest 불변·service-token 유일 decrypt 경로 명시. -- Modules: - - `app/schemas/` — REQ-052 응답 스키마(`CredentialMetaResponse`)에 `app_key`/`app_secret` 필드 부재 강제(Pydantic 모델 설계 단계 확인). - - `app/services/` — 인증정보 write 서비스에서 로그 호출 시 secret 평문 비포함 확인. - - `app/core/encryption.py` — Fernet 암호화 후 저장 경로(REQ-052 write) 재사용. -- Tests: - - caplog 기반 로그 secret 비노출 테스트(AC-4). - - 응답 스키마 secret 필드 부재 테스트(AC-1/2/3). - - DB 저장 암호화 확인 테스트(AC-5). - - REQ-051 회귀 테스트(AC-7): 신규 API 추가 후 기존 service-token gate 테스트 전부 PASS. - -## Notes -- dcy-011 NFR-1(보안 — 최우선): REQ-052/REQ-053의 각 AC에 secret 부재 조건이 포함되어 있으나, 이 REQ-054는 신규 관리 API 전체를 아우르는 **독립 측정·검증 목적** Non-Functional REQ로 분리한다. REQ-051(서비스 토큰 전용 격리)과 나란히 추적한다. -- AC-7(REQ-051 회귀): REQ-052·REQ-053 구현 후 기존 service-token 보안 격리(REQ-051 AC-1~8)가 회귀 없이 통과함을 이 REQ의 검증 항목으로 포함한다. -- at-rest Fernet 암호화(AC-5): `CREDENTIAL_ENCRYPTION_KEY` 관리 정책은 REQ-051 Notes에 따라 이 REQ 범위 밖(환경변수 기반 유지). -- NFR-2(권한)·NFR-3(일관성)의 나머지는 REQ-052/REQ-053 AC에 흡수된다. NFR-1 보안만 별도 Non-Functional REQ로 승격한다. -- Source: dcy-011 NFR-1, R-1(복호화 secret 유출 리스크), S-2(복호화 secret 미포함 성공 기준). - -## Change Log - -### CHG-20260628-01 -- Date: 2026-06-28 -- Author: Justin Ko - -#### Change Summary -- 신규 생성 (Proposed) — dcy-011 NFR-1 산출물. - -#### Intent -- dcy-011 NFR-1(복호화 secret이 user-token/admin 응답·로그에 미포함, REQ-051 패턴)을 신규 관리 API(REQ-052·REQ-053) 표면 전체에 대한 독립 측정 가능 Non-Functional REQ로 정규화한다. - -#### Acceptance Criteria Delta -- Added: - - AC-1 ~ AC-7 전체 (신규 초안) -- Updated: 없음 -- Removed: 없음 - -#### Impacted Area Delta -- Docs: - - `docs/srs/Non-Functional/req-054_credential-management-security-write-only.md` 신규 생성 - - `docs/interface-contract.md` 갱신 예고 -- Modules: - - 응답 스키마(secret 필드 부재), 서비스 로그 확인 -- Tests: - - caplog 로그 비노출(AC-4), 응답 schema secret 부재(AC-1~3), DB 암호화 확인(AC-5), REQ-051 회귀(AC-7) 신규 - -#### Delivery Trace Delta -- Batches: 미정 (suggest-batch-reqs 대기) -- Releases: 미정 - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 suggest-batch-reqs. REQ-052/REQ-053와 동일 batch 묶기 권고. - -#### Validation Plan -- pytest caplog fixture로 AC-4(로그 비노출), 응답 JSON 필드 열거로 AC-1~3, DB row 직접 확인으로 AC-5, REQ-051 회귀 suite로 AC-7 검증. - -### CHG-20260628-02 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req: `Proposed` → `Approved`. dcy-011 NFR-1 추적. 신규 관리 API(REQ-052/053) 응답·로그 secret 미포함(AC-1~4)·Fernet at-rest(AC-5)·service-token 유일 decrypt 불변(AC-6)·REQ-051 회귀(AC-7) testable. REQ-051과 나란히 독립 보안 REQ 승격 타당. blocker 없음. - -### CHG-20260628-03 -- Date: 2026-06-28 -- Author: 팀장(Claude) -- confirm-req-implemented: `Approved` → `Implemented`. 근거: BAT-022 PR #19 mergeCommit 1b78c18, QC PASS-WITH-NOTES(opus, blocker 0). AC-1~7 전건 통과(565 passed). 응답 schema secret 부재(AC-1/2/3)·caplog 로그 비노출(AC-4)·Fernet at-rest(AC-5)·service-token 유일 decrypt 불변(AC-6)·REQ-051 회귀 PASS(AC-7) 확인. owner 재할당 원자화 수정(83e6dde) 반영으로 음성 보안 테스트 3종 추가 포함. 잔여 리스크 없음. diff --git a/docs/srs/Testing/req-042_trade-pnl-api-test-coverage.md b/docs/srs/Testing/req-042_trade-pnl-api-test-coverage.md deleted file mode 100644 index 69e4933..0000000 --- a/docs/srs/Testing/req-042_trade-pnl-api-test-coverage.md +++ /dev/null @@ -1,107 +0,0 @@ -# REQ-042: 체결 내역/실현 PnL API 테스트 커버리지 (WAC 시나리오 기댓값 대조, 날짜 필터, 422) - -- Status: Implemented -- Type: Testing -- Priority: High -- Owner: Justin Ko - -## Intent -- REQ-039(체결 내역 조회), REQ-040(실현 PnL 집계), REQ-041(WAC 산출 로직)의 구현 산출물에 대해, WAC 시나리오 기댓값 대조·날짜 필터 경계·422 오류·R-1 경고 필드를 포함한 단위/통합 테스트를 정의한다. -- 테스트는 구현자(delivery)가 아닌 독립 QC 시점에서도 검증 가능해야 하며, AC를 충족하는 최소 evidence를 제공해야 한다(dcy-007 NFR-3, S-1, S-2). -- WAC 기댓값 수기 계산 대조(시나리오별 fixture 사용)와 날짜 필터 경계 테스트를 의무 포함한다. - -## Requirement -- 아래 테스트 파일을 신규 작성해야 한다: - - `tests/test_wac_calculation.py` — WAC 산출 로직 순수 단위 테스트(DB 불필요). - - `tests/test_v1_portfolios_trades.py` — 체결 내역 엔드포인트 통합 테스트(TestClient). - - `tests/test_v1_portfolios_pnl.py` — 실현 PnL 엔드포인트 통합 테스트(TestClient). -- 각 테스트 파일은 pytest 단독 실행 가능해야 한다(`pytest tests/test_wac_calculation.py` 등). -- 테스트 픽스처는 portfolio_ledger INSERT로 알려진 buy/sell 데이터를 설정하고, 수기 계산 기댓값과 API 응답을 대조해야 한다. - -## Acceptance Criteria -1. Given `tests/test_wac_calculation.py` 실행, When `pytest tests/test_wac_calculation.py`, Then 다음 시나리오 모두 통과: - - 단순 buy → sell WAC 수기 기댓값 일치(dcy-007 S-2 기준) - - 다중 buy → 부분 sell WAC(AC-1 of REQ-041 기댓값: 199,300) - - 빈 입력 → total_pnl=0, cost_basis_from=None - - sell 수량 초과(running_qty=0) → data_completeness_warning 비어 있지 않음 -2. Given `tests/test_v1_portfolios_trades.py` 실행, When `pytest tests/test_v1_portfolios_trades.py`, Then 다음 시나리오 모두 통과: - - `date=YYYY-MM-DD` 단일 날짜 필터 — 해당일 건수 일치 - - `from`/`to` 범위 필터 — 경계 날짜 포함 건수 일치 - - 날짜 파라미터 누락 → HTTP 422 - - 날짜 형식 오류 → HTTP 422 - - 해당 기간 0건 → HTTP 200 + empty items - - 비소유자 token → HTTP 403 - - 존재하지 않는 portfolio_id → HTTP 404 -3. Given `tests/test_v1_portfolios_pnl.py` 실행, When `pytest tests/test_v1_portfolios_pnl.py`, Then 다음 시나리오 모두 통과: - - buy 2회 + sell 1회 픽스처, `total_realized_pnl` 수기 기댓값 일치(REQ-040 AC-1 기댓값: 199,300) - - `from`/`to` 파라미터 누락 → HTTP 422 - - 기간 내 매도 0건 → HTTP 200 + total_realized_pnl=0 + items=[] - - data_completeness_warning 필드: 이력 완전 시 null, 불완전 시 비어 있지 않음(R-1 경고 반영) - - symbol 필터 적용 — 해당 symbol 매도만 집계 - - 비소유자 token → HTTP 403 -4. Given 3개 테스트 파일 전체 실행, When `pytest tests/test_wac_calculation.py tests/test_v1_portfolios_trades.py tests/test_v1_portfolios_pnl.py -v`, Then 전체 PASSED(0 failed) + 각 파일 실행 결과 출력 존재. -5. Given `tests/test_wac_calculation.py`, When DB 컨테이너 없이 실행(`PORTFOLIOHUB_DB_ENABLED` 미설정 환경), Then WAC 단위 테스트가 통과(DB 격리 확인, REQ-041 AC-8 연동). -6. Given REQ-039~041 구현 후 기존 테스트 스위트(`pytest` 전체) 실행, When 실행, Then 기존 통과 테스트가 모두 계속 통과(회귀 없음). - -## Impacted Area -- Docs: - - 없음 (테스트 파일만 추가) -- Modules: - - 없음 (기존 소스 파일 수정 없음 — 테스트 전용 REQ) -- Tests: - - `tests/test_wac_calculation.py` 신규 - - `tests/test_v1_portfolios_trades.py` 신규 - - `tests/test_v1_portfolios_pnl.py` 신규 - -## Notes -- 이 REQ는 REQ-039/040/041의 검증 근거를 명시적으로 확보하기 위한 Testing REQ다. 구현 코드를 직접 생산하지 않으므로 별도 REQ로 분리. -- 통합 테스트(TestClient 기반)는 DB-enabled 환경(`PORTFOLIOHUB_DB_ENABLED=true`)에서 실행하거나 모킹 전략을 사용할 수 있다. 모킹 전략과 실 DB 사용 여부는 구현 단계에서 결정. -- AC-1의 수기 기댓값(199,300)은 REQ-041 AC-1과 동일한 픽스처를 사용해야 한다. -- dcy-007 S-1/S-2 성공 기준의 측정 근거는 이 REQ의 테스트 실행 결과가 된다. -- Source: dcy-007 NFR-3, S-1, S-2, R-1. - -## Change Log - -### CHG-20260618-01 -- Date: 2026-06-18 -- Author: Justin Ko - -#### Change Summary -- 최초 생성 — dcy-007 confirm-discovery 후 draft-req 실행. 체결 내역/PnL API 테스트 커버리지 산출물. - -#### Intent -- dcy-007 NFR-3(PnL 로직 단위 테스트), S-1/S-2(기댓값 대조 통합 테스트)를 Testing REQ로 정규화. WAC 시나리오 기댓값과 R-1 경고 필드 검증을 의무 포함. - -#### Acceptance Criteria Delta -- Added: - - AC-1~6 최초 작성 - -#### Impacted Area Delta -- Tests: tests/test_wac_calculation.py, tests/test_v1_portfolios_trades.py, tests/test_v1_portfolios_pnl.py — 최초 지정 - -#### Delivery Trace Delta -- Batches: none (미정) -- Releases: none (미정) - -#### Revalidation Impact -- Reverification Needed: no -- Existing Implementation Invalidated: no -- Status Recommendation: keep-current -- Follow-up Action: confirm-req 후 REQ-039/040/041과 동일 batch 편입(검증 REQ로서 구현 REQ와 동행) - -#### Validation Plan -- pytest 실행으로 AC-1~6 직접 검증; 전체 스위트 회귀(AC-6)는 기존 pytest baseline과 비교 - -### CHG-20260618-02 -- Date: 2026-06-18 -- Author: 팀장 (Claude) - -#### Change Summary -- confirm-req: `Proposed` → `Approved`. WAC 시나리오 기댓값·날짜필터·422·R-1 경고 검증 AC가 REQ-039~041과 정합, blocker 없음. 구현 REQ와 동일 batch. M1 fast-track. - -### CHG-20260618-03 -- Date: 2026-06-18 -- Author: 팀장 (Claude) - -#### Change Summary -- confirm-req-implemented: `Approved` → `Implemented`. Evidence: 3개 테스트 파일 50 passed(20+12+18) 독립 QC 재현, 전체 427 passed 회귀 없음. AC-1~6 충족. diff --git a/docs/srs/index.md b/docs/srs/index.md deleted file mode 100644 index 0210523..0000000 --- a/docs/srs/index.md +++ /dev/null @@ -1,150 +0,0 @@ -# Software Requirement Specification Index - -## 목적 -- 이 문서는 프로젝트의 SRS 운영 기준과 requirement 목록을 관리하는 entry point이다. -- 개별 requirement의 상세 내용은 타입별 폴더 하위 `req-XXX_*.md` 파일에서 관리한다. - -## 사용 방법 -1. 새로운 requirement가 필요하면 `Next Requirement ID`를 확인하고 `docs/srs/req-template.md`를 복사해 `docs/srs//req-XXX_.md` 파일을 생성한다. -2. 초안 작성은 `draft-req`, 승인 전환은 `confirm-req`, 변경 대응은 `change-req`, 구현 완료 전환은 `confirm-req-implemented` skill을 기준으로 수행한다. -3. 작성 또는 수정 후 누락과 모호성을 점검한다. -4. requirement 추가 또는 변경 후 `Next Requirement ID`, `Requirement Register`, `Recent Change Log Summary`를 함께 갱신한다. -5. `Approved` requirement를 승인하려면 승인 준비 상태와 blocker를 점검한다. -6. `Approved` requirement는 구현 이슈나 작업 항목으로 연결한다. -7. 구현과 검증이 끝난 requirement는 `confirm-req-implemented` skill로 완료 전환 가능 여부를 점검한다. -8. requirement 변경 시 `change-req` skill을 기준으로 해당 REQ 파일의 `Change Log`를 먼저 갱신한 뒤 본문을 수정한다. -9. 구현 전에는 관련 REQ 파일과 최근 `Change Log`를 기준으로 변경 범위를 판단한다. - -## 상태 정의 -- Approved: 초안 상태이며 구현 기준으로 확정되지 않음 -- Approved: 구현 기준으로 승인됨 -- Implemented: 구현과 검증이 완료됨 -- Deprecated: 더 이상 유지하지 않음 - -## Status Transition Evidence - -### Approved -> Approved -- `Status`, `Type`, `Priority`, `Owner`, `Intent`, `Requirement`, `Acceptance Criteria`, `Impacted Area`, `Change Log`가 모두 존재해야 한다. -- `Type`은 아래 분류 도표에서 정확하게 하나로 지정되어야 한다. -- `Acceptance Criteria`는 구현 판단에 사용할 수 있을 정도로 구체적이어야 한다. -- 미해결 assumption 또는 open question이 있으면 `Notes`에 명시하고, 승인 blocker 여부를 판단해야 한다. -- 승인 전환 시 해당 REQ 파일의 `Change Log`에 status 변경 사유를 기록해야 한다. - -### Approved -> Implemented -- 각 `Acceptance Criteria`에 대해 test, manual verification, 또는 구현 결과 중 최소 1개의 evidence가 있어야 한다. -- evidence는 requirement 문서 자체 또는 연결된 구현/검증 결과에서 추적 가능해야 한다. -- 미충족 `Acceptance Criteria`가 있으면 `Implemented`로 전환하지 않는다. -- 구현 완료 전환 시 잔여 리스크와 미검증 영역이 있으면 `Change Log`에 기록해야 한다. -- 상태 전환은 `confirm-req-implemented` skill을 기준으로 수행한다. - -### Requirement Change Handling -- 기존 REQ 본문(`Requirement`, `Acceptance Criteria`, `Impacted Area`)을 수정하기 전에는 새 `Change Log` 항목을 만든다. -- `Change Log`에는 최소한 `Acceptance Criteria Delta`, `Impacted Area Delta`, `Delivery Trace Delta`, `Revalidation Impact`, `Validation Plan`이 포함되어야 한다. -- 변경이 기존 구현 또는 verification evidence를 무효화하면 `Implemented` requirement는 `Approved`로 되돌린다. -- 변경이 승인 전제 자체를 다시 검토하게 만들면 `Approved` 또는 `Implemented` requirement를 `Approved`로 되돌릴 수 있다. -- 상태 되돌림이 발생하면 `Requirement Register`와 `Recent Change Log Summary`도 함께 갱신한다. - -## Requirement 타입 분류 도표 - -| 타입 | 정의 | 예시 | -|------|------|------| -| **Configuration** | 설정 옵션, 커스터마이징 포인트 | 설정 파라미터, 환경 변수, 옵션 항목 | -| **Data** | 데이터 구조, 저장/로드, 파일 형식, 스키마 | 설정 파일 형식, DB 스키마, API 구조 | -| **Deployment** | 배포 방식, 운영 절차, 배포 환경 | 컨테이너 배포, 스택 관리, 운영 가이드 | -| **Documentation** | 문서 작성, 가이드, 매뉴얼, 안내서 | 사용자 가이드, API 문서, 튜토리얼 | -| **Exception** | 오류 처리, 예외 복구, 재시도 전략 | 오류 복구, 검증 실패 처리, 폴백 전략 | -| **Installation** | 시스템 설치, 환경 구성, 초기화 절차 | Docker 이미지 빌드, DB 마이그레이션, 사전 조건 설정 | -| **Integration** | 외부 시스템 연동, 플러그인, 확장 | 3rd party 연동, 플러그인 인터페이스 | -| **Interface** | 사용자 상호작용 방식 (UI, API, CLI, 제어방식) | CLI 명령어, API 엔드포인트, 웹 UI 기능 | -| **Migration** | 데이터 이전, 버전 업그레이드 | 데이터 마이그레이션, 버전 호환성 | -| **Non-Functional** | 성능, 보안, 확장성, 호환성, 신뢰성 | 성능 목표, 보안 정책, 확장성 | -| **Testing** | 테스트 전략, QA 요구사항, 검증 방식 | 테스트 커버리지, 회귀 테스트, 수락 기준 | - -## Requirement 작성 규칙 -- 각 requirement는 고유 ID를 가진다. 예: `REQ-001` -- 다음 신규 requirement 번호의 source of truth는 이 문서의 `Next Requirement ID` 섹션이다. -- 새 requirement를 추가하거나 ID 충돌이 발생할 수 있는 변경을 했으면 `Next Requirement ID`를 함께 갱신한다. -- 각 requirement는 `Type`을 명시해야 한다. 위 분류 도표 중 정확하게 하나로 선택하며, `docs/srs//` 폴더명도 같은 문자열을 사용한다. -- 각 requirement는 `Intent`, `Requirement`, `Acceptance Criteria`, `Impacted Area`를 반드시 포함한다. -- 모호한 표현("개선한다", "적절히 처리한다") 대신 관찰 가능하고 test 가능한 문장을 사용한다. -- requirement 변경 사항은 해당 REQ 파일의 `Change Log`에 기록한다. -- requirement 변경 시 영향 받는 batch/release와 재검증 필요 여부를 `Change Log`에 남긴다. -- `Recent Change Log Summary`는 최신 5건만 유지한다. -- status 전환은 `Status Transition Evidence` 기준을 따른다. - -## Next Requirement ID - -- Current: `REQ-058` -- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - -## Requirement Register - -| ID | Title | Type | Status | Priority | Owner | Link | -| --- | --- | --- | --- | --- | --- | --- | -| REQ-001 | tags 마스터 테이블 도입 | Data | Implemented | High | Justin Ko | [req-001](./Data/req-001_tags-master-table.md) | -| REQ-002 | assets.tag_ids UUID[] 컬럼 추가 | Data | Implemented | High | Justin Ko | [req-002](./Data/req-002_assets-tag-ids-column.md) | -| REQ-003 | portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거 | Data | Implemented | High | Justin Ko | [req-003](./Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md) | -| REQ-004 | tags 마스터 HTTP API | Interface | Implemented | High | Justin Ko | [req-004](./Interface/req-004_tags-http-api.md) | -| REQ-005 | 태그 적용·해제 HTTP API | Interface | Implemented | High | Justin Ko | [req-005](./Interface/req-005_tag-apply-remove-http-api.md) | -| REQ-006 | portfolio_tags 데이터 마이그레이션 | Migration | Implemented | High | Justin Ko | [req-006](./Migration/req-006_portfolio-tags-data-migration.md) | -| REQ-007 | pfhub tag CLI 명령어 | Interface | Implemented | Medium | Justin Ko | [req-007](./Interface/req-007_pfhub-tag-cli.md) | -| REQ-008 | 태그 모델 도입 baseline 문서 갱신 | Documentation | Implemented | Low | Justin Ko | [req-008](./Documentation/req-008_tag-baseline-docs-update.md) | -| REQ-009 | assets 테이블 스키마 도입 | Data | Implemented | High | Justin Ko | [req-009](./Data/req-009_assets-table-schema.md) | -| REQ-010 | admin asset catalog CRUD | Interface | Implemented | High | Justin Ko | [req-010](./Interface/req-010_admin-asset-catalog-crud.md) | -| REQ-011 | portfolios asset_id FK 추가 및 symbol/market 제거 | Data | Implemented | High | Justin Ko | [req-011](./Data/req-011_portfolios-asset-id-fk-and-column-removal.md) | -| REQ-012 | portfolio 생성 CLI catalog 검증 | Interface | Implemented | High | Justin Ko | [req-012](./Interface/req-012_portfolio-create-catalog-validation.md) | -| REQ-013 | GET /v1/assets 토큰 타입별 분기 재정의 | Interface | Deprecated | High | Justin Ko | [req-013](./Interface/req-013_get-v1-assets-token-branching.md) | -| REQ-014 | portfolios 데이터 migration | Migration | Implemented | High | Justin Ko | [req-014](./Migration/req-014_portfolios-data-migration.md) | -| REQ-015 | asset catalog 도입 baseline 문서 갱신 | Documentation | Implemented | Low | Justin Ko | [req-015](./Documentation/req-015_asset-catalog-baseline-docs-update.md) | -| REQ-016 | client token 발급 경로 코드 폐지 | Interface | Implemented | High | Justin Ko | [req-016](./Interface/req-016_client-token-issuance-deprecation.md) | -| REQ-017 | service-token rotate unique index 보강 | Migration | Implemented | 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 | Implemented | High | Justin Ko | [req-018](./Interface/req-018_service-token-http-daemon-id-forward.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 | 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) | -| REQ-033 | ledger_trades.asset_id → portfolio_id 컬럼명 정합화 | Migration | Implemented | Medium | Justin Ko | [req-033](./Migration/req-033_ledger-trades-asset-id-to-portfolio-id-rename.md) | -| REQ-034 | LoginRequest 추가 필드 거부 enforcement | Interface | Implemented | Medium | Justin Ko | [req-034](./Interface/req-034_login-request-extra-forbid-enforcement.md) | -| REQ-035 | GET /health 응답에 db_enabled 필드 추가 | Interface | Implemented | Medium | Justin Ko | [req-035](./Interface/req-035_health-response-db-enabled-field.md) | -| REQ-036 | portfolio_ledger 회계 원장 통합 마이그레이션 (ledger_trades 흡수/DROP) | Migration | Implemented | High | Justin Ko | [req-036](./Migration/req-036_portfolio-ledger-consolidation-migration.md) | -| REQ-037 | 신 path 회계 책임 단일화 + 응답에 portfolio 상태 포함 | Interface | Implemented | High | Justin Ko | [req-037](./Interface/req-037_ledger-trade-write-accounting-and-state-response.md) | -| REQ-038 | 레거시 buy/sell endpoint deprecated 표기 | Interface | Implemented | Low | Justin Ko | [req-038](./Interface/req-038_legacy-buy-sell-endpoint-deprecation.md) | -| REQ-039 | GET /v1/portfolios/{id}/trades 체결 내역 조회 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-039](./Interface/req-039_trade-history-query-endpoint.md) | -| REQ-040 | GET /v1/portfolios/{id}/pnl/realized 실현 PnL 집계 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-040](./Interface/req-040_realized-pnl-query-endpoint.md) | -| REQ-041 | WAC 기반 cost_basis 산출 및 실현 PnL 계산 로직 (service/domain 계층 격리) | Integration | Implemented | High | Justin Ko | [req-041](./Integration/req-041_wac-cost-basis-pnl-calculation.md) | -| REQ-042 | 체결 내역/실현 PnL API 테스트 커버리지 (WAC 시나리오 기댓값 대조, 날짜 필터, 422) | Testing | Implemented | High | Justin Ko | [req-042](./Testing/req-042_trade-pnl-api-test-coverage.md) | -| REQ-043 | GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단(WAC) read 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-043](./Interface/req-043_holdings-cost-basis-read-endpoint.md) | -| REQ-044 | GET /v1/assets 권한 완화 — require_admin → require_read | Interface | Implemented | High | Justin Ko | [req-044](./Interface/req-044_get-v1-assets-require-read-relaxation.md) | -| REQ-045 | 사용자 가용 데몬 목록 read 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-045](./Interface/req-045_user-daemon-list-endpoint.md) | -| REQ-046 | BrokerCredential 도메인 모델 daemon_id 필드 추가 | Data | Implemented | High | Justin Ko | [req-046](./Data/req-046_broker-credential-daemon-id-field.md) | -| REQ-047 | broker_credentials 테이블 daemon-scoped PK 전환 마이그레이션 | Migration | Implemented | High | Justin Ko | [req-047](./Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md) | -| REQ-048 | 기존 user-scoped 인증정보 daemon_id 백필 | Migration | Implemented | High | Justin Ko | [req-048](./Migration/req-048_broker-credentials-daemon-id-backfill.md) | -| REQ-049 | GET /v1/user_info daemon-scoped 단일 broker 전달로 확장 | Interface | Implemented | High | Justin Ko | [req-049](./Interface/req-049_user-info-daemon-scoped-broker-delivery.md) | -| REQ-050 | credential CLI --daemon-id 옵션 추가 | Interface | Implemented | High | Justin Ko | [req-050](./Interface/req-050_credential-cli-daemon-id-option.md) | -| REQ-051 | 브로커 인증정보 보안 격리 — service-token 전용·web 토큰 차단 | Non-Functional | Implemented | High | Justin Ko | [req-051](./Non-Functional/req-051_broker-credential-security-isolation.md) | -| REQ-052 | 사용자 소유 스코프 인증정보 관리 HTTP API | Interface | Implemented | High | Justin Ko | [req-052](./Interface/req-052_user-owned-credential-management-api.md) | -| REQ-053 | admin 데몬 CRUD HTTP API | Interface | Implemented | High | Justin Ko | [req-053](./Interface/req-053_admin-daemon-crud-api.md) | -| REQ-054 | 인증정보 관리 보안 격리 — write-only·secret 미반환·로그 미노출 | Non-Functional | Implemented | High | Justin Ko | [req-054](./Non-Functional/req-054_credential-management-security-write-only.md) | -| REQ-055 | GET /v1/portfolios 응답에 전략·시장 메타데이터 노출 (additive) | Interface | Implemented | High | Justin Ko | [req-055](./Interface/req-055_v1-portfolios-strategy-market-meta-exposure.md) | -| REQ-056 | owner-scoped 수동 장부 입력 HTTP API | Interface | Implemented | High | Justin Ko | [req-056](./Interface/req-056_v1-portfolios-manual-ledger-api.md) | -| REQ-057 | portfolio_ledger 수동 장부 메타데이터 명시 Alembic 마이그레이션 | Migration | Approved | High | Justin Ko | [req-057](./Migration/req-057_portfolio-ledger-manual-metadata-alembic-migration.md) | - -## Recent Change Log Summary - -- 2026-07-15: REQ-057 `Proposed` → `Approved` (confirm-req) — REQ-056/BAT-024의 검증 완료 API 계약은 유지하고, `portfolio_ledger` 네 컬럼의 명시 Alembic migration을 독립 추적한다. 실제 `alembic heads` 단일 head `20260628_0027` 확인, 구현 시점 graph 재확인(AC-1) 유지. BAT-025 standard에서 fresh/기존/runtime-precreated schema 검증, data preservation, runbook, 독립 QC를 수행한다. 운영 DB upgrade·서비스 활성화는 별도 release 결재 전 실행하지 않는다. -- 2026-07-14: REQ-056 `Approved` → `Implemented` (confirm-req-implemented) — BAT-024 local implementation commit `b3447f6`, independent QC PASS (`deleg_f2337a39`). Manual ledger API endpoints added (`POST/GET /v1/portfolios/{portfolio_id}/ledger/manual`, `GET /v1/portfolios/{portfolio_id}/returns/summary`), strict owner-scope without admin bypass, service-token fail-closed, insufficient state 409, daemon-managed confirmation, `total_value_after`, `cumulative_profit_withdrawn`. Verification: full `594 passed`, focused remediation `4 passed`. Release caveat: production DB rollout should address additive `portfolio_ledger` columns with migration/runbook at release gate. -- 2026-07-14: REQ-056 `Proposed` → `Approved` (confirm-req) — dcy-012 confirmed Discovery 근거. 신규 `POST /v1/portfolios/{portfolio_id}/ledger/manual` + action enum(`INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `BUY`, `SELL`, `WITHDRAW`, `PROFIT`, `REVERSAL`) owner-scoped user/session API 계약. 기존 `/ledger/trades`는 daemon/service-token broker write 전용으로 보존. 부족 cash/quantity/buffer 409, daemon-managed confirmation 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION`, after-snapshot/history fields, `cumulative_profit_withdrawn` 및 `PROFIT` 순입금액 미포함 의미 포함. BAT-024 delivery 후보로 진행. -- 2026-07-04: REQ-055 신규 생성 `Implemented` (draft-req) — trex-tvr-v1 dcy-001 confirmed Discovery 근거. `GET /v1/portfolios` 응답에 `active_strategy_id`·`strategy_name`·`strategy_params`·`markets` additive 노출. DB 마이그레이션 불필요(기존 컬럼·테이블 활용), N+1 금지·파싱 에러 격리(500 금지) 포함. BAT-024 delivery 후보로 진행. -- 2026-06-28: REQ-052/053/054 `Approved` → `Implemented` (confirm-req-implemented) — BAT-022 PR #19 mergeCommit 1b78c18, QC PASS-WITH-NOTES(opus, blocker 0). 565 passed(+leak 테스트), AC 전건·secret 미반환·소유 스코프·REQ-051 회귀 PASS. Major 보안 #3(owner 재할당 credential leak) 원자화 수정(83e6dde) 반영 — 단일 트랜잭션+선삭제+leak 방지 테스트 3종. REQ-053 owner 재할당 원자화 수정 명시. -- 2026-06-28: REQ-052/053/054 `Approved` → `Approved` (confirm-req 팀장 승인) — dcy-011 추적·AC testable·blocker 0. 소유 스코프 음성(REQ-052 AC-6/9/10)·owner 재할당+인증정보 무효화(REQ-053 AC-4/5)·secret 미반환·로그 미노출·REQ-051 회귀(REQ-054) 확인. admin도 인증정보는 소유 스코프 전용(self-service 결정 정합). 엔드포인트 경로(OQ-1/2) design 위임. 다음=suggest-batch-reqs. -- 2026-06-28: REQ-047 `Approved` → `Implemented` (confirm-req-implemented) — BAT-021 PR #18 머지(7164f9e), 실 dev PG 통합검증 AC-1~7 PASS(Step1→backfill→Step2→downgrade), QC PASS-WITH-NOTES(blocker 0). **Step2(PK 전환) 실 운영 DB 적용은 별도 release 결재 게이트**(런북 `docs/runbooks/bat-021-req047-step2-migration-runbook.md` 엄수, 배포순서 Step1→backfill→Step2→코드배포, alembic stamp 사전확인). 운영 적용 시 AC-3/4/5 실운영 evidence 재확보. diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..aa23229 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1365 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "alembic" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "47.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, + { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, + { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/94/00f2059e4835eace3ae8fde680b932c496f8ec7bdc99168dfa53fb2e6b79/filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d", size = 71521, upload-time = "2026-07-08T05:46:58.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/02/be4a57b60c7149b55b9e3b3c13f609cd8eb5307c751f22bd8fb8d262e75b/filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51", size = 46036, upload-time = "2026-07-08T05:46:57.53Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, + { url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portfoliohub" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "cryptography" }, + { name = "fastapi" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "pre-commit" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.14.0,<2.0.0" }, + { name = "cryptography", specifier = ">=42.0.0,<48.0.0" }, + { name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0,<1.0.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.8.0,<4.0.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0,<4.0.0" }, + { name = "pydantic-settings", specifier = ">=2.4.0,<3.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0,<9.0.0" }, + { name = "python-dotenv", specifier = ">=1.0.0,<2.0.0" }, + { name = "rich", specifier = ">=13.9.0,<14.0.0" }, + { name = "typer", specifier = ">=0.12.5,<1.0.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<1.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "pre-commit" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815, upload-time = "2024-07-28T19:59:01.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643, upload-time = "2024-07-28T19:58:59.335Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/81/58c70036dffeccb7fe7d79d6260c69f7a28272bbd3909c29a01ea9422744/python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3", size = 72212, upload-time = "2026-07-08T23:06:50.691Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe", size = 34181, upload-time = "2026-07-08T23:06:49.402Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/d9/b477fddb68840b570af8b22afe9b035cbc277b5fb7b33dea390617a8b10f/virtualenv-21.6.1.tar.gz", hash = "sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128", size = 5526620, upload-time = "2026-07-10T19:33:53.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/7c/4e7225d46d634a0d8d534dd8a6ce0c319d09b4d0cf0337eb314ca4789d8c/virtualenv-21.6.1-py3-none-any.whl", hash = "sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b", size = 5506392, upload-time = "2026-07-10T19:33:51.629Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/13/d47429afcc2c28616c32640009c84ea3f95660dab805766345b9682468e0/websockets-16.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0", size = 179770, upload-time = "2026-07-10T06:30:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/2f0a722039a1e0107be73ed672ba604449b4956e48733e8e6b8a005aea42/websockets-16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f", size = 177455, upload-time = "2026-07-10T06:30:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/43/6a/c26b0ae449e93d256ce5cdd50d5fe97b575a63e8dcd311a1faa972fd6bc6/websockets-16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94", size = 177731, upload-time = "2026-07-10T06:30:49.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/3f/381550b344a02f0d2f84cda25e79b54575291bc7022128a41163fe8ba5b0/websockets-16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de", size = 187066, upload-time = "2026-07-10T06:30:50.505Z" }, + { url = "https://files.pythonhosted.org/packages/4a/87/5ab1ec2086910f23cfb9ec0c1c29fbcc24a9d190b5198b1557c00ce4a47e/websockets-16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976", size = 188301, upload-time = "2026-07-10T06:30:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/bbbb8e6fac4cfc53d7aaa69a3d531bf10799354b0021f4b58914aced8c1a/websockets-16.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3", size = 191594, upload-time = "2026-07-10T06:30:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/6c0c349443d6e999f481e3d9a0e57e7ac2956d75d6391bec24b92af3fe13/websockets-16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce", size = 188862, upload-time = "2026-07-10T06:30:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ea/a368d37c010425a5451f42052fe804e754e23333e8448aef5d55c8a8d64f/websockets-16.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef", size = 187633, upload-time = "2026-07-10T06:30:56.055Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4e/2ecd59add10d0855ec03dbdedfcdacdbd1aaabcd44b7dcbeda27538662e9/websockets-16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c", size = 185089, upload-time = "2026-07-10T06:30:57.444Z" }, + { url = "https://files.pythonhosted.org/packages/6f/eb/c6c3dcd7a01097bb0d42f4e9ef21a2c2a491d36b77cd0870ab59f9e8e77f/websockets-16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60", size = 187790, upload-time = "2026-07-10T06:30:58.731Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3e/775d36885d5e48ab8020aaf377de0ff5fbeb8bc2682a7e46419e4a14521c/websockets-16.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2", size = 186381, upload-time = "2026-07-10T06:31:00.355Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/6305c00812a92e47d0582604c02bd759db0118bbafc13f707d712dbcf898/websockets-16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030", size = 188193, upload-time = "2026-07-10T06:31:01.677Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/96bf8302c81d961585b4d34a2ddd3f229782f9b8c57bc78bbf98f1b1a4ac/websockets-16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216", size = 185771, upload-time = "2026-07-10T06:31:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1f/e8fe44b1d2dc417d740d9959d28fd2a846f268e7df38a686c04ac7dfe947/websockets-16.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4", size = 186803, upload-time = "2026-07-10T06:31:04.34Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/b07d3a4e1eb2ab03e94e7f53f0c7a628e85fde6ad86011f7afd08f27b985/websockets-16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5", size = 187041, upload-time = "2026-07-10T06:31:05.567Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fd/e0abb8acc435642ac4a671490f6cf781c882f3fe682cdced9080ea455ab5/websockets-16.1-cp311-cp311-win32.whl", hash = "sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4", size = 180158, upload-time = "2026-07-10T06:31:06.929Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/85574d9458d3b913090087b817df0cc47b68e9a01dd0ab6ac04b77f49b0a/websockets-16.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7", size = 180456, upload-time = "2026-07-10T06:31:08.247Z" }, + { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, + { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, + { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, + { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" }, + { url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" }, + { url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" }, + { url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" }, + { url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" }, + { url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" }, + { url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" }, + { url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" }, + { url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f4/84ef884775bbe77c46cce79bc7d705ea3bc6574cc00acf81af89754c077d/websockets-16.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9", size = 177387, upload-time = "2026-07-10T06:32:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d9/6831ec6f65e1eeac770375f4f4b604f23df9bafaa1b47004bc5f9488d513/websockets-16.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a", size = 177663, upload-time = "2026-07-10T06:32:50.043Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/21d4922fa7fe855813a8b38f181a0ecf02a586e16c1f095fd05471f78cc2/websockets-16.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a", size = 178501, upload-time = "2026-07-10T06:32:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/91/87/7a0320df854dacd09507ca972cb04a4dc5aae279583cc5b80ad5f5819533/websockets-16.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6", size = 179397, upload-time = "2026-07-10T06:32:52.892Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/0da1eb8c8da2ace7b578c8523d32618af85e62a9ebad56051d4a14a38a1c/websockets-16.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f", size = 180546, upload-time = "2026-07-10T06:32:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +]