# Portfolio Hub Interface Contract - Baseline Mode: observed - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers - Last Updated (KST): 2026-05-20 00:00 - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005 - Primary Runtime: mixed ## Project 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. ## 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 - Primary HTTP API :: manage authentication, portfolios, strategy sets, and users over JSON HTTP endpoints - PortfolioHub CLI :: provide command-line access to health checks, database setup, users, portfolios, strategies, and service tokens ## Interface: `Primary HTTP API` - Type: http-api - Consumers / Producers: authenticated clients, operators, and downstream services - Purpose: manage authentication, portfolios, strategy sets, and users over JSON HTTP endpoints - Stability: internal ### Inputs - Authorization: Bearer :: required for protected routes and mapped to read, trade-write, or admin permissions - JSON request bodies :: carry login, token issuance, user CRUD, strategy-set CRUD, portfolio mutations, comments, and tags - Route and query parameters :: select users, tokens, portfolios, filters, recent history windows, comments, and tags ### Outputs - JSON response models :: responses are shaped by app/schemas/auth.py, app/schemas/user.py, and app/schemas/portfolio.py - HTTP 201, 200, and 204 status codes :: create, read or update, and delete semantics respectively - GET /health payload :: returns `{"status": "ok", "service": "portfolio-hub", "version": "", "checked_at": ""}` for readiness checks ### Error Contract - 401 Unauthorized :: invalid credentials, invalid token, or missing token on protected routes - 403 Forbidden :: insufficient permission or token issuance policy violation - 404 Not Found :: missing user, token, portfolio, comment, tag, or strategy set - 422 Unprocessable Entity :: FastAPI or Pydantic request validation failure ### Compatibility Rules - Route prefixes /health, /auth, /users, /strategy-sets, and /portfolios define the current internal API surface. - Permission semantics read, trade-write, and admin must stay aligned across auth guards and token metadata. - Breaking route or schema changes require REQ and Batch documentation updates before release. ## Interface: `PortfolioHub CLI` - Type: cli - Consumers / Producers: operators and automation - Purpose: provide command-line access to health checks, database setup, users, portfolios, strategies, and service tokens - Stability: internal ### Inputs - pfhub subcommands :: include user, portfolio, strategy, tag, service-token, daemon, credential, health-check, init-db, and migrate-db command families - Flags and options :: include --format table|json, --force confirmations, and operation-specific mutation arguments - Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution - Portfolio identification (bat-006) :: all portfolio mutation commands accept `` (positional) + `--user ` instead of raw portfolio_id; `comment` sub-commands accept portfolio_id (UUID) directly - Tag commands (bat-005) :: `pfhub tag create/list/deactivate/apply/remove` operate on the tags master table; `pfhub portfolio tag add/list/remove` has been removed (breaking change) ### Outputs - Table or JSON console rendering :: read flows emit structured operator output suitable for manual review or automation - One-time token reveal :: issued client or service tokens are shown once and then only masked metadata remains - Process exit status :: command success or failure is communicated through standard CLI exit behavior ### Error Contract - Typer validation errors :: invalid arguments or missing required options stop the command before execution - Confirmation refusal :: write commands abort unless the operator confirms or passes --force - Persistence and auth failures :: disabled DB mode, connectivity issues, or permission problems surface as command errors ### Compatibility Rules - Command names and primary flags should remain stable for internal automation. - Write commands require explicit confirmation unless --force is present. - JSON output should remain machine-readable across additive command evolution. ## Interface: `v1 Consumer API (trex-basic)` - Type: http-api - Consumers / Producers: trex-basic service (Consumer-Driven Contract, dcy-002, dcy-006) - Purpose: provide daemon-scoped asset listing, user info with daemon_id diagnostic, and daemon-assignment-authorized trade ledger recording - Stability: internal (versioned under /v1) ### Authentication - All /v1 endpoints require `Authorization: Bearer ` header. - Token must be DB-issued (`token_source="db"`); env tokens are rejected with HTTP 403. - Admin catalog endpoints (`POST /v1/assets`, `PATCH /v1/assets/{id}`, `PATCH /v1/assets/{id}/deactivate`) require admin permission. - POST /v1/assets/{asset_id}/ledger/trades additionally requires `trade-write` permission. ### Token Type Branching - `token_type="service"`: daemon-scoped endpoints. `daemon_id` must be non-NULL (NULL → HTTP 403 fail-closed). - `token_type="user"`: user-scoped. Endpoints return owner's own data. - `token_type="admin"`: admin-scoped. May query other users' data via `?user_id=` param. ### Daemon Authorization Model - Service tokens are bound to a daemon at issuance via `daemon_id` field. Legacy/unbound tokens have `daemon_id=NULL`. - GET /v1/assets (service token) and POST /v1/assets/{asset_id}/ledger/trades are daemon-scoped. NULL token `daemon_id` → HTTP 403 (fail-closed). - GET /v1/user_info is available to any service token; returns `daemon_id: null` as diagnostic for unbound tokens. - Each asset (portfolio) may be assigned to exactly one daemon via `portfolios.managed_by_daemon_id`. Mismatch or unassigned asset → HTTP 403 on ledger writes. ### Endpoint: `GET /v1/assets` - Auth: DB token required; behavior branches by `token_type` - `service`: returns portfolios where `managed_by_daemon_id = token.daemon_id`; NULL daemon_id → 403 - `user`: returns caller's own portfolios (`owner_user_id = token.user_id`) - `admin`: returns caller's own portfolios; `?user_id=` overrides to specified user - Query Params: `?status=` (optional portfolio status filter), `?market=` (optional market code filter), `?user_id=` (admin only) - Response: HTTP 200 ``` { "total": , "assets": [ { "asset_id": "", // portfolio.portfolio_id "symbol": "", "status": "", "cash": , "quantity": , "broker_type": "", "strategy_id": "", "strategy_type": "", "strategy_params": {}, "updated_at": "", "name": "", "price": , "profit_buffer": , "tags": []|null, "owner_user_id": "", "created_at": "", "last_trade_at": "" } ] } ``` - Error: HTTP 401 if no token; HTTP 403 if env token or (service token with daemon_id NULL) ### Endpoint: `POST /v1/assets` (Admin Catalog) - Auth: admin token required - Body: `{"symbol": "", "markets": [], "display_name": "", "status": "active"}` - Response: HTTP 201 `AssetCatalogResponse` ``` { "asset_id": "", "symbol": "", "markets": [], "display_name": "", "status": "", "created_at": "", "updated_at": "" } ``` - Error: HTTP 403 if not admin ### Endpoint: `PATCH /v1/assets/{asset_id}` (Admin Catalog Update) - Auth: admin token required - Body: `{"markets": []|null, "display_name": "", "status": ""}` - Response: HTTP 200 `AssetCatalogResponse` - Error: HTTP 403 if not admin ### Endpoint: `PATCH /v1/assets/{asset_id}/deactivate` (Admin Catalog Deactivate) - Auth: admin token required - Body: none - Response: HTTP 200 `AssetCatalogResponse` with `status="inactive"` - Error: HTTP 403 if not admin ### Endpoint: `GET /v1/user_info` - Auth: service-token bearer (read); available to all service tokens including unbound (daemon_id=NULL) - Response: HTTP 200 ``` { "runtime_id": "", // null for tokens without runtime_id (diagnostic) "daemon_id": "", // null for unbound/legacy tokens (diagnostic) "trading_enabled": , // mapped from users.is_active "disabled_reason": "", // "account_inactive" when trading_enabled=false "brokers": [ { "broker": "", "account_ref": "", "credential_payload": {}|null, // Fernet-decrypted {app_key, app_secret} "credential_version": , "last_rotated_at": "" } ], "user_id": "", "username": "", "email": "", "permission": "" } ``` - Error: HTTP 401 if no token; HTTP 403 if not a service token ### Endpoint: `POST /v1/assets/{asset_id}/ledger/trades` - Auth: service-token bearer (trade-write); token must have `daemon_id` bound; asset must be assigned to that daemon - Path Param: `asset_id` — must be a valid UUID portfolio_id - Authorization: `portfolios.managed_by_daemon_id` must equal `token.daemon_id`; NULL token → 403; unassigned asset → 403; mismatch → 403 - Request Body: ``` { "order_id": "", // required, min_length=1 "execution_id": "", // required, min_length=1 "side": "buy"|"sell", "quantity": , // > 0 "price": , // > 0 "fee_amount": , // >= 0, default 0 "tax_amount": , // >= 0, default 0 "executed_at": "" } ``` - Response HTTP 201 (new record): ``` { "ledger_entry_id": , "asset_id": "", "order_id": "", "execution_id": "", "side": "", "quantity": , "price": , "fee_amount": , "tax_amount": , "executed_at": "", "recorded_at": "", "status": "new" } ``` - Response HTTP 200 (idempotent duplicate with identical payload): same body with `"status": "duplicate"` - Error: HTTP 401 if no token; HTTP 403 if not service token, daemon_id NULL, asset unassigned, or daemon mismatch; HTTP 404 if asset_id not found; HTTP 409 if duplicate key with different payload ### Idempotency Contract - Deduplication key: `(asset_id, order_id, execution_id)` — enforced by `uq_ledger_trades_idempotency` DB unique constraint - Identical payload re-send → HTTP 200 + `status="duplicate"`, no new row created - Same key, different payload → HTTP 409 Conflict ### Deployment Constraint (bat-006) - trex-basic and portfolio-hub must be deployed simultaneously when BAT-006 is released. - After BAT-006, all `asset_id` values in GET /v1/assets responses change from TEXT to UUID format. - trex-basic consumers must handle UUID-format asset_id before the coordinated deployment is completed. ## Interface: `Tag Management API (BAT-004)` - Type: http-api - Consumers / Producers: authenticated operators and admin services - Purpose: manage tag master records and apply/remove tags on assets and portfolios - Stability: internal (versioned under /v1) ### Authentication - All /v1/tags, /v1/assets/{id}/tags, /v1/portfolios/{id}/tags endpoints require `Authorization: Bearer `. - Token must be DB-issued (`token_source="db"`); env tokens rejected with HTTP 403. ### Endpoint: `POST /v1/tags` - Auth: DB token required; trade-write or admin permission - Body: `{"name": ""}` (min_length=1; normalized to lowercase + stripped) - Response: HTTP 201 `TagItem` `{"tag_id": "", "name": "", "is_admin_tag": , "is_active": true, "created_at": ""}` - is_admin_tag is set to true when caller has admin permission; false for trade-write - Error: HTTP 409 if tag name already exists; HTTP 403 if env token ### Endpoint: `GET /v1/tags` - Auth: DB token required; read permission - Query Params: `?include_inactive=true` (default false — returns only active tags) - Response: HTTP 200 `TagListResponse` `{"tags": [TagItem], "total": }` - Error: HTTP 403 if env token ### Endpoint: `PATCH /v1/tags/{tag_id}/deactivate` - Auth: DB token required; read permission (admin or tag creator) - Path Param: `tag_id` — must be a valid tag UUID - Body: none - Response: HTTP 200 `TagItem` with `is_active=false`; idempotent (already-inactive returns 200 immediately) - Error: HTTP 404 if tag not found; HTTP 403 if caller is not admin and not tag creator ### Endpoint: `POST /v1/assets/{asset_id}/tags` - Auth: DB token required; admin permission - Path Param: `asset_id` — maps to portfolio_id (v1 convention: asset_id = portfolio_id) - Body: `{"tag_id": ""}` - Response: HTTP 200 `TagApplyResponse` `{"tag_id": "", "status": "applied"}` - Policy: tag must have is_admin_tag=true AND is_active=true; non-admin-tag → HTTP 403; inactive → HTTP 403 - Error: HTTP 404 if asset or tag not found; HTTP 403 if policy violation or env token ### Endpoint: `DELETE /v1/assets/{asset_id}/tags/{tag_id}` - Auth: DB token required; admin permission - Body: none - Response: HTTP 200 `TagApplyResponse` `{"tag_id": "", "status": "removed"}` - Error: HTTP 404 if asset not found; HTTP 403 if not admin or env token ### Endpoint: `POST /v1/portfolios/{portfolio_id}/tags` - Auth: DB token required; trade-write or admin permission - Body: `{"tag_id": ""}` - Response: HTTP 200 `TagApplyResponse` `{"tag_id": "", "status": "applied"}` - Policy: tag must have is_active=true; caller must be portfolio owner or admin; non-admin non-owner-created tag → HTTP 403 - Error: HTTP 404 if portfolio or tag not found; HTTP 403 if policy violation or env token ### Endpoint: `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}` - Auth: DB token required; trade-write or admin permission - Body: none - Response: HTTP 200 `TagApplyResponse` `{"tag_id": "", "status": "removed"}` - Error: HTTP 404 if portfolio not found; HTTP 403 if caller is not owner or admin ## 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 settings. - Secret and token values are only revealed once at issuance time and are masked afterwards. ## Current Gaps / Planned Changes - External consumer versioning policy is still implicit and not published as a separate contract artifact. - Service-token lifecycle and ownership rules may continue to evolve as authorization requirements mature. - OpenAPI publication and CLI reference generation are not yet automated. ## 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 - 2026-05-20 KST: bat-005 — REQ-008 AC-2: added Tag Management API section (POST/GET/PATCH /v1/tags, POST/DELETE /v1/assets/{id}/tags, POST/DELETE /v1/portfolios/{id}/tags); updated CLI surface (pfhub tag, removed pfhub portfolio tag); updated CLI inputs. - 2026-05-19 KST: bat-002 — REQ-010/012/013/015: added admin catalog endpoints (POST/PATCH /v1/assets, PATCH /v1/assets/{id}/deactivate); rewrote GET /v1/assets authentication to support service/user/admin token branching; added market filter param; removed market field from AssetItem; added AssetCatalogResponse schema. - 2026-05-13 KST: bat-010 — REQ-006/007/008/011/023: added daemon authorization model section; updated GET /v1/assets (daemon-scoped filter, NULL 403); updated GET /v1/user_info (daemon_id field, NULL runtime_id → 200+null diagnostic); updated POST /v1/assets/{asset_id}/ledger/trades (daemon assignment authorization, fail-closed policy); added daemon CLI subcommand to CLI surface; updated v1 purpose and source discovery. - 2026-05-11 KST: bat-008 — REQ-011 AC-1: expanded v1 Consumer API section with authentication contract, detailed request/response schemas for GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades; updated GET /health response schema (REQ-004); added idempotency contract. - 2026-05-11 KST: bat-006 — Added v1 consumer API interface section (trex-basic); noted asset_id UUID change and simultaneous deployment constraint; updated CLI portfolio inputs to reflect name+user identification. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.