# Portfolio Hub Runtime Flows - Baseline Mode: observed - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers - Last Updated (KST): 2026-05-21 00:00 - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004) - Primary Runtime: mixed ## Project Summary - PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. ## 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 - uvicorn app.main:app :: serve the authenticated HTTP API, OpenAPI docs, and startup bootstrap hooks - pfhub :: run operational CLI commands for health, users, portfolios, strategies, tags, service tokens, and daemon registry - alembic upgrade head :: apply ordered database schema migrations for persistent environments ## Shared Components - app/core/config.py Settings loading and environment normalization - app/core/auth.py permission guards and auth context resolution - app/services/* business logic for auth, users, portfolios, and strategy sets - app/repositories/factory.py repository selection and Postgres config construction - app/repositories/postgres.py Postgres persistence, schema bootstrap, and query execution ## Flow: `HTTP API Request Handling` 1. Uvicorn loads app.main:app, which creates the FastAPI application from create_app(). 2. create_app() loads Settings, registers middleware and routers, and wires exception handlers. 3. During lifespan startup, the app optionally bootstraps an initial admin user when DB mode and bootstrap credentials are configured. 4. Incoming requests pass through auth and permission dependencies before services mutate state or query persistence. 5. Services call the repository factory, which returns PostgresPortfolioRepository only when PORTFOLIOHUB_DB_ENABLED=true. 6. Routes serialize domain results into app/schemas response models or return HTTP 204 for deletion flows. ## Flow: `CLI Operational Command Execution` 1. The pfhub entrypoint builds a Typer application and injects Settings, console output, and a repository factory into AppContext. 2. Command groups parse arguments, confirmations, and output format selection before invoking the relevant service or repository methods. 3. Database setup commands use the shared settings layer to target the configured PostgreSQL or TimescaleDB instance. 4. Read commands print table or JSON output, while write commands require explicit confirmation unless --force is supplied. 5. Command failures surface as validation errors, feature-not-ready errors, or persistence and auth exceptions. ## Flow: `pfhub tag CLI Command Execution` 1. `pfhub tag create [--user ] [--admin]` - Resolves `--user` to `user_id` via `repo.get_user_by_username()`; raises ValueError if not found. - Calls `repo.create_tag(name=name.strip(), is_admin_tag=--admin, created_by_user_id=resolved_id)`. - Duplicate name → repo ValueError → console error, exit 1. 2. `pfhub tag list [--inactive]` - Calls `repo.list_tags_master(include_inactive=--inactive)`. - Renders Tag table (table format) or JSON array (--format json). 3. `pfhub tag deactivate --force` - Without `--force`: prompts for confirmation; abort on refusal (exit 0). - Calls `repo.get_tag_by_id(tag_id)`; if not found → ValueError → exit 1. - If already inactive → prints "already inactive", returns (idempotent, exit 0). - Calls `repo.deactivate_tag(tag_id)`. 4. `pfhub tag apply asset ` - Validates tag: get_tag_by_id → not found → ValueError; not is_admin_tag → ValueError; not is_active → ValueError. - Calls `repo.append_portfolio_tag_id(asset_id, tag_id)`. 5. `pfhub tag apply portfolio ` - Validates tag: get_tag_by_id → not found → ValueError; not is_active → ValueError. - Resolves portfolio: repo.get(portfolio_id) → not found → ValueError. - Policy check: tag.is_admin_tag OR tag.created_by_user_id == portfolio.owner_user_id; else → ValueError. - Calls `repo.append_portfolio_tag_id(portfolio_id, tag_id)`. 6. `pfhub tag remove asset --force` / `pfhub tag remove portfolio --force` - Without `--force`: prompts for confirmation; abort on refusal (exit 0). - Calls `repo.remove_portfolio_tag_id(...)`. 7. `pfhub portfolio tag add/list/remove` — **removed in BAT-005**; Typer returns exit code 2 (no such command). ## Flow: `CLI Portfolio Command — Name+User Resolution` 1. All portfolio mutation commands (`status`, `create`, `update`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`) accept a `` positional argument and `--user ` option. 2. `_resolve_portfolio_id(repository, name, user)` is called before any mutation: a. Calls `get_user_by_username(user)` → raises ValueError if user not found. b. Calls `get_portfolio_by_name_and_user(owner_user_id, name)` → raises ValueError if portfolio not found. c. Returns `portfolio.portfolio_id` (UUID) for downstream use. 3. `create` command takes `` as the portfolio identifier; portfolio_id (UUID) is server-generated and returned in the response. 4. `update --new-name ` renames the portfolio; `--force` is required to confirm the rename. 5. `comment` and `tag` sub-commands continue to accept `` (UUID) directly; they are outside the name+user resolution flow. ## Flow Constraints - Persistence-dependent HTTP and CLI flows require PORTFOLIOHUB_DB_ENABLED=true plus valid TimescaleDB connection settings. - Permission levels read, trade-write, and admin must map consistently across API guards, issued tokens, and CLI mutations. - Request validation and auth resolution should happen before persistence mutation or response rendering. - The health endpoint remains unauthenticated so infrastructure can probe readiness without a token. - `GET /health` response includes `db_enabled: bool` (REQ-035, dcy-005). 본 필드는 환경변수 `PORTFOLIOHUB_DB_ENABLED` 설정값을 그대로 노출하는 것이며 actual DB connectivity ping이 아니다. production-grade readiness probe (SELECT 1 ping 등)는 본 endpoint 범위 밖이며 별도 `/readiness` endpoint 분리는 후속 Discovery에서 다룬다. ## Flow: `v1 API — Tag Management (POST/GET/PATCH /v1/tags)` 1. **POST /v1/tags** (create tag): - DB token required; trade-write or admin permission. - Env token → HTTP 403. - `name` is stripped and lowercased; `is_admin_tag` set if caller's permission is admin. - `repo.create_tag(name, is_admin_tag, created_by_user_id=auth.user_id)` → UniqueViolation → HTTP 409. - Returns HTTP 201 `TagItem`. 2. **GET /v1/tags** (list tags): - DB token required; read permission. - `?include_inactive=true` → `repo.list_tags_master(include_inactive=True)`; default returns active only. - Returns HTTP 200 `TagListResponse {"tags": [...], "total": N}`. 3. **PATCH /v1/tags/{tag_id}/deactivate**: - DB token required; read permission. - `repo.get_tag_by_id(tag_id)` → None → HTTP 404. - Caller must be admin or tag creator (`auth.user_id == tag.created_by_user_id`); else → HTTP 403. - If already inactive → return HTTP 200 immediately (idempotent). - `repo.deactivate_tag(tag_id)` → HTTP 200 `TagItem`. ## Flow: `v1 API — Portfolio Mutation (POST/PATCH/DELETE /v1/portfolios)` 1. **POST /v1/portfolios** (create): - Any valid token; `_require_write_token` blocks service/client (`token_type ∈ {service, client}` → HTTP 403). - admin with `body.user_id` → creates portfolio for target user; otherwise uses `auth.user_id`. - `repo.get_asset_by_id(asset_id)` → None or inactive → HTTP 422. - `repo.get_or_create_strategy_set(strategy_name, strategy_params_json)` → resolves strategy. - `repo.create_portfolio(...)` with `status="enabled"` → HTTP 201 `PortfolioItem`. 2. **PATCH /v1/portfolios/{portfolio_id}** (update): - `_require_write_token` blocks service/client → HTTP 403. - `_get_portfolio_or_404(auth, portfolio_id, repo)` → checks ownership; non-owner non-admin → HTTP 403. - If `portfolio.status == "disabled"`: non-status fields present → HTTP 409 (only status restoration allowed). - `repo.update_portfolio(portfolio_id, **fields)` → HTTP 200 `PortfolioItem`. 3. **DELETE /v1/portfolios/{portfolio_id}** (soft delete): - `_require_write_token` blocks service/client → HTTP 403. - `_get_portfolio_or_404` → ownership check. - `repo.soft_delete_portfolio(portfolio_id)` → sets `status="disabled"`, `managed_by_daemon_id=NULL`. - Dependent data (portfolio_comments, tag_ids) preserved. Portfolio still queryable with `?include_disabled=true`. - Returns HTTP 200 `PortfolioItem` with `status="disabled"`. ## Flow: `v1 API — GET /v1/portfolios` 1. client token → HTTP 403. Any other valid token proceeds. 2. `service` token: `auth.daemon_id is None` → HTTP 403. `repo.list_portfolios(managed_by_daemon_id=auth.daemon_id, ...)`. 3. `user`/`admin` token: admin with `?user_id=` → filter by that user; else filter by `auth.user_id`. 4. `?include_disabled=false` (default) → `status="enabled"` filter applied. `?include_disabled=true` → no status filter. 5. Returns HTTP 200 `PortfolioListResponse`. ## Flow: `v1 API — Soft Delete and Dependent Data Preservation` 1. `DELETE /v1/portfolios/{portfolio_id}` sets `portfolios.status="disabled"` and `portfolios.managed_by_daemon_id=NULL`. 2. `portfolio_comments` rows keyed by `portfolio_id` are NOT deleted; they remain retrievable via `GET /v1/portfolios/{id}/comments` even after soft delete. 3. `portfolios.tag_ids UUID[]` is NOT cleared; existing tag references are preserved. 4. Disabled portfolios are excluded from `GET /v1/portfolios` responses by default (`?include_disabled=false`). 5. Write mutations (PATCH non-status fields, POST/PATCH/DELETE tags, POST/PATCH/DELETE comments) on a disabled portfolio → HTTP 409. READ operations (GET tags, GET comments) remain allowed. 6. Status restoration: `PATCH /v1/portfolios/{id}` with `{"status": "enabled"}` is the only allowed mutation on a disabled portfolio; this re-enables the portfolio. ## Flow: `v1 API — Tag Apply/Remove (POST/DELETE /v1/assets/{id}/tags and /v1/portfolios/{id}/tags)` 1. **POST /v1/assets/{asset_id}/tags** (apply tag to asset — admin only): - DB token required; admin permission (`require_admin`). - `repo.get(asset_id)` → ValueError → HTTP 404. - `repo.get_tag_by_id(tag_id)` → None → HTTP 404. - `not tag.is_admin_tag` → HTTP 403; `not tag.is_active` → HTTP 403. - `repo.append_asset_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "applied"}` (BAT-013에서 portfolios → assets 테이블 대상으로 정합화). 2. **DELETE /v1/assets/{asset_id}/tags/{tag_id}** (remove tag from asset — admin only): - DB token required; admin permission. - `repo.get(asset_id)` → ValueError → HTTP 404. - `repo.remove_asset_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}` (BAT-013에서 portfolios → assets 테이블 대상으로 정합화). 3. **POST /v1/portfolios/{portfolio_id}/tags** (apply tag to portfolio — owner or admin): - DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (`_require_write_token`). - `repo.get(portfolio_id)` → ValueError → HTTP 404. - Caller must be portfolio owner or admin; else → HTTP 403. - `portfolio.status == "disabled"` → HTTP 409 (disabled portfolio write blocked). - `repo.get_tag_by_id(tag_id)` → None → HTTP 404. - `not tag.is_active` → HTTP 403. - Policy: `tag.is_admin_tag OR tag.created_by_user_id == portfolio.owner_user_id`; else → HTTP 403. - `repo.append_portfolio_tag_id(portfolio_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "applied"}`. 4. **DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}** (remove tag from portfolio — owner or admin): - DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (`_require_write_token`). - `repo.get(portfolio_id)` → ValueError → HTTP 404. - Caller must be portfolio owner or admin; else → HTTP 403. - `portfolio.status == "disabled"` → HTTP 409. - `repo.remove_portfolio_tag_id(portfolio_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}`. 5. **GET /v1/portfolios/{portfolio_id}/tags** (list applied tags — REQ-030, BAT-010): - `require_read`; service/client → HTTP 403 (inline). - `_get_portfolio_or_404(auth, portfolio_id, repo)` → ownership check; disabled portfolio allowed (GET). - `repo.get_tags_by_ids(portfolio.tag_ids)` → dict; empty tag_ids → `PortfolioTagListResponse(tags=[], total=0)`. - Returns HTTP 200 `PortfolioTagListResponse {"tags": [TagItem], "total": }`. ## Flow: `v1 API — Portfolio Comment CRUD (REQ-031, BAT-010)` 1. **POST /v1/portfolios/{portfolio_id}/comments** (create): - `require_read`; `_require_write_token` blocks service/client → HTTP 403. - `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409. - `repo.create_comment(portfolio_id, content=body.content)` → HTTP 201 `PortfolioCommentItem`. 2. **GET /v1/portfolios/{portfolio_id}/comments** (list): - `require_read`; service/client → HTTP 403 (inline). - `_get_portfolio_or_404` → ownership check; disabled portfolio GET is allowed. - `repo.list_comments(portfolio_id)` → list in `created_at desc` order. - Returns HTTP 200 `PortfolioCommentListResponse {"comments": [...], "total": }`. 3. **PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}** (update): - `require_read`; `_require_write_token` blocks service/client → HTTP 403. - `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409. - `repo.update_comment(portfolio_id, comment_id, content=body.content)` → ValueError → HTTP 404. - Returns HTTP 200 `PortfolioCommentItem`. 4. **DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}** (delete): - `require_read`; `_require_write_token` blocks service/client → HTTP 403. - `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409. - `repo.delete_comment(portfolio_id, comment_id)` → ValueError → HTTP 404. - Returns HTTP 204 (no content). ## Flow: `v1 API — DB Token Authentication` 1. v1 endpoint receives Bearer token from Authorization header. 2. `require_read` / `require_admin` resolves the token: checks env tokens first, then DB lookup via `get_token_principal()`. 3. Env tokens → HTTP 403 (all /v1 endpoints require DB tokens). 4. `AuthContext` is populated with `user_id`, `token_type`, `permission`, `runtime_id`, and `daemon_id`. - `token_type`: "session" (login) | "service" (daemon-bound) — "client" type 발급 제거됨 (REQ-016/BAT-006) - `daemon_id`: service token에만 존재; NULL이면 daemon-scoped endpoint에서 HTTP 403 fail-closed ## Flow: `v1 API — GET /v1/assets` (admin asset catalog) 1. Admin token required (`require_admin`). Non-admin (user/service/client) callers receive HTTP 403. 2. Query params: `?status=` (default `active`), `?market=` (optional). `status` outside the allow-list → HTTP 422. 3. `repo.list_assets()` → in-memory filter by `status` (skipped when `all`) and by `market` (` in asset.markets`). 4. Tag archived 합성: 결과 자산들의 `tag_ids` union을 모아 `repo.get_tags_by_ids(...)`로 1회 조회. `tags.is_active=False` 또는 마스터에 없는 tag_id는 `archived=True`로 응답. 빈 배열은 그대로 빈 배열. 5. Returns `AssetCatalogListResponse` with `items[]` and `total`. Each item: `asset_id, symbol, markets, display_name, status, tag_ids: [{tag_id, archived}], created_at, updated_at`. Portfolio 필드(`cash`, `quantity`, `owner_user_id` 등)는 포함하지 않는다 — 포지션 조회는 `GET /v1/portfolios`(REQ-028). ## Flow: `Admin Asset Catalog CRUD — POST/PATCH /v1/assets` 1. Admin token required (`require_admin`). 2. `POST /v1/assets`: `repo.insert_asset(symbol, markets, display_name, status)` → 201 `AssetCatalogResponse`. 3. `PATCH /v1/assets/{asset_id}`: `repo.update_asset(asset_id, markets, display_name, status)` → 200 `AssetCatalogResponse`. 4. `PATCH /v1/assets/{asset_id}/deactivate`: `repo.update_asset(asset_id, status="inactive")` → 200 `AssetCatalogResponse`. 5. Non-admin callers receive HTTP 403. ## Flow: `v1 API — GET /v1/user_info` 1. Service-token auth required. Available to all service tokens including unbound (daemon_id=NULL); no daemon-id guard. 2. `get_user()` fetches user record by `user_id`. 3. `list_broker_credentials()` fetches all broker credential rows for the user. 4. For each credential, Fernet-decrypts `encrypted_payload` using `CREDENTIAL_ENCRYPTION_KEY`; decryption failures log a warning and return `credential_payload: null`. 5. Returns `UserInfoResponse` with `trading_enabled` (from `is_active`), `disabled_reason`, `brokers[]`, `runtime_id` (null if unbound), and `daemon_id` (null for legacy/unbound tokens — diagnostic, not an error). ## Flow: `v1 API — POST /v1/portfolios/{portfolio_id}/ledger/trades` (REQ-029, BAT-011) 1. Service-token auth required with `trade-write` permission (`_require_service_token` dependency). 2. `token_source != "db"` or `token_type != "service"` → HTTP 403. 3. `auth.daemon_id is None` → HTTP 403 fail-closed ("daemon_id not assigned to this token"). 4. `repo.get(portfolio_id)` → exception → HTTP 404. 5. `portfolio.status == "disabled"` → HTTP 409 (disabled write 차단, REQ-027 정합). 6. `portfolio.managed_by_daemon_id is None` → HTTP 403 ("Asset is not assigned to any daemon"). 7. `portfolio.managed_by_daemon_id != auth.daemon_id` → HTTP 403 ("Token daemon does not match asset daemon assignment"). 8. `insert_ledger_trade(asset_id=portfolio_id, ...)` with idempotency key `(portfolio_id, order_id, execution_id)`. 9. New record → HTTP 201 + `status="new"`. 10. Duplicate key + identical payload → HTTP 200 + `status="duplicate"`. 11. Duplicate key + different payload → HTTP 409. - 구 path `POST /v1/assets/{asset_id}/ledger/trades`: BAT-011에서 제거. 호출 시 HTTP 404. ## Flow: `v1 API — POST /auth/service-tokens` 1. Admin token auth required (`require_admin` dependency). 2. Validates request body: `name`, `username`, `permission`, `expires_in_days` / `no_expiry`, `daemon_id` (optional; BAT-006/REQ-018 forward 보정). 3. If `daemon_id` provided: `get_daemon_by_id()` → 존재하지 않거나 inactive → HTTP 422. daemon.owner_user_id ≠ 발급 대상 user → HTTP 422. 4. Calls `AuthService.issue_service_token()` which creates a new DB token with `runtime_id` and `daemon_id`. 5. Returns HTTP 201 with `token` (raw value), `runtime_id`, `daemon_id`, and `auth_token` metadata. ## Flow: `Daemon Registry — Create and Bind Service Token` 1. Operator creates a daemon via `pfhub daemon create --user ` (optionally `--display-name `). - `AuthService` is not involved; the repository creates the `Daemon` record directly with `owner_user_id` resolved from `username`. - Returns `daemon.id` (server-generated UUID), `daemon_slug`, `is_active=True`. 2. Operator issues a service token bound to the daemon via `pfhub service-token issue --user --daemon-id `. - `AuthService.issue_service_token()` validates: daemon exists, is_active=True, and `owner_user_id` matches the token's user. - Unknown or inactive daemon → ValueError ("존재하지 않거나 비활성"). - `create_auth_token()` persists the token with `daemon_id` set. 3. Operator assigns an asset to the daemon via portfolio update (`managed_by_daemon_id` column set). 4. `GET /v1/assets` with the bound token returns only assets where `portfolios.managed_by_daemon_id = token.daemon_id`. 5. `POST /v1/portfolios/{portfolio_id}/ledger/trades` with the bound token passes daemon assignment authorization and records the trade (신 path, REQ-029). ## Flow: `pfhub service-token rebind` (BAT-007/REQ-023) 1. Operator calls `pfhub service-token rebind --daemon-id `. 2. `AuthService.rebind_service_token(name, daemon_id)`: a. `get_service_token_by_name(name)` → None → ValueError. b. `get_daemon_by_id(daemon_id)` → None 또는 is_active=false → ValueError. c. `daemon.owner_user_id ≠ token.user_id` → ValueError ("해당 토큰 사용자에 속하지 않습니다"). d. `repository.rebind_service_token(name, daemon_id)` → token.daemon_id 갱신, runtime_id 불변. 3. Returns `(previous_daemon_id, rebound_token)`. ## Flow: `pfhub daemon update --deactivate` (BAT-007/REQ-022) 1. Operator calls `pfhub daemon update --deactivate [--force]`. 2. `--force` 없으면 확인 프롬프트; 거절 시 exit 0. 3. `repo.update_daemon(daemon_id, is_active=False)` — daemon is_active=false 설정. 4. `repo.revoke_tokens_by_daemon_id(daemon_id)` — 바인딩된 모든 active service token에 `revoked_at=NOW()` 설정. 5. Console에 revoked count 출력. ## Flow: `Service-Token Rotate — daemon_id Invariant` 1. Operator calls `pfhub service-token rotate --force`. 2. `AuthService.rotate_service_token(name)` delegates to `repository.rotate_service_token(name)`. 3. Repository looks up the existing token by name; raises ValueError if not found. 4. Old token is revoked (`revoked_at = NOW()`). 5. New token row is created with: - New raw token value (generate_token()), new `token_hash`, new `token_prefix`. - New `runtime_id` (uuid4) — per-token, changes on every rotate. - Same `daemon_id` as the old token — stable across rotates (daemon_id invariant). - Same `user_id`, `name`, `permission`, `token_type`, `expires_at`. 6. Returns raw token (shown once) and new `auth_token` metadata including new `runtime_id`. 7. `GET /v1/user_info` called with the new token returns the same `daemon_id` but a new `runtime_id`. ## Flow: `Application Startup Validation` 1. `lifespan` startup hook runs `_bootstrap_initial_user()` if DB enabled and bootstrap credentials configured. 2. If `db_enabled=True`, calls `settings.require_credential_encryption_key()`. 3. If `CREDENTIAL_ENCRYPTION_KEY` is not configured, raises `RuntimeError` and aborts startup. ## Flow: `Local DB Initialization and Validation` ### Scenario A — Fresh DB (DB 미존재 상태) 1. `docker compose -f docker-compose.local-test.yml up -d` — TimescaleDB 컨테이너 시작. 2. 컨테이너 준비 확인: `docker compose -f docker-compose.local-test.yml ps` → `portfolio-hub-timescaledb` 상태가 `healthy` 인지 확인. 3. `pfhub_DB_ENABLED=true pfhub init-db` — 대상 DB 생성 + Alembic `upgrade head` 실행. - 기대 출력: `database: portfolio_hub`, `created: True`, `schema_initialized: True`, `migration_applied: head` - pass 기준: exit code 0, 위 필드 모두 출력 - fail 기준: connection refused, permission denied, Alembic revision 오류 4. `pfhub_DB_ENABLED=true pfhub health-check` — DB ping 성공 확인. - 기대 출력: `db_enabled: True`, `repository: PostgresPortfolioRepository` - pass 기준: exit code 0, `db_enabled: True` 5. Smoke flow: 사용자 생성 → portfolio 생성 → `portfolio list --user ` 확인. - pass 기준: 생성된 portfolio가 list 출력에 포함됨 ### Scenario B — runtime-precreated schema (ensure_schema() 선행) 1. DB 컨테이너 시작 (Scenario A 1~2 동일). 2. `pfhub_DB_ENABLED=true uvicorn app.main:app` — App 기동. `ensure_schema()`가 핵심 테이블 생성. 3. `pfhub_DB_ENABLED=true pfhub migrate-db` — `bootstrap_or_upgrade_to_head()` 실행. - 내부 동작: `alembic_version` 테이블 없음 → 기존 핵심 테이블(portfolios 등) 존재 → legacy stamp → head로 stamp + 미보유 객체(broker_credentials, ledger_trades, runtime_id 등) upgrade - 기대 출력: `migration_applied: head` - pass 기준: exit code 0, 이후 `pfhub health-check` exit code 0 4. `pfhub_DB_ENABLED=true pfhub health-check` 재확인. ### Repeatable Run Criteria - 모든 시나리오는 컨테이너 볼륨 초기화(`docker compose down -v`) 후 재실행 가능. - 출력 형식: `pfhub --output json ` 로 JSON 출력 수집 시 필드명이 고정됨. - 로그 수집 위치: `docker compose logs portfolio-hub-timescaledb` — DB 측 오류 시 참조. - Evidence 형식: exit code + 주요 필드 값 스크린샷 또는 텍스트 캡처 → batch verification.md에 기록. ## Flow: `Alembic vs ensure_schema() Schema Ownership Boundary` `ensure_schema()` (postgres.py)와 Alembic revision 양쪽이 다루는 객체가 존재한다. 충돌은 `bootstrap_or_upgrade_to_head()`의 legacy stamp 경로 + 각 revision의 IF NOT EXISTS guard로 처리된다. ### 경계 규칙 1. **ensure_schema() 선행 가능 객체**: users, auth_tokens, strategy_sets, portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 관련 인덱스. 모두 IF NOT EXISTS로 보호됨. BAT-010: `auth_tokens.daemon_id`, `portfolios.managed_by_daemon_id`, `daemons` 테이블도 ensure_schema()에 ADD/CREATE IF NOT EXISTS guard로 추가됨. 2. **Alembic만 소유 (ensure_schema()에 없음)**: `portfolios.market`, `auth_tokens.runtime_id`, `idx_auth_tokens_runtime_id_unique`, `broker_credentials`, `ledger_trades`. ensure_schema()만 실행한 환경에서 v1 API 사용 불가. 3. **BAT-010 migration 20260513_0016**: `daemons` 테이블, `auth_tokens.daemon_id`, `portfolios.managed_by_daemon_id` 추가. down_revision=20260511_0015. ensure_schema() 선행 환경에서도 migration 적용 시 IF NOT EXISTS guard로 safe. 4. **허용 실행 순서**: `init-db` 선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시 `migrate-db` 후속 실행. 5. **금지**: `alembic_version` 테이블 수동 삭제 후 migrate-db 재실행 (legacy stamp 오판). ## Token Expiry and Rotation Policy (REQ-021/BAT-008 — dcy-004 FR-6, OQ-5 결정: 현행 TTL 유지) ### Type별 기본 TTL | Token Type | 기본 만료 | no_expiry 허용 | 비고 | |------------|-----------|----------------|------| | session | `NOW() + settings.session_token_ttl_hours` | ✗ (거부) | `AuthService.login()` | | service | `NOW() + 365일` (기본) | ✓ | `AuthService.issue_service_token(no_expiry=True)` | | env | 없음 (TTL 미적용) | N/A | `PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수 | ### no_expiry 허용 범위 - service token: `pfhub service-token issue --no-expiry` 또는 POST /auth/service-tokens body `"no_expiry": true` → expires_at=NULL 발급. - session: no_expiry 요청 불가. 요청 시 → ValueError / HTTP 422 거부. - ⚠ 현행 코드에서 session 경로에 no_expiry 파라미터가 없어 코드 level 거부가 미구현. 정책은 거부이며 follow-up으로 코드 enforcement 추가 예정. - `no_expiry`와 `expires_in_days`를 동시에 지정하면 ValueError ("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다"). ### 강제 회전 주기 - session / service: 강제 회전 주기 없음. - env admin token: 분기(3개월) 권장 회전, 강제 없음 (아래 SOP 참조). ### 정책 위반 처리 - no_expiry를 session에 요청 → 정책상 HTTP 422 또는 ValueError 거부 (미구현 — follow-up 필요). - 코드 reference: `app/services/auth_service.py` `AuthService.issue_service_token()` no_expiry + expires_in_days 분기 검증. ## env admin token 운영 SOP (REQ-024/BAT-008 — dcy-004 FR-9, OQ-6 결정: 분기 권장 회전, 강제 없음) ### 적용 범위 `PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수로 주입되는 admin token. dev/test/prod 모든 환경에 적용. prod 환경 약한 placeholder 발견 시 즉시 적용. ### 권장 회전 주기 분기(3개월). 강제 회전·만료 알람 없음. 운영자 자율 결정. ### 회전 트리거 조건 - (a) 정기: 분기마다 - (b) token leak 의심 - (c) 운영자 교체·퇴사 - (d) 보안 사고 대응 ### 다단계 회전 절차 (7단계) 1. 강한 무작위 token 생성: ``` python3 -c "import secrets; print(secrets.token_urlsafe(48))" ``` 2. `PORTFOLIOHUB_ADMIN_TOKENS`에 신규 token을 콤마 분리로 **추가** (다중 token 동시 운영): ``` PORTFOLIOHUB_ADMIN_TOKENS=<구_token>,<신규_token> ``` 3. service rolling restart (구 token 계속 유효). 4. 신규 token으로 admin 호출 검증 (예: `GET /health`, admin 작업 sanity check). 5. 구 token을 `PORTFOLIOHUB_ADMIN_TOKENS`에서 **제거**: ``` PORTFOLIOHUB_ADMIN_TOKENS=<신규_token> ``` 6. service rolling restart (구 token 무효화). 7. evidence 기록: 회전 일자, 운영자, 신규 token 강도 확인, sanity check 결과. ### rollback 절차 - 회전 실패 시: 환경 변수에 구 token 재추가 후 rolling restart. - fallback admin 접근 경로: DB 직접 접근으로 임시 token 재생성 (비상 시). ### Token 강도 가이드 - 최소 48-byte URL-safe 무작위 (`secrets.token_urlsafe(48)` 이상). - 평문 단어, 예측 가능한 패턴 (예: `admin-token-789`) 금지. - dev/test 환경에서도 약한 placeholder 사용 금지. ### 운영자 권한 - `PORTFOLIOHUB_ADMIN_TOKENS` 추가/제거는 인프라 운영자 권한. - 현행 코드(`app/core/auth.py:_resolve_permission`)가 set 기반으로 다중 token 처리 → 추가 코드 변경 없이 다중 token 동시 운영 가능. ## Current Gaps / Planned Changes - End-to-end validation against a real PostgreSQL or TimescaleDB deployment is still pending. - Redis and Loki are configured as supporting infrastructure but are not yet represented as explicit runtime flows. - Broker credential write path (upsert) is available only via CLI; no HTTP API endpoint for credential registration exists yet. ## Change Log - 2026-05-21 KST: BAT-013 (REQ-005 CHG-20260521-02) — /v1/assets/{id}/tags apply/remove SQL 대상 정합화: `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id` 호출을 `repo.append_asset_tag_id`/`repo.remove_asset_tag_id`로 교체. portfolios 테이블 mutation 호출은 portfolio 라우트(`/v1/portfolios/{id}/tags`) 전용으로 한정. HTTP contract 무변, BAT-012 신설 `assets.tag_ids` 컬럼이 비로소 정상 채워짐. - 2026-05-21 KST: BAT-012 (REQ-002 / REQ-010 AC-9~12) — GET /v1/assets 흐름 재정의: 기존 token_type 분기 portfolio 조회 설명을 admin asset catalog 조회로 교체; tag_ids 배열 + archived 합성 흐름 신설(tags 마스터 is_active 조회로 비활성·미존재 tag_id를 `archived=True`로 응답). - 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 흐름 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체 (disabled 차단 + 인가 순서 명확화); Daemon Registry flow의 구 path 참조 갱신. - 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 흐름 추가: "Portfolio Mutation" 흐름 신규 추가(REQ-025~028; ownership + disabled 보호 + write token 차단); "GET /v1/portfolios" token 분기 흐름 신규 추가(REQ-028); "Soft Delete and Dependent Data Preservation" 흐름 신규 추가(REQ-027; disabled 의미 + 의존 데이터 보존); POST/DELETE /v1/portfolios/{id}/tags에 disabled(409) + service/client(403) 보강(REQ-030); "GET /v1/portfolios/{id}/tags" 흐름 추가(REQ-030); "Portfolio Comment CRUD" 흐름 신규 추가(REQ-031); Source Discovery/Batch 헤더 갱신. - 2026-05-21 KST: BAT-008 (REQ-019/021/024) — 토큰 surface 정합성 회복 + 신규 정책 섹션: DB Token Auth flow에 AuthContext token_type/daemon_id 설명 보강(REQ-019); POST /auth/service-tokens daemon_id forward 흐름 갱신(REQ-018/019); service-token rebind 흐름 추가(REQ-023); daemon update --deactivate 자동 revoke 흐름 추가(REQ-022); "Token Expiry and Rotation Policy" 섹션 신규 추가(REQ-021); "env admin token 운영 SOP" 섹션 신규 추가(REQ-024). - 2026-05-20 KST: bat-005 — REQ-008 AC-4: added pfhub tag CLI flow; added v1 tag management flow (POST/GET/PATCH /v1/tags); added v1 tag apply/remove flow (/v1/assets/{id}/tags, /v1/portfolios/{id}/tags); updated Covered Entry Points (tags). - 2026-05-13 KST: bat-010 — REQ-006/007/008/009/023/005/012: updated GET /v1/assets flow (daemon-id guard, daemon-scoped filter); updated GET /v1/user_info flow (daemon_id diagnostic, NULL → 200+null); updated POST ledger/trades flow (3-step daemon assignment auth, fail-closed); added "Daemon Registry — Create and Bind Service Token" flow; added "Service-Token Rotate — daemon_id Invariant" flow; updated Alembic/ensure_schema boundary (BAT-010 migration 20260513_0016); updated Covered Entry Points (daemon subcommand). - 2026-05-11 KST: bat-008 — REQ-011 AC-3 confirmed: v1 API flows and runtime_id issuance flow already present from bat-001; Source Discovery/Batch header updated. - 2026-05-11 KST: bat-007 — Added "Local DB Initialization and Validation" flow (Scenario A/B, repeatable run criteria); added "Alembic vs ensure_schema() Schema Ownership Boundary" flow. - 2026-05-11 KST: bat-006 — Added CLI portfolio name+user resolution flow; updated GET /v1/assets flow to note asset_id is UUID after BAT-006 migration. - 2026-05-01 KST: bat-001 — Added v1 API flows (service-token auth, GET /v1/assets, GET /v1/user_info, POST ledger/trades, POST /auth/service-tokens), startup validation flow. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.