Files
portfolio-hub/docs/runtime-flows.md
T

38 KiB

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 <name> [--user <username>] [--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 <tag_id> --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 <asset_id> <tag_id>
    • 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 <portfolio_id> <tag_id>
    • 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 <asset_id> <tag_id> --force / pfhub tag remove portfolio <portfolio_id> <tag_id> --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 <name> positional argument and --user <username> 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 <name> as the portfolio identifier; portfolio_id (UUID) is server-generated and returned in the response.
  4. update --new-name <new_name> renames the portfolio; --force is required to confirm the rename.
  5. comment and tag sub-commands continue to accept <portfolio_id> (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.
    • Required strategy_id is resolved with repo.get_strategy_set(strategy_id); unknown → HTTP 404, inactive → HTTP 409. Portfolio mutation never creates a strategy implicitly.
    • 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).
    • Optional strategy_id is resolved before repo.update_portfolio(...); unknown → HTTP 404, inactive → HTTP 409. The stable catalog ID becomes active_strategy_id.
    • repo.update_portfolio(portfolio_id, **fields) → HTTP 200 PortfolioItem.

Flow: v1 Strategy Catalog

  1. Read callers list active entries at GET /v1/strategies; admin reads include inactive entries under /v1/admin/strategies.
  2. Admin create canonicalizes the JSON config. A new row stores the requested status at revision 1; a matching existing name/config reuses its stable ID and applies an actual status change through the normal update path.
  3. Admin PATCH requires at least one field. The row is locked FOR UPDATE; effective no-ops keep the current revision, while concurrent real changes serialize, preserve ID, recompute the derived nick from the latest committed name/config, increment revision once, and update updated_at.
  4. Inactive entries are retained for existing portfolio references but rejected for new assignments.
  5. Portfolio selection locks an active strategy FOR SHARE in the same transaction as portfolio INSERT/UPDATE. Admin PATCH/DELETE lock it FOR UPDATE; DELETE checks both active_strategy_id and avabile_strategies and deletes in one transaction. Any reference returns HTTP 409, otherwise deletion returns HTTP 204 without an orphan or inactive-selection race.
  6. Legacy unversioned /strategy-sets routes are not registered.
  7. BUY/SELL persistence updates balances, price, buffer, timestamps, and ledger atomically but never writes active_strategy_id or avabile_strategies; a stale trade snapshot therefore cannot restore an old or deleted catalog assignment after a concurrent strategy change.
  8. DELETE /v1/portfolios/{portfolio_id} (soft delete):
    • _require_write_token blocks service/client → HTTP 403.
    • _get_portfolio_or_404 → ownership check.
    • repo.soft_delete_portfolio(portfolio_id) → sets status="disabled", managed_by_daemon_id=NULL.
    • Dependent data (portfolio_comments, tag_ids) preserved. Portfolio still queryable with ?include_disabled=true.
    • Returns HTTP 200 PortfolioItem with status="disabled".

Flow: v1 API — GET /v1/portfolios

  1. client token → HTTP 403. Any other valid token proceeds.
  2. service token: auth.daemon_id is None → HTTP 403. repo.list_portfolios(managed_by_daemon_id=auth.daemon_id, ...).
  3. user/admin token: admin with ?user_id=<id> → filter by that user; else filter by auth.user_id.
  4. ?include_disabled=false (default) → status="enabled" filter applied. ?include_disabled=true → no status filter.
  5. 필터링된 반환 대상 portfolio ID만 repo.get_return_summaries(ids)에 전달한다. repository는 한 번의 grouped query로 source와 무관한 유효 ledger를 side별 집계한다.
  6. 동일 portfolio에서 matching REVERSAL.target_ledger_entry_id로 참조된 INVEST/WITHDRAW/PROFIT은 original과 reversal의 source와 무관하게 제외한다. REVERSAL 자체는 금액 action으로 합산하지 않는다.
  7. total_invested는 유효 INVEST의 gross 합계, total_withdrawn은 유효 WITHDRAW 합계, net_contributed_capital은 두 값의 차이다. PROFIT는 원금 계산에서 제외하고 cumulative_profit_withdrawn으로만 집계한다. 집계 row가 없으면 값은 0.0이다.
  8. Returns HTTP 200 PortfolioListResponse.

단건 GET /v1/portfolios/{portfolio_id}/returns/summary도 같은 grouped aggregate 구현을 portfolio ID 하나로 호출하므로 목록과 유효 원장 의미가 일치한다.

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": <int>}.

Flow: v1 API — Portfolio Comment CRUD (REQ-031, BAT-010)

  1. POST /v1/portfolios/{portfolio_id}/comments (create):
    • require_read; _require_write_token blocks service/client → HTTP 403.
    • _get_portfolio_or_404 → ownership check; portfolio.status == "disabled" → HTTP 409.
    • repo.create_comment(portfolio_id, content=body.content) → HTTP 201 PortfolioCommentItem.
  2. GET /v1/portfolios/{portfolio_id}/comments (list):
    • require_read; service/client → HTTP 403 (inline).
    • _get_portfolio_or_404 → ownership check; disabled portfolio GET is allowed.
    • repo.list_comments(portfolio_id) → list in created_at desc order.
    • Returns HTTP 200 PortfolioCommentListResponse {"comments": [...], "total": <int>}.
  3. PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id} (update):
    • require_read; _require_write_token blocks service/client → HTTP 403.
    • _get_portfolio_or_404 → ownership check; portfolio.status == "disabled" → HTTP 409.
    • repo.update_comment(portfolio_id, comment_id, content=body.content) → ValueError → HTTP 404.
    • Returns HTTP 200 PortfolioCommentItem.
  4. DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id} (delete):
    • require_read; _require_write_token blocks service/client → HTTP 403.
    • _get_portfolio_or_404 → ownership check; portfolio.status == "disabled" → HTTP 409.
    • repo.delete_comment(portfolio_id, comment_id) → ValueError → HTTP 404.
    • Returns HTTP 204 (no content).

