feat: implement daemon model with registry and single-asset assignment (BAT-010, REQ-023/028)

Core Implementation (BAT-010):
- Add daemon registry schema and CRUD operations (req-023)
- Implement single-daemon asset assignment constraint (one asset per daemon)
- Add daemon_id field to service_token for daemon identification
- Add daemon.py CLI module for daemon management commands
- Add 20260513_0016_daemon_model.py migration for daemon tables

API Changes:
- GET /v1/assets: include daemon_id in AssetResponse
- POST /v1/assets/{asset_id}/ledger/trades: require daemon token binding
- GET /v1/user_info: include daemon metadata
- Enhanced service-token rotate to preserve daemon bindings

CLI Commands:
- pfhub daemon register --name <name> --runtime-id <runtime-id>
- pfhub daemon list
- pfhub daemon get <daemon-id>
- pfhub daemon revoke <daemon-id>

Requirements (REQ-023, REQ-028):
- REQ-023: Daemon Registry and Service Token Daemon Binding (Interface, Approved)
  * AC-1: daemon CRUD operations
  * AC-2: service token daemon binding for POST /v1/assets/{asset_id}/ledger/trades
  * AC-3: single daemon per asset (exact-one assignment model)
- REQ-028: README.md Daemon Provisioning Runbook update (Documentation, Approved)

Documentation:
- Add dcy-006 Discovery for daemon-id and single-daemon asset assignment
- Update README.md with Daemon Provisioning Runbook section
- Add BAT-010 batch documentation with planning, implementation, verification
- Update rel-006 release document (daemon model implementation)
- Update data-model.md with daemon and daemon_asset_assignment tables
- Update interface-contract.md with daemon registry APIs
- Update runtime-flows.md with daemon lifecycle flows

Breaking Changes:
- REQ-005~009, REQ-011~012 status reverted to Approved due to revised contract
  * daemon_id field addition and exact-one asset assignment requirement
  * Existing Implemented evidence invalidated by contract revision
  * BAT-001/002/008 re-verification required

Testing:
- Add test_daemon_registry.py for daemon CRUD operations
- Add test_daemon_assignment.py for single-asset assignment constraints
- Update test_auth.py, test_cli.py, test_v1.py for daemon support
- Verify daemon token binding in trade endpoint authorization

Impact:
- Database: 2 new tables (daemons, daemon_asset_assignments)
- Migration: 20260513_0016 creates daemon model schema
- CLI: 4 new daemon subcommands
- API: daemon_id propagation through v1 endpoints
- Verification: BAT-001/002/008 re-verification gates next release
This commit is contained in:
2026-05-13 23:07:24 +00:00
parent f0a6caa178
commit e6b6a8715b
45 changed files with 2688 additions and 167 deletions
+51 -18
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed
- Owner: repository-maintainers
- Last Updated (KST): 2026-05-11 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008
- Last Updated (KST): 2026-05-13 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010
- Primary Runtime: mixed
## Project Summary
@@ -22,7 +22,7 @@
## 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, and service tokens
- pfhub :: run operational CLI commands for health, users, portfolios, strategies, service tokens, and daemon registry
- alembic upgrade head :: apply ordered database schema migrations for persistent environments
## Shared Components
@@ -78,29 +78,33 @@
## Flow: `v1 API — GET /v1/assets`
1. Service-token auth required (see above).
2. `list_portfolios_with_strategies()` fetches only the authenticated service-token owner's portfolios from DB by passing `owner_user_id=auth.user_id`.
3. Optional `?status=<value>` query param filters portfolios by status field.
4. For portfolios with `active_strategy_id`, `get_strategy_set()` fetches config and JSON-parses `strategy_params`; failures fall back to `{}`.
5. Returns `AssetsResponse` with `total` and `assets[]` using new field names: `cash`, `quantity`, `broker_type`, `strategy_params`.
6. Portfolios owned by other users must not be included in the response.
7. `asset_id` in each asset item equals `portfolio.portfolio_id` which is a server-generated UUID after BAT-006 migration (dcy-002 A-3 updated: text identity assumption replaced by UUID).
2. Daemon-id guard: if `auth.daemon_id is None` → HTTP 403 ("daemon_id not assigned to this token"). NULL tokens are fail-closed.
3. `list_portfolios_with_strategies()` fetches only the token owner's portfolios scoped to `managed_by_daemon_id=auth.daemon_id`; `owner_user_id=auth.user_id` prevents cross-user leakage.
4. Optional `?status=<value>` query param filters portfolios by status field; `include_disabled=False` by default.
5. For portfolios with `active_strategy_id`, `get_strategy_set()` fetches config and JSON-parses `strategy_params`; failures fall back to `{}`.
6. Returns `AssetsResponse` with `total` and `assets[]` using field names: `cash`, `quantity`, `broker_type`, `strategy_params`.
7. `asset_id` in each asset item equals `portfolio.portfolio_id` (server-generated UUID after BAT-006 migration).
## Flow: `v1 API — GET /v1/user_info`
1. Service-token auth required. HTTP 404 if `runtime_id` is None.
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`, and `brokers[]`.
5. Returns `UserInfoResponse` with `trading_enabled` (from `is_active`), `disabled_reason`, `brokers[]`, `runtime_id` (null if unbound), and `daemon_id` (null for legacy/unbound tokens — diagnostic, not an error).
## Flow: `v1 API — POST /v1/assets/{asset_id}/ledger/trades`
1. Service-token auth required with `trade-write` permission.
2. Validates asset existence via `repo.get(asset_id)`; HTTP 404 if missing.
3. Calls `insert_ledger_trade()` with idempotency key `(asset_id, order_id, execution_id)`.
4. If new record: returns HTTP 201 with `status="new"`.
5. If duplicate key with identical payload: returns HTTP 200 with `status="duplicate"`.
6. If duplicate key with different payload: returns HTTP 409 with conflict detail.
3. Daemon assignment authorization (fail-closed, three checks in order):
a. `auth.daemon_id is None` → HTTP 403 ("daemon_id not assigned to this token").
b. `portfolio.managed_by_daemon_id is None` → HTTP 403 ("Asset is not assigned to any daemon").
c. `portfolio.managed_by_daemon_id != auth.daemon_id` → HTTP 403 ("Token daemon does not match asset daemon assignment").
4. Calls `insert_ledger_trade()` with idempotency key `(asset_id, order_id, execution_id)`.
5. If new record: returns HTTP 201 with `status="new"`.
6. If duplicate key with identical payload: returns HTTP 200 with `status="duplicate"`.
7. If duplicate key with different payload: returns HTTP 409 with conflict detail.
## Flow: `v1 API — POST /auth/service-tokens`
@@ -109,6 +113,33 @@
3. Calls `AuthService.issue_service_token()` which creates a new DB token with `runtime_id`.
4. Returns HTTP 201 with `token` (raw value), `runtime_id`, and `auth_token` metadata.
## 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/assets/{asset_id}/ledger/trades` with the bound token passes daemon assignment authorization and records the trade.
## 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.
@@ -154,10 +185,11 @@
### 경계 규칙
1. **ensure_schema() 선행 가능 객체**: users, auth_tokens, strategy_sets, portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 관련 인덱스. 모두 IF NOT EXISTS로 보호됨.
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. **허용 실행 순서**: `init-db` 선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시 `migrate-db` 후속 실행.
4. **금지**: `alembic_version` 테이블 수동 삭제 후 migrate-db 재실행 (legacy stamp 오판).
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 오판).
## Current Gaps / Planned Changes
@@ -167,6 +199,7 @@
## Change Log
- 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.