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
+31 -15
View File
@@ -87,6 +87,7 @@ class _RuntimeIdRoundTripRepo:
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
daemon_id: str | None = None,
) -> AuthToken:
token = AuthToken(
id=f"token-{self._seq}",
@@ -98,6 +99,7 @@ class _RuntimeIdRoundTripRepo:
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
runtime_id=runtime_id,
daemon_id=daemon_id,
)
self._seq += 1
self.tokens_by_hash[token_hash] = token
@@ -113,6 +115,7 @@ class _RuntimeIdRoundTripRepo:
permission=token.permission,
token_type=token.token_type,
runtime_id=token.runtime_id,
daemon_id=token.daemon_id,
)
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
@@ -226,6 +229,7 @@ def _make_portfolio(portfolio_id: str = "p1") -> Portfolio:
active_strategy_id=None,
avabile_strategies=None,
owner_user_id="user-1",
managed_by_daemon_id="d-1",
)
@@ -253,7 +257,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
portfolio = _make_portfolio()
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1"
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
@@ -280,6 +284,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="user-1",
include_disabled=False,
managed_by_daemon_id="d-1",
)
@@ -294,7 +299,7 @@ def test_v1_assets_status_filter(monkeypatch) -> None:
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1"
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
@@ -328,7 +333,7 @@ def test_v1_assets_strategy_params(monkeypatch) -> None:
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1"
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
@@ -357,7 +362,7 @@ def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1"
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
@@ -379,23 +384,27 @@ def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
# GET /v1/user_info
# ---------------------------------------------------------------------------
def test_v1_user_info_404_when_no_runtime_id(monkeypatch) -> None:
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id=None, # no runtime_id
)
user = User(id="user-1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = []
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 404
assert "runtime_id" in resp.json()["detail"]
assert resp.status_code == 200
assert resp.json()["runtime_id"] is None
def test_v1_user_info_returns_user(monkeypatch) -> None:
@@ -539,7 +548,7 @@ def _make_ledger_trade(**kwargs) -> LedgerTrade:
def _make_ledger_principal() -> TokenPrincipal:
return TokenPrincipal(
token_id="t1", user_id="user-1", permission="trade-write",
token_type="service", runtime_id="rt-1"
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
@@ -548,7 +557,9 @@ def _make_mock_repo_for_ledger(monkeypatch, trade: LedgerTrade, created: bool, f
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = _make_ledger_principal()
if found:
mock_repo.get.return_value = MagicMock() # portfolio exists
mock_portfolio = MagicMock()
mock_portfolio.managed_by_daemon_id = "d-1"
mock_repo.get.return_value = mock_portfolio
else:
mock_repo.get.side_effect = Exception("not found")
mock_repo.insert_ledger_trade.return_value = (trade, created)
@@ -759,6 +770,7 @@ def _make_portfolio(portfolio_id: str = "p1") -> Portfolio:
active_strategy_id=None,
avabile_strategies=None,
owner_user_id="user-1",
managed_by_daemon_id="d-1",
)
@@ -786,7 +798,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
portfolio = _make_portfolio()
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1"
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
@@ -809,23 +821,27 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
# GET /v1/user_info
# ---------------------------------------------------------------------------
def test_v1_user_info_404_when_no_runtime_id(monkeypatch) -> None:
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id=None, # no runtime_id
)
user = User(id="user-1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = []
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app()
resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 404
assert "runtime_id" in resp.json()["detail"]
assert resp.status_code == 200
assert resp.json()["runtime_id"] is None
def test_v1_user_info_returns_user(monkeypatch) -> None: