feat(portfolios): persist and validate daemon assignment on create/update
POST /v1/portfolios now forwards a non-null managed_by_daemon_id into create persistence, and both create and PATCH apply the same daemon validation contract (UUID format -> 422, missing/foreign -> 404, inactive -> 409, admin target-owner basis). Daemon validation locks the daemon row FOR SHARE inside the portfolio write transaction so validation and write form one atomic decision boundary. Adds domain errors, repository protocol/signature updates, API/PostgreSQL tests (POST->GET persistence, no partial state on failure, assignment vs deactivation race), and contract/data-model documentation.
This commit is contained in:
+3
-1
@@ -166,6 +166,7 @@
|
||||
- tag_ids stores UUID references to active tags; duplicates are prevented by conditional array_append; comments are retrieved in created_at descending order.
|
||||
- `status="disabled"` (soft delete): `managed_by_daemon_id` is set to NULL; portfolio is excluded from `GET /v1/portfolios` by default. Dependent data (`portfolio_comments`, `tag_ids`) is preserved — never deleted on soft delete. READ operations on sub-resources (GET tags, GET comments) remain allowed; write mutations raise HTTP 409.
|
||||
- Status restoration: PATCH with `{"status": "enabled"}` re-activates a disabled portfolio; no other fields may be set in the same request.
|
||||
- Daemon assignment: a non-null `managed_by_daemon_id` on create or PATCH must reference an existing, active daemon owned by the target portfolio owner (admin `user_id` override uses the final target owner). The daemon `FOR SHARE` row lock and the portfolio INSERT/UPDATE share one transaction, so validation and write form a single atomic decision boundary; a concurrently deactivated daemon is rejected (HTTP 409) rather than committed. Deactivation after an assignment commits preserves the portfolio reference and follows the existing token-revocation contract.
|
||||
|
||||
## Entity: `User`
|
||||
|
||||
@@ -255,7 +256,8 @@
|
||||
### State Rules
|
||||
|
||||
- A daemon must exist and be active before a service token can be bound to it at issuance.
|
||||
- Deactivating a daemon (is_active=false) immediately revokes all bound service tokens via `revoke_tokens_by_daemon_id` (REQ-022/BAT-007) and blocks new token issuance.
|
||||
- A daemon must exist, be active, and be owned by the target portfolio owner before a portfolio can be assigned to it (create or PATCH). Assignment validation locks the daemon row `FOR SHARE` inside the portfolio write transaction (Issue #27 / dcy-0004).
|
||||
- Deactivating a daemon (is_active=false) immediately revokes all bound service tokens via `revoke_tokens_by_daemon_id` (REQ-022/BAT-007) and blocks new token issuance. It does not auto-remove existing portfolio references; deactivation racing a new assignment is rejected with HTTP 409 when the deactivation commits first.
|
||||
- daemon_slug is immutable after creation; only display_name and is_active can be updated.
|
||||
|
||||
## Entity: `BrokerCredential`
|
||||
|
||||
@@ -304,7 +304,8 @@
|
||||
- Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_id": "<required strategy ID>", "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}`
|
||||
- Response: HTTP 201 `PortfolioItem {"portfolio_id": "<uuid>", ..., "active_strategy_id": "<str>", "strategy_name": "<str>", "strategy_params": {<dict>}, "strategy_revision": <int>, ...}`
|
||||
- Note: `symbol` is populated from the server-side joined asset row (`portfolios.asset_id -> assets.asset_id -> assets.symbol`), not from client-side catalog reconstruction.
|
||||
- Error: HTTP 403 if service/client token; HTTP 404 if `strategy_id` is unknown; HTTP 409 if the strategy is inactive; HTTP 422 for a missing/invalid `strategy_id` or an unknown/inactive asset
|
||||
- Note: a non-null `managed_by_daemon_id` must be a daemon server-generated UUID that exists, is active, and is owned by the target portfolio owner (admin `user_id` override uses the final target owner, not the calling admin). Daemon validation and the portfolio write share one atomic transaction (`FOR SHARE` daemon lock); a selected daemon that is deactivated concurrently is rejected rather than committed.
|
||||
- Error: HTTP 403 if service/client token; HTTP 404 if `strategy_id` is unknown or the daemon does not exist/is not owned by the portfolio owner; HTTP 409 if the strategy is inactive or the daemon is inactive; HTTP 422 for a missing/invalid `strategy_id`, an unknown/inactive asset, or a non-UUID `managed_by_daemon_id`
|
||||
|
||||
### Endpoint: `PATCH /v1/portfolios/{portfolio_id}`
|
||||
|
||||
@@ -312,7 +313,8 @@
|
||||
- Body: `{"broker": "<str|null>", "status": "<str|null>", "name": "<str|null>", "strategy_id": "<str|null>", "asset_price": <float|null>, "managed_by_daemon_id": "<str|null>"}`
|
||||
- Response: HTTP 200 `PortfolioItem {"portfolio_id": "<uuid>", ..., "active_strategy_id": "<str>", "strategy_name": "<str>", "strategy_params": {<dict>}, "strategy_revision": <int>, ...}`
|
||||
- Note: response `symbol` is emitted from the joined asset row referenced by `asset_id`.
|
||||
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio or strategy is not found; HTTP 409 if the portfolio is disabled for the mutation or the selected strategy is inactive; HTTP 422 for an invalid request body
|
||||
- Note: a non-null `managed_by_daemon_id` assignment/reassignment follows the same daemon validation contract as create: it must be a UUID that exists, is active, and is owned by the portfolio owner. The daemon `FOR SHARE` lock and the portfolio UPDATE share one atomic transaction, so a concurrently deactivated daemon is rejected with HTTP 409.
|
||||
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio, strategy, or daemon is not found (foreign daemon is hidden as 404); HTTP 409 if the portfolio is disabled for the mutation, the selected strategy is inactive, or the daemon is inactive; HTTP 422 for an invalid request body or a non-UUID `managed_by_daemon_id`
|
||||
|
||||
## Interface: `v1 Strategy Catalog API`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user