Flow: v1 API — DB Token Authentication

  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=<active|inactive|all> (default active), ?market=<code> (optional). status outside the allow-list → HTTP 422.
  3. repo.list_assets() → in-memory filter by status (skipped when all) and by market (<code> 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 /v1/portfolios/{portfolio_id}/ledger/manual (REQ-056, BAT-024)

  1. DB user/session token auth required. Service/client/env tokens fail closed with HTTP 403.
  2. _get_portfolio_or_404() enforces owner scope; non-owner receives HTTP 403.
  3. Disabled portfolio receives HTTP 409 PORTFOLIO_DISABLED.
  4. If managed_by_daemon_id IS NOT NULL and request lacks confirm_daemon_managed_manual_write=true, the API returns HTTP 409 MANUAL_WRITE_REQUIRES_CONFIRMATION with daemon id and required confirmation marker.
  5. Valid actions dispatch to a single repository transaction that locks the portfolio row (FOR UPDATE OF p), calculates the action effect, appends a portfolio_ledger row with source='manual', and updates the portfolio snapshot.
  6. BUY/SELL require sufficient cash/quantity. BUFFER_OUT/BUFFER_IN/WITHDRAW/PROFIT require sufficient buffer/cash as appropriate. Insufficient state returns HTTP 409 with structured code.
  7. REVERSAL requires target_ledger_entry_id, rejects duplicate reversal targets, and appends a reversing row instead of mutating/deleting the target.
  8. Response includes after-snapshot: cash_balance_after, asset_quantity_after, profit_buffer_after, total_value_after.
  9. GET /v1/portfolios/{portfolio_id}/ledger/manual returns newest manual entries only (source='manual').
  10. GET /v1/portfolios/{portfolio_id}/returns/summary returns net_contributed_capital=sum(INVEST)-sum(WITHDRAW) and cumulative_profit_withdrawn=sum(PROFIT) separately.

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 <daemon_slug> --user <username> (optionally --display-name <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 <name> --user <username> --daemon-id <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 <name> --daemon-id <daemon_id>.
  2. AuthService.rebind_service_token(name, daemon_id): a. get_service_token_by_name(name) → None → ValueError. b. get_daemon_by_id(daemon_id) → None 또는 is_active=false → ValueError. c. daemon.owner_user_id ≠ token.user_id → ValueError ("해당 토큰 사용자에 속하지 않습니다"). d. repository.rebind_service_token(name, daemon_id) → token.daemon_id 갱신, runtime_id 불변.
  3. Returns (previous_daemon_id, rebound_token).

Flow: pfhub daemon update --deactivate (BAT-007/REQ-022)

  1. Operator calls pfhub daemon update <daemon_id> --deactivate [--force].
  2. --force 없으면 확인 프롬프트; 거절 시 exit 0.
  3. repo.update_daemon(daemon_id, is_active=False) — daemon is_active=false 설정.
  4. repo.revoke_tokens_by_daemon_id(daemon_id) — 바인딩된 모든 active service token에 revoked_at=NOW() 설정.
  5. Console에 revoked count 출력.

Flow: PATCH /v1/admin/daemons/{daemon_id} — Owner Transfer

  1. Admin authentication is required; an unknown daemon returns HTTP 404.
  2. For an effective owner_user_id change, update_daemon_owner() starts one transaction and locks the daemon row FOR UPDATE.
  3. It checks portfolios.managed_by_daemon_id while holding that lock.
    • A reference exists: raise the dedicated conflict and return HTTP 409. Daemon owner, portfolio assignment, and broker credentials remain unchanged.
    • No reference exists: delete credentials, update the daemon owner, and commit the existing atomic credential-invalidation flow.
  4. Portfolio assignment uses FOR SHARE on the same daemon row before INSERT/UPDATE. Thus transfer-first makes the old owner's pending assignment fail as foreign; assignment-first makes the transfer fail HTTP 409. No automatic unassignment, portfolio owner migration, or alternate-daemon reassignment occurs.

Flow: Service-Token Rotate — daemon_id Invariant

  1. Operator calls pfhub service-token rotate <name> --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 <username> 확인.
    • 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 <command> 로 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 <name> --no-expiry 또는 POST /auth/service-tokens body "no_expiry": true → expires_at=NULL 발급.
  • session: no_expiry 요청 불가. 요청 시 → ValueError / HTTP 422 거부.
    • ⚠ 현행 코드에서 session 경로에 no_expiry 파라미터가 없어 코드 level 거부가 미구현. 정책은 거부이며 follow-up으로 코드 enforcement 추가 예정.
  • no_expiry와 expires_in_days를 동시에 지정하면 ValueError ("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다").

강제 회전 주기

  • session / service: 강제 회전 주기 없음.
  • env admin token: 분기(3개월) 권장 회전, 강제 없음 (아래 SOP 참조).

정책 위반 처리

  • no_expiry를 session에 요청 → 정책상 HTTP 422 또는 ValueError 거부 (미구현 — follow-up 필요).
  • 코드 reference: app/services/auth_service.py AuthService.issue_service_token() no_expiry + expires_in_days 분기 검증.

env admin token 운영 SOP

(REQ-024/BAT-008 — dcy-004 FR-9, OQ-6 결정: 분기 권장 회전, 강제 없음)

적용 범위

PORTFOLIOHUB_ADMIN_TOKENS 환경 변수로 주입되는 admin token. dev/test/prod 모든 환경에 적용. prod 환경 약한 placeholder 발견 시 즉시 적용.

권장 회전 주기

분기(3개월). 강제 회전·만료 알람 없음. 운영자 자율 결정.

회전 트리거 조건

  • (a) 정기: 분기마다
  • (b) token leak 의심
  • (c) 운영자 교체·퇴사
  • (d) 보안 사고 대응

다단계 회전 절차 (7단계)

  1. 강한 무작위 token 생성:
    python3 -c "import secrets; print(secrets.token_urlsafe(48))"
    
  2. PORTFOLIOHUB_ADMIN_TOKENS에 신규 token을 콤마 분리로 추가 (다중 token 동시 운영):
    PORTFOLIOHUB_ADMIN_TOKENS=<구_token>,<신규_token>
    
  3. service rolling restart (구 token 계속 유효).
  4. 신규 token으로 admin 호출 검증 (예: GET /health, admin 작업 sanity check).
  5. 구 token을 PORTFOLIOHUB_ADMIN_TOKENS에서 제거:
    PORTFOLIOHUB_ADMIN_TOKENS=<신규_token>
    
  6. service rolling restart (구 token 무효화).
  7. evidence 기록: 회전 일자, 운영자, 신규 token 강도 확인, sanity check 결과.

rollback 절차

  • 회전 실패 시: 환경 변수에 구 token 재추가 후 rolling restart.
  • fallback admin 접근 경로: DB 직접 접근으로 임시 token 재생성 (비상 시).

Token 강도 가이드

  • 최소 48-byte URL-safe 무작위 (secrets.token_urlsafe(48) 이상).
  • 평문 단어, 예측 가능한 패턴 (예: admin-token-789) 금지.
  • dev/test 환경에서도 약한 placeholder 사용 금지.

운영자 권한

  • PORTFOLIOHUB_ADMIN_TOKENS 추가/제거는 인프라 운영자 권한.
  • 현행 코드(app/core/auth.py:_resolve_permission)가 set 기반으로 다중 token 처리 → 추가 코드 변경 없이 다중 token 동시 운영 가능.

Current Gaps / Planned Changes

  • End-to-end validation against a real PostgreSQL or TimescaleDB deployment is still pending.
  • 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.