Skip to content

feat: managed-custody worker lifecycle in WorkerManager (ENGN-8646) - #35

Draft
TaniBuilds wants to merge 15 commits into
tani/engn-8646-managed-custodyfrom
tani/engn-8646-managed-orchestrator
Draft

feat: managed-custody worker lifecycle in WorkerManager (ENGN-8646)#35
TaniBuilds wants to merge 15 commits into
tani/engn-8646-managed-custodyfrom
tani/engn-8646-managed-orchestrator

Conversation

@TaniBuilds

@TaniBuilds TaniBuilds commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Builds the orchestrator-level managed-custody flow so the dev kit can run Privy-managed workers end-to-end — the deferred Phase-3 piece of ENGN-8646. Previously WorkerManager was local-custody only (generated a mnemonic, persisted a key file, keyed everything by a local address); the --custody managed switch only existed at the single-worker worker_runtime layer.

Stacked on #34 (the --custody managed switch + local SDK pin) and depends on allora-sdk-py #81 (provision_wallet + the new clear_association).

  • deploy_worker(custody="managed") provisions an idempotent get-or-create managed wallet bound to the topic via the Forge backend and registers the worker against the backend-assigned address — no local key. One wallet per topic (ENGN-8572); a re-deploy refreshes the artifact on the same wallet rather than allocating a new one.
  • start_worker launches managed workers with --custody managed and injects FORGE_API_KEY/FORGE_BACKEND_URL into the subprocess env (no --mnemonic-file). Extracted into a testable _build_run_command(...).
  • remove_worker releases the (user, topic) binding via clear-association on decommission — best-effort: the worker is removed locally regardless, and the backend get-or-create is idempotent so a stale binding is simply reused next deploy.
  • workers table gains custody + signing_wallet_id (with the existing lightweight ALTER TABLE migration pattern); status_worker surfaces them. The Forge client is built lazily from FORGE_* env and is injectable for tests.

The managed flow reuses allora_sdk's ForgeBackendClient rather than re-implementing the (security-hardened) HTTP plumbing.

Local testing

Inherits #34's [tool.uv.sources] local SDK pin, so it runs against the local branch build now. Live run needs a forge-v2 backend (Privy auth key + owner) and FORGE_API_KEY/FORGE_BACKEND_URL.

Test plan

  • tests/test_worker_manager.py — 15 pass (8 existing + 7 new): provision/register, idempotent redeploy, managed command/env construction, clear-on-remove, clear-failure tolerance, local-not-cleared, missing-credentials error
  • allora-sdk-py — 17 pass (added test_clear_association)
  • Imports verified against the local SDK build
  • Live: deploy + start + remove a managed worker against a running backend and confirm provision → register → submit, and that remove clears the association

Summary by cubic

Add managed custody to WorkerManager so the orchestrator can run Privy-managed workers end-to-end without local keys. Implements ENGN-8646 by provisioning one backend-managed wallet per (user, topic) and reusing it on redeploy.

  • New Features

    • deploy_worker(custody="managed") provisions an idempotent wallet via the Forge backend and registers the worker with the backend-assigned address; one wallet per topic; redeploy updates the artifact on the same wallet and syncs reject_zero/signing_wallet_id. Rejects local-only inputs (address/mnemonic/identity_alias) when custody="managed".
    • start_worker adds --custody managed, injects FORGE_API_KEY/FORGE_BACKEND_URL/FORGE_SIGNING_WALLET_ID, and prechecks credentials and wallet id; command building extracted to _build_run_command(...).
    • remove_worker calls backend clear-association to release the (user, topic) binding; best-effort (local removal always proceeds).
    • workers table adds custody and signing_wallet_id; status_worker and status_all return them.
    • Safety and robustness: validate backend wallet fields, friendly error if allora-sdk is missing, build the Forge client under the manager lock, type the client via a Protocol, use identity_ref="managed" for managed rows, and report action="replaced" on managed redeploys.
  • Migration

    • Set FORGE_API_KEY and FORGE_BACKEND_URL to use managed custody; otherwise deploy_worker(..., custody="managed") fails fast.
    • No manual DB migration needed; columns are added on startup.

Written for commit 9aa5226. Summary will update on new commits.

Review in cubic

WorkerManager can now run Privy-managed workers end-to-end, not just local
self-custody:

- deploy_worker(custody="managed") provisions (idempotent get-or-create) a
  managed wallet bound to the topic via the Forge backend and registers the
  worker against the backend-assigned address — no local mnemonic/key file.
  One wallet per topic; a re-deploy refreshes the artifact on the same wallet.
- start_worker launches managed workers with --custody managed and injects
  FORGE_API_KEY/FORGE_BACKEND_URL into the subprocess env (no --mnemonic-file).
- remove_worker releases the (user, topic) binding via clear-association on
  decommission (best-effort: local removal always proceeds).
- workers table gains custody + signing_wallet_id (lightweight migration);
  Forge client built lazily from FORGE_* env (injectable for tests).

Reuses allora_sdk's ForgeBackendClient (provision_wallet + new
clear_association). Tests cover provision/register, idempotent redeploy,
managed command/env construction, clear-on-remove (incl. failure tolerance),
local-not-cleared, and the missing-credentials error.
@TaniBuilds

TaniBuilds commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

🔍 Multi-Agent Multi-Repo Code Review 🧑‍💻

20 findings across 9 dimensions — 🟠 1 high · 🟡 8 medium · 🔵 11 low

🧵 Synthesized by anthropic/claude-sonnet-4.6 — 29 raw findings → 20 clusters (4 dismissed)

Dimensions: adversarial architecture correctness cross-repo deep-analysis performance runtime-semantics security style · Models: anthropic/claude-sonnet-4.6


🚨 Must Fix

  • 🟠 Managed custody is unreachable from shipped operator entry points (workerctl CLI and web_dashboard) (allora_forge_builder_kit/workerctl.py:11)

    The new custody='managed' code path lives entirely on WorkerManager.deploy_worker/_deploy_managed_worker, but the two shipped operator surfaces in this repo never wire the dependencies it requires:

  1. workerctl._make_manager() (workerctl.py:23-29) constructs WorkerManager with only db_path/secrets_path/network/monitor/reconcile_on_start — it does NOT forward forge_api_key, forge_backend_url, or forge_client. The CLI has no 'deploy' subcommand at all.

  2. Even if a managed worker row exists in the DB (e.g. created via direct Python scripting), calling workerctl reconcile or workerctl start-all hits _build_run_command's precheck (worker_manager.py:561-565), which raises ValueError because _make_manager forwarded no forge credentials. reconcile() catches this and logs it for every managed row on every cycle, but the row stays enabled — creating an eternal log-spam loop with no operator-actionable signal.

Consequence: managed custody is a domain-layer feature with no presentation/wiring layer update. An operator cannot deploy, start, or reconcile managed workers using any shipped tool — only by writing Python directly against WorkerManager.

💡 Add forge_api_key/forge_backend_url to _make_manager's WorkerManager construction (env-derived via os.environ fallback, no new CLI flags required). Add a 'deploy' subcommand accepting --topic, --artifact, --custody {local,managed} so managed deploys are reachable from the shipped CLI. Note: arch-003 (eternal reconcile loop) is a symptom of the same gap and should be fixed concurrently.

Correctness (5 findings)

🟡 synth-p1-002 (medium) — pyproject.toml:25
allora-sdk>=1.0.6 lower bound admits SDK versions without provision_wallet/clear_association — pip-installed users hit AttributeError, not the friendly ImportError path

🤝 flagged by 2 agents: correctness, adversarial

pyproject.toml declares allora-sdk>=1.0.6 (line 25) and notes that the actual ENGN-8646 SDK changes ship in an unreleased branch consumed via [tool.uv.sources] (lines 47-53, explicitly commented 'LOCAL-DEV PIN'). For any consumer who installs the kit with plain pip install allora-forge-builder-kit (without uv's local-path override), the resolver picks the published 1.0.6, which lacks ForgeBackendClient.provision_wallet and ForgeBackendClient.clear_association.

The lazy loader in _forge_client() (worker_manager.py:184-191) catches ImportError only. With the older SDK, the import succeeds (the class exists) but client.provision_wallet(topic_id, label=label) at line 486 raises AttributeError, which is opaque to operators. Worse: in remove_worker, the missing clear_association is caught by the bare-except at line 273 and only logged as a warning, so a pip-installed user calling remove_worker on a managed worker silently leaks the backend wallet binding with no way to retry.

Additionally, the [tool.uv.sources] block in pyproject.toml ships in built wheel metadata — pip ignores it, but the comment says it should be removed before the SDK release ships.

💡 Either (a) gate this PR on a published allora-sdk release containing provision_wallet/clear_association and bump the lower bound accordingly, dropping the [tool.uv.sources] LOCAL-DEV PIN block from the publishable pyproject.toml; or (b) add a runtime hasattr guard inside _forge_client() after the ForgeBackendClient import that checks for both methods and raises a clear ValueError naming the minimum SDK version. Option (b) is a minimal fix until the version bump lands.

🔗 Cross-repo: affects allora-sdk-py

🔵 synth-p1-010 (low) — allora_forge_builder_kit/worker_manager.py:601
start_worker leaks the log file handle when subprocess.Popen raises

start_worker (worker_manager.py:601-610) opens log_f = open(log_path, 'ab') then wraps _build_run_command in a try/except that closes log_f on failure. However, subprocess.Popen(...) on line 608 is OUTSIDE that try/except. If Popen raises (OSError when sys.executable is unreadable, FileNotFoundError on a transient FS issue, PermissionError on cwd), the open file descriptor is never released. The DB row stays at status='stopped' with a leaked FD per attempt; reconcile loops compound the leak.

This is pre-existing behavior that the refactor (extracting _build_run_command) preserved verbatim — the new try/except around _build_run_command shows the contributor was thinking about cleanup but missed widening it to cover Popen.

💡 Widen the try block (or use try/finally) to also close log_f when Popen raises. On success ownership transfers to the runner dict; on failure, log_f.close() must run.

🔵 synth-p1-014 (low) — allora_forge_builder_kit/worker_manager.py:486
One-wallet-per-topic invariant enforced by backend trust only — a different backend address for the same topic creates an orphaned managed row

_deploy_managed_worker probes _worker_exists(topic_id, address) using the returned info.address. The UNIQUE constraint on workers is (topic_id, address), NOT (topic_id, custody='managed'). If the backend ever returns a different address for the same topic_id (wallet cleared/regenerated server-side, backend incident, dev environment wipe), _worker_exists(topic_id, new_address) returns False, add_worker cheerfully INSERTs a second managed row, and the existing row for the old address is left orphaned. status_all() then reports two managed workers for the same topic, and reconcile will try to spawn both.

💡 Before INSERTing a fresh managed row, look up any existing managed worker for topic_id (any address). If one exists with a different address than info.address, either (a) refuse with a clear error pointing at remove_worker(topic_id, old_address), or (b) atomically clean up the orphaned row and call clear_association(old_signing_wallet_id) first.

🔵 synth-p1-015 (low) — allora_forge_builder_kit/worker_manager.py:487
Provisioned wallet address is stored without bech32-prefix validation against self._network — network mismatch is only caught inside the subprocess

_deploy_managed_worker validates that info.address is non-empty (lines 487-490) but does NOT verify the address prefix matches WorkerManager's configured network. A manager with network='mainnet' but FORGE_BACKEND_URL pointed at a testnet backend results in a worker row with a mismatched address. The SDK's RemoteWallet init cross-checks the address against the pubkey-derived address WITH the configured prefix — but that check fires in the worker subprocess after the DB row is already inserted and the subprocess has been spawned. The result is a row that fails to start and a permanent reconcile loop (per synth-p1-006).

💡 After the emptiness check, also assert that info.address starts with the expected bech32 prefix for self._network (e.g. 'allo1'). Raise RuntimeError with a clear message naming the expected vs returned prefix. This catches a misconfigured backend before any DB write.

🔵 synth-p1-019 (low) — allora_forge_builder_kit/worker_manager.py:557
FEE_GRANTER is SDK-recommended for managed (Privy) signing but never plumbed by WorkerManager — operators get opaque tx-fee failures

The sibling allora-sdk-py's AlloraWalletConfig docstring explicitly states fee_granter 'is the recommended pairing for Privy-delegated (RemoteWallet) signing' — because a freshly-provisioned Privy wallet has zero ALLO and cannot pay gas fees without a granter. _build_run_command injects FORGE_API_KEY, FORGE_BACKEND_URL, FORGE_SIGNING_WALLET_ID — but not FEE_GRANTER. It falls through os.environ.copy(), so the SDK only sees a granter if the parent process happened to export it. There is no fee_granter parameter on WorkerManager.init and no precheck warns when managed custody is configured without a granter.

Failure mode: managed worker subprocess starts, RemoteWallet builds successfully, the first submission fails with an insufficient-fee error from the chain. reconcile() re-attempts forever (no terminal misconfigured state per synth-p1-006).

💡 Either (a) accept fee_granter on WorkerManager.init (defaulting to $FEE_GRANTER) and explicitly inject it into the managed env; or (b) emit logger.warning at deploy_worker(custody='managed') time when FEE_GRANTER is absent from the environment, naming the SDK recommendation. Add an equivalent precheck in build_run_command for parity with the existing FORGE* credential checks.

🔗 Cross-repo: affects allora-sdk-py

🔒 Security (4 findings)

🟡 synth-p1-003 (medium) — allora_forge_builder_kit/worker_manager.py:482
provision_wallet called before any local write — backend wallet binding is orphaned if artifact copy or DB INSERT fails

_deploy_managed_worker (worker_manager.py:482-534) executes:

  1. client.provision_wallet(topic_id, label=...) — backend now holds a (user, topic) → wallet binding
  2. _worker_exists check
  3. _update_worker or add_worker (which calls _materialize_artifact via shutil.copy2 + sqlite3 INSERT)

Step 3 can fail for many reasons: ENOSPC on shutil.copy2, sqlite3.OperationalError (database locked, disk full), sqlite3.IntegrityError from a concurrent deploy race, or SIGTERM/KeyboardInterrupt. When this happens, the backend binding survives (provision_wallet is idempotent at (user, topic) level, so a retry recovers the same wallet) BUT the operator only recovers the binding by re-running deploy_worker. If they instead run remove_worker to clean up, _get_custody returns ('local', None) (no row exists) and no clear_association is ever issued. The backend binding leaks permanently — the provisioned wallet_id was never persisted locally.

💡 Wrap the provision_wallet → add_worker sequence in a try/except: on any exception after a successful provision_wallet, log the provisioned wallet_id+address at WARN level (so an operator can grep logs) and best-effort call clear_association(info.id) to release the backend binding. Alternatively, stage a tombstone row (signing_wallet_id=NULL, custody='managed_provisioning') before the provision_wallet call, update it on success, and let reconcile() detect and clean up stale tombstones on restart.

🔗 Cross-repo: affects allora-sdk-py, forge-v2

🟡 synth-p1-004 (medium) — allora_forge_builder_kit/worker_manager.py:257
remove_worker deletes the local worker row BEFORE calling clear_association — a crash in this window orphans a live delegated-signing binding with no local retry record

remove_worker (worker_manager.py:257-279) executes:

  1. _get_custody reads signing_wallet_id into a local variable
  2. _archive_active_deployment
  3. DELETE FROM workers ... COMMIT
  4. clear_association(signing_wallet_id) ← only attempted after the row is gone

If the process is killed (SIGKILL, OOM, host reboot) between step 3 and step 4, the Forge backend retains an active (user, topic) → wallet_id binding that authorizes delegated Privy signing, while the local registry shows no worker and no pending-release record. There is no way to enumerate orphaned bindings from the kit; the backend binding leaks permanently.

This is distinct from the prior synth-004 concern (exception swallowing): even if clear_association succeeds, the window between DELETE and clear_association is non-retryable because signing_wallet_id is never persisted to a durable cleanup queue — it only exists as a local variable at that point.

Note: The author left synth-004 open as a 'product decision'; this finding adds the stronger ordering argument as a new angle.

💡 Reverse the ordering for managed custody: call clear_association FIRST (while the row still exists), then DELETE only after the backend acknowledges release. If clear_association fails, either leave the row in a 'pending_release' status (so reconcile can retry) or log the signing_wallet_id to a durable cleanup log. At minimum this reduces the SIGKILL window from 'before every failure' to only the clear_association network call duration.

🔗 Cross-repo: affects allora-sdk-py, forge-v2

🟡 synth-p1-005 (medium) — allora_forge_builder_kit/worker_manager.py:493
_update_worker silently overwrites signing_wallet_id on redeploy without releasing the prior wallet

In _deploy_managed_worker's redeploy path (lines 498-500), _update_worker is called with the freshly-provisioned info.id. _update_worker (worker_manager.py:996-1015) writes signing_wallet_id=COALESCE(?, signing_wallet_id) — when a non-NULL id is supplied, it overwrites the prior id with no diff check.

If the backend ever returns the same address but a different wallet_id for the same topic (wallet cleared/regenerated server-side, backend migration, DR event), _update_worker silently replaces the stored id. The previous wallet's backend binding is now invisible to the kit — clear_association can never be called on it because the id is gone, and no warning is logged. This is a reliance on a backend invariant (wallet_id stable per (user, topic)) that is not locally enforced.

While this backend invariant likely holds today, a future backend migration or incident could violate it silently, and the kit has no observability hook to detect the rotation.

💡 Before overwriting via _update_worker, read the current signing_wallet_id from the DB. If it is non-NULL and != info.id, log a WARN ('backend rotated wallet for topic {topic_id}: old={old_id} new={info.id}') and best-effort call clear_association(old_id) so the prior binding does not leak. Alternatively, refuse the redeploy and surface a clear error directing the operator to remove_worker + redeploy.

🔗 Cross-repo: affects allora-sdk-py, forge-v2

🔵 synth-p1-018 (low) — allora_forge_builder_kit/worker_manager.py:573
_build_run_command copies the full parent process environment into managed worker subprocesses — principle-of-least-privilege violation

🤝 flagged by 2 agents: adversarial, performance

For managed workers, _build_run_command calls env = os.environ.copy() (line 573) on every invocation. This propagates EVERY environment variable the parent process carries — AWS_ACCESS_KEY_ID, DATABASE_URL, GITHUB_TOKEN, FORGE_OPERATOR_PRIVATE_KEY, etc. — into the managed subprocess, regardless of whether worker_runtime needs them. The blast radius is bounded (same codebase) but it is a least-privilege violation: a managed worker subprocess legitimately needs FORGE_API_KEY, FORGE_BACKEND_URL, FORGE_SIGNING_WALLET_ID, ALLORA_API_KEY, FEE_GRANTER, and standard infra vars (PATH, HOME).

Additionally, copying os.environ N times during start_all/reconcile for N managed workers is avoidable per-startup allocation (though low severity given typical N).

💡 Build a minimal env dict instead of os.environ.copy(): start from an allowlist of std vars + ALLORA_API_KEY + FEE_GRANTER, then add the FORGE_* trio. This makes the credential surface auditable and reduces accidental secret propagation.

🏗️ Architecture (4 findings)

🟡 synth-p1-006 (medium) — allora_forge_builder_kit/worker_manager.py:685
reconcile() catches _build_run_command ValueError but leaves enabled=1 — misconfigured managed workers spin in an eternal log-spam loop

_build_run_command prechecks managed credentials and raises ValueError when FORGE_API_KEY/FORGE_BACKEND_URL are missing (lines 561-565) or signing_wallet_id is absent (lines 567-571). start_worker propagates this; reconcile() (lines 700-713) catches it, logs it, and appends to failed — but the worker row's enabled flag stays 1 and status stays 'stopped'. On the next reconcile cycle, the same enabled/stopped row re-triggers need_start and the ValueError fires again.

The lifecycle state machine has no terminal 'misconfigured' state for permanent config errors: 'stopped' means 'should be started', so any permanent configuration defect becomes an eternal log-spam loop with no operator-actionable signal. A managed worker can reach this state via: (a) DB row exists but FORGE_API_KEY was rotated/unset; (b) signing_wallet_id is NULL; (c) workerctl reconcile run without forge credentials (the workerctl gap in synth-p1-001).

💡 Extend the status or add an error-count field so reconcile() can transition a permanently-failing row to a terminal 'misconfigured' state (or set enabled=0 + write last_error) after N consecutive failures for the same row. At minimum, distinguish permanent-config ValueError from transient subprocess failures and skip rows in the terminal state rather than re-attempting every cycle. Surface the terminal-state rows in the returned failed payload as a one-time actionable signal.

🔵 synth-p1-011 (low) — allora_forge_builder_kit/worker_manager.py:469
_deploy_managed_worker TOCTOU race against UNIQUE(topic_id, address) — confusing error for the losing concurrent deployer

🤝 flagged by 2 agents: architecture, adversarial

_deploy_managed_worker runs provision_wallet → _worker_exists → add_worker without holding self._lock, racing the UNIQUE(topic_id, address) constraint. Two concurrent managed deploys for the same topic_id both call provision_wallet (idempotent, so both get the same address), both observe _worker_exists == False, then both call add_worker. The second INSERT hits sqlite3.IntegrityError which add_worker translates into ValueError('Worker already exists for topic=..., address=...'). One deployer wins; the other gets a confusing 'already exists' error for a worker they believed they were creating fresh, even though replace semantics were intended.

Adversarial note: the UNIQUE constraint is an adequate correctness floor — no data is corrupted, only a UX wart. Severity is LOW. Any lock-wrap fix should also cover the local explicit-address branch (lines 412-454) which has the same pattern and is the original source of the missing-lock discipline.

🎭 adversarial note: adv-pr35-6 challenges arch-002's medium severity: the UNIQUE(topic_id, address) constraint is the safety net — worst case is a confusing-but-clear ValueError to the losing thread, not data corruption. Severity demoted from medium to low. Any fix should also extend to the local explicit-address branch (lines 412-454) which has the same missing-lock pattern.

💡 Wrap the provision_wallet → _worker_exists → add_worker/_update_worker sequence in with self._lock: (the RLock is reentrant so nested calls into _forge_client remain safe). Also apply to the equivalent explicit-address branch in deploy_worker for consistency.

🔵 synth-p1-012 (low) — allora_forge_builder_kit/worker_runtime.py:68
worker_runtime.py comment describes the SDK's deferred-provisioning path but misrepresents how the kit actually uses it

🤝 flagged by 2 agents: architecture, adversarial

worker_runtime.py lines 68-73 reads: 'with no FORGE_SIGNING_WALLET_ID, defers wallet provisioning to the worker — which get-or-creates a managed wallet bound to this topic at startup (ENGN-8646)'. In the kit's actual flow, _build_run_command ALWAYS injects FORGE_SIGNING_WALLET_ID (worker_manager.py:578), so the deferred path is dead code from the kit's perspective. The comment is correct about the SDK's broader capability (the SDK DOES support deferred provisioning when FORGE_API_KEY is set without FORGE_SIGNING_WALLET_ID via init_worker_wallet), but misrepresents which side owns provisioning in the kit — misleading an engineer into thinking the subprocess provisions the wallet, potentially causing them to remove the FORGE_SIGNING_WALLET_ID injection as 'redundant'.

🎭 adversarial note: adv-pr35-7 partially challenges arch-004: the SDK deferred-provisioning capability the comment describes IS real (from_env + init_worker_wallet). The suggested rewrite should preserve that info rather than dropping it entirely. The revised suggested_code above incorporates this nuance.

💡 Rewrite to document both cases: (a) the kit's path: WorkerManager pre-provisions the topic-bound wallet and pins FORGE_SIGNING_WALLET_ID; (b) the SDK's deferred path (available when only FORGE_API_KEY is set, used by callers that bypass WorkerManager). This preserves documentation of the SDK's broader contract while clarifying which path the kit takes.

🔵 synth-p1-013 (low) — allora_forge_builder_kit/worker_manager.py:105
Injected forge_client is never verified at construction — wrong-shape client fails late with AttributeError swallowed by bare-except in remove_worker

ForgeClientProtocol is structural (duck-typed) and used only as a type annotation. An operator-supplied forge_client missing one method passes init without error and only raises AttributeError on the first managed deploy (or in remove_worker's bare-except, silently). The sibling real-SDK contract test catches a missing method on the real class but does NOT cover an arbitrary operator-injected client (e.g. a custom wrapper that only implements one of the two methods). This is the gap the bryn-architecture style calls out: compile-time contract checks equivalent for Python means explicit runtime assertion at the injection boundary.

💡 In init, when forge_client is not None, verify it at the boundary: if not (callable(getattr(forge_client, 'provision_wallet', None)) and callable(getattr(forge_client, 'clear_association', None))): raise TypeError('forge_client must expose provision_wallet(topic_id, label) and clear_association(wallet_id)'). This makes a wrong-shape injected client fail at construction (fast, clear message) rather than at first managed deploy.

Performance (1 findings)

🔵 synth-p1-017 (low) — allora_forge_builder_kit/worker_manager.py:482
_deploy_managed_worker unconditionally calls provision_wallet before checking the local DB — redundant network round-trip on every redeploy

🤝 flagged by 2 agents: performance, security

_deploy_managed_worker unconditionally calls client.provision_wallet(topic_id, label=...) and only AFTERWARDS checks _worker_exists(topic_id, address). On the common redeploy path (same topic, refreshed artifact), a local DB row already exists with a stored signing_wallet_id and address. The backend call is idempotent but every managed redeploy pays a network round-trip to the Forge backend carrying FORGE_API_KEY even though the wallet binding is already stored locally.

Additionally, deploy_worker does not hold self._lock during this sequence, so two concurrent deploys for the same topic both call provision_wallet in parallel, doubling the API-key-bearing requests. The provision_wallet call is the only place the Forge API key leaves the process over the network, so minimizing its frequency reduces attack surface.

💡 Read the existing worker row for (topic_id) first; if a managed row with a non-null signing_wallet_id exists and replace=False, reuse the stored address/wallet_id and skip the provision_wallet call, calling the backend only when the row is missing or signing_wallet_id is null. If re-provisioning is required for correctness on every deploy, document it as an explicit contract and add a force_reprovision flag.

🔗 Cross-repo: affects allora-sdk-py, forge-v2

🎨 Style (4 findings)

🟡 synth-p1-007 (medium) — allora_forge_builder_kit/worker_manager.py:186
ImportError is re-raised as ValueError in _forge_client() — misleads exception handlers and violates Python exception-type semantics

In _forge_client() (worker_manager.py:184-191), a missing optional dependency (allora_sdk) is caught as ImportError and re-raised as ValueError. This is semantically incorrect: ValueError conventionally signals that an argument has an invalid value, not that an optional package is missing. Callers writing except ImportError to detect missing-dependency situations will silently miss this exception; callers writing except ValueError for bad-argument handling will unexpectedly catch an import failure. The Allora Python style guide favours domain-specific exceptions; at minimum, re-raise as ImportError (preserving the chained cause) so the exception type communicates the root cause.

💡 Re-raise as ImportError instead of ValueError: raise ImportError("managed custody requires the 'allora-sdk' package …") from e. This preserves standard Python semantics for missing optional dependencies and allows callers to distinguish config errors from import errors.

🟡 synth-p1-008 (medium) — allora_forge_builder_kit/worker_manager.py:281
status_worker and status_all use diverging magic column indices — reject_zero is at row[16] in one and row[14] in the other

The PR adds three new magic column accesses to status_worker (row[14]=custody, row[15]=signing_wallet_id, row[16]=reject_zero) and two to status_all (row[15]=custody, row[16]=signing_wallet_id). The reject_zero column lands at index 16 in status_worker but at index 14 in status_all because the two SELECT lists use different column orderings — a direct consequence of building the mapping by hand against positional indices.

The Allora Python style guide explicitly prefers structured types (dataclasses / TypedDict) over raw dicts with magic indices. A future column addition to either SELECT that misaligns the indices would be a silent wrong-value bug (no test failure, no type error). Using sqlite3.Row (name-based column access) or a WorkerStatus TypedDict would eliminate this fragility entirely.

💡 Set conn.row_factory = sqlite3.Row on the connection in both status_worker and status_all, then access columns by name (row['custody'], row['signing_wallet_id'], etc.) instead of index. Alternatively, define a WorkerStatus TypedDict and populate it from named fields to give callers static checks and IDE completion.

🔵 synth-p1-016 (low) — allora_forge_builder_kit/worker_manager.py:56
DeployResult.action typed as bare str with inline comment — should use Literal["created", "reused", "replaced"] for consistency with CustodyMode

The PR introduces CustodyMode = Literal["local", "managed"] specifically to give type-checker coverage on a fixed two-value discriminant. However, DeployResult.action (line 56) still uses bare str with a trailing comment # created|reused|replaced. This is exactly the pattern CustodyMode was added to replace. Defining action: Literal["created", "reused", "replaced"] would give callers static guarantees, allow exhaustiveness checks, and remove the comment. The inconsistency is especially visible because both are introduced/touched in the same PR.

💡 Change action: str # created|reused|replaced to action: Literal["created", "reused", "replaced"] (Literal is already imported at line 17).

🔵 synth-p1-020 (low) — tests/test_worker_manager.py:19
_FakeForgeClient.provision_wallet missing return type annotation in tests

The provision_wallet method in _FakeForgeClient (test file, line ~19) has parameter type hints but no return type annotation. The Allora Python style guide requires type hints on all functions. The return value is SimpleNamespace at runtime but should structurally satisfy ProvisionedWallet. Adding -> ProvisionedWallet (imported from worker_manager) would document the contract and let Pyright verify the test stub satisfies the Protocol.

💡 Add -> ProvisionedWallet return type and import ProvisionedWallet alongside ForgeClientProtocol in the test imports: def provision_wallet(self, topic_id: int, label: str | None = None) -> ProvisionedWallet:

🔗 Cross Repo (1 findings)

🟡 synth-p1-009 (medium) — allora_forge_builder_kit/worker_manager.py:178
FORGE_BACKEND_URL strictly required by WorkerManager but defaults to https://forge.allora.network in the SDK — config contract asymmetry

WorkerManager enforces a stricter contract for the Forge backend URL than the SDK it depends on. _forge_client() (worker_manager.py:178-182) raises ValueError unless both FORGE_API_KEY AND FORGE_BACKEND_URL are explicitly set. The sibling allora-sdk-py (tani/engn-8646-auto-provision-managed-wallets) defaults FORGE_BACKEND_URL to https://forge.allora.network inside AlloraWalletConfig.from_env() and ForgeBackendClient.

Consequence: an operator who sets only FORGE_API_KEY (relying on the SDK default — a documented, supported 12-factor pattern) will find the SDK's worker_runtime.py path works fine BUT cannot deploy a managed worker through the kit — _deploy_managed_worker calls _forge_client() which raises ValueError because FORGE_BACKEND_URL is unset. The management/provisioning side and the runtime/signing side disagree on whether the URL has a default.

💡 Drop the explicit FORGE_BACKEND_URL requirement from WorkerManager and pass self._forge_backend_url (which may be None) into ForgeBackendClient, letting the SDK apply its own https://forge.allora.network default. Update the ValueError to require only FORGE_API_KEY. If you prefer to keep the local guard, document in the README that FORGE_BACKEND_URL is required here even though the SDK does not require it.

🔗 Cross-repo: affects allora-sdk-py, allora-forge-builder-kit

📝 Agent Summaries

🎭 adversarial: Phase B (adversarial red-team): Phase A covered the major correctness/security gaps well, but missed three durability concerns symmetric across the managed-custody lifecycle: (1) provision-then-write ordering on deploy leaks backend bindings if the write fails (medium, symmetric to sec-001 on remove); (2) silent overwrite of signing_wallet_id by _update_worker leaks the prior backend binding if the backend ever rotates the per-topic wallet (medium); (3) missing bech32-prefix validation on the provisioned address before INSERT (low). Two operational/security gaps: (4) os.environ.copy() leaks the full parent environment into the managed subprocess (low, least-privilege), and (5) the SDK-recommended FEE_GRANTER pairing for managed signing is never plumbed by WorkerManager, leaving operators to discover this via cryptic chain errors (low). Challenges to Phase A: arch-002 is real but mitigated by UNIQUE(topic_id, address), suggesting a severity demotion from medium to low, and the suggested fix should be extended to the local explicit-address branch for consistency. arch-004 is partially valid — the worker_runtime.py comment is misleading IN CONTEXT but the SDK's deferred-provisioning capability is real, so the rewrite should preserve that. correctness-pr35-1 is confirmed with an additional observation that pyproject.toml's [tool.uv.sources] block ships in built wheels (pip ignores it), making the pip-user UX gap a real blocker.

🏗️ architecture: The managed-custody addition is well-isolated behind a ForgeClientProtocol and the custody-discriminant column design is sound, but the feature is wired only into the WorkerManager core: neither the workerctl CLI nor the web_dashboard (the only shipped operator entry points) construct a WorkerManager with forge credentials or expose a managed deploy command, so the path is unreachable in any shipped configuration. Two lifecycle gaps remain: _deploy_managed_worker mutates state without the manager RLock (a TOCTOU race against the UNIQUE(topic_id,address) constraint), and reconcile() catches the new ValueError from _build_run_command but leaves enabled=1, so a misconfigured managed worker spins in an eternal log-spam loop. The worker_runtime.py comment also mis-describes the contract by claiming the subprocess provisions the wallet when in fact WorkerManager provisions it pre-spawn.

✅ correctness: Most previously-flagged correctness issues have been addressed in subsequent commits (status_all parity, managed local-only-input rejection, managed redeploy reject_zero/wallet sync, replaced-vs-reused action, ForgeClientProtocol, identity_ref sentinel, lock-guarded lazy SDK build, friendly ImportError fallback, malformed-wallet rejection, credentials precheck in _build_run_command, get-or-create topic label, and typed test helper). Verified the sibling allora-sdk-py branch tani/engn-8646-auto-provision-managed-wallets does carry the provision_wallet/clear_association methods the kit calls, refuting the prior synth-001/002 wrong-branch claims.

Three new concerns:

  1. (medium) pyproject.toml's allora-sdk>=1.0.6 constraint admits SDK versions without the new methods, and the lazy loader only catches ImportError; a pip-installed user attempting managed custody hits an opaque AttributeError rather than the kit's friendly version-mismatch message.
  2. (low) _deploy_managed_worker relies on backend idempotency to enforce one managed worker per topic; the local schema UNIQUE constraint is (topic_id, address), so a backend-side wallet reissue silently creates a second managed row instead of warning.
  3. (low) start_worker's refactored cleanup path closes log_f if _build_run_command raises but leaks the FD if subprocess.Popen itself raises; widening the try block to cover Popen would close the gap.

🔗 cross-repo: This PR (ENGN-8646) adds Privy managed-custody worker lifecycle to the builder-kit, depending on allora-sdk-py's ForgeBackendClient.provision_wallet/clear_association and a deferred-managed AlloraWalletConfig.from_env(). The previously-flagged CRITICAL cross-repo findings (missing SDK methods, missing FORGE_SIGNING_WALLET_ID injection) are FALSE POSITIVES — they were raised against the wrong sibling branch (tani/ENGN-8615-sdk-privy-delegated-signing). The real dependency is allora-sdk-py #81 branch tani/engn-8646-auto-provision-managed-wallets, which the author verified implements both methods plus the deferred config, and the forge-v2 auto-provision branch adds the POST /api/v1/signing-wallets (GetOrCreateForTopic) and clear-association endpoints. The author also added real-SDK contract tests that lock the contract going forward. The one genuinely novel cross-repo gap I found that prior comments missed: FORGE_BACKEND_URL has a default (https://forge.allora.network) on the SDK side but is hard-required by the kit's WorkerManager, so an operator following the SDK README's API-key-only pattern cannot deploy a managed worker through the kit. Everything else is either covered and resolved, or a known prospective tracking item (go/ts managed-custody parity). No blocker.

🔬 deep-analysis: Managed-custody lifecycle changes are cohesive with migration handling and test coverage; no additional deep cross-file issues found beyond prior comments.

⚡ performance: The managed-custody additions are not algorithmically pathological (no O(n^2), no unbounded fan-out, no lock contention beyond the already-fixed _forge_client lazy build). The two perf-relevant patterns introduced by this diff are both on the managed-deploy/start path: (1) _deploy_managed_worker pays a Forge backend network round-trip on every deploy_worker(custody='managed') before consulting the local DB row that already stores the provisioned wallet id — medium severity because it serializes the operator deploy hot path behind a remote call returning data the manager already has; (2) _build_run_command copies os.environ per managed worker start, repeated N times during reconcile/start_all — low severity, avoidable per-startup allocation. Neither is a blocker; both have straightforward fixes. No DB N+1 or connection-pool concerns apply (SQLite, per-call connections are pre-existing and unchanged by this diff).

🔄 runtime-semantics: Managed-custody lifecycle changes appear runtime-safe: command/env construction, lazy client init, and start/remove behavior align with intended semantics; no new runtime-semantics defects identified beyond existing coverage.

🔒 security: Pass-1 security review of allora-forge-builder-kit#35 (managed-custody worker lifecycle). The two CRITICAL claims from prior passes (missing SDK methods on ForgeBackendClient, missing FORGE_SIGNING_WALLET_ID env injection) were verified as false positives against the wrong sibling branch — on the real dependency branch tani/engn-8646-auto-provision-managed-wallets, both provision_wallet and clear_association exist on the imported class, and _build_run_command DOES inject FORGE_SIGNING_WALLET_ID at line 578; AlloraWalletConfig.from_env() also defers without requiring it. The https-scheme concern (synth-017, resolved wontfix) was verified: the SDK's _validate_backend_url IS reached in the worker subprocess path via make_remote_wallet -> ForgeBackendClient.init. Three new gaps not covered by prior reviews: (1) MEDIUM — remove_worker deletes the local row before clear_association with no durable retry record, so a crash or transient backend failure orphans a live delegated-signing binding on the Forge backend with no local trace (root-cause distinct from the synth-004 exception-swallow framing); (2) LOW — _deploy_managed_worker always calls provision_wallet (API-key-bearing) before the existence check, amplified by no lock on deploy_worker; (3) INFO/cross-repo — operator-supplied topic_desc flows into the backend wallet 'label' persisted and rendered by forge-v2, a potential stored-XSS vector if forge-v2 does not escape labels at render time. SQL is parameterized throughout (sqlc-style ? placeholders in all sqlite3.execute calls). No secrets are logged. No path traversal (artifact paths are materialized under a uuid-named file in artifact_dir). No injection in subprocess argv (topic_id is int, address is bech32 from the backend).

🎨 style: The managed-custody additions are generally well-structured — the Protocol boundary, lazy import, and locking pattern are clean. Five style issues are worth addressing: (1) ImportError is re-raised as ValueError in _forge_client(), misleading exception handlers and violating domain-exception semantics; (2) DeployResult.action is typed as bare str with an inline comment when the PR just introduced CustodyMode = Literal[...] for exactly this pattern — should be Literal["created", "reused", "replaced"]; (3) status_worker and status_all return raw dicts with magic positional column indices that diverge in ordering between the two methods (reject_zero is at row[16] in one and row[14] in the other), a WorkerStatus TypedDict or sqlite3.Row would eliminate the fragility; (4) _FakeForgeClient.provision_wallet in tests is missing its return type annotation; (5) the "managed" identity-ref sentinel is passed as an anonymous positional argument and would benefit from being a named constant.

📋 synthesis: PR#35 adds managed (Privy-via-Forge-backend) custody to WorkerManager. Many issues from prior passes have been fixed by the author (synth-001/002 FP on wrong branch, synth-004 exception swallow as product decision, synth-005 local-input rejection, synth-006 flag sync, synth-007 action label, synth-008/009 Protocol+Literal types, synth-010 identity_ref sentinel, synth-011/012 lock and ImportError message, synth-013 malformed wallet, synth-014 precheck, synth-016 label resolver, synth-017 helper typing). Phase A/B reviewers surfaced 29 new findings; after clustering and deduplication this pass yields 20 consolidated findings (4 dismissed). The key unresolved risks are: (HIGH) managed custody is architecturally complete but unreachable from the shipped workerctl CLI and web_dashboard — no operator can deploy or reconcile managed workers without writing Python directly; (MEDIUM) the allora-sdk>=1.0.6 lower bound admits published SDK versions without provision_wallet/clear_association, so pip-installed users hit AttributeError instead of the friendly error path; (MEDIUM) three durability gaps in the managed lifecycle — provision-before-write on deploy, DELETE-before-clear on remove, and silent signing_wallet_id overwrite on redeploy without releasing the prior wallet — can orphan backend bindings with no local retry record; and (MEDIUM) reconcile() has no terminal 'misconfigured' state, so a managed worker with missing credentials spins forever. Note: runtime-semantics and deep-analysis agents returned empty findings.


Generated by code-review-forge — multi-agent PR review with temporal redundancy

@TaniBuilds TaniBuilds left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Multi-Agent Inline Review

17 inline findings posted as resolvable threads.

Generated by code-review-forge

Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread tests/test_worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread tests/test_worker_manager.py Outdated

@TaniBuilds TaniBuilds left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Multi-Agent Inline Review

17 inline findings posted as resolvable threads.

1 findings not on changed lines (see PR comment for full report): 🟠 1 high

Generated by code-review-forge

Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py
# Managed custody: release the (user, topic) binding on the backend so the topic slot is
# freed. Best-effort — the worker is already gone locally, and the backend get-or-create is
# idempotent, so a stale binding is simply reused on the next deploy rather than leaking.
if custody == "managed" and signing_wallet_id:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM · 🔒 security · synth-004

remove_worker swallows all clear_association exceptions, masking permanently leaked signing-wallet bindings

The except Exception: block on lines 232–238 catches any failure from clear_association (auth errors, 403, transport failure) and logs only a warning. The worker row is already deleted before the call, so:

  1. If clear_association fails due to an expired or revoked FORGE_API_KEY, the local state shows the worker gone but the backend still holds a (user, topic) → wallet_id binding that authorizes delegated signing indefinitely.
  2. If the topic is decommissioned entirely and not re-deployed, the stale binding is never retried and remains live until manual cleanup.
  3. Auth failures (401/403) are indistinguishable from transient transport errors in the log, so operators have no signal to rotate the API key.

The comment claims 'a stale binding is simply reused on the next deploy rather than leaking' — true only if the same topic is re-deployed by the same operator; any other decommission leaves an orphaned signing authority.

💡 Suggestion: Persist failed clear_association attempts as a 'pending_release' record (topic_id, signing_wallet_id, last_error, attempted_at) in the DB so a reconcile loop or background janitor can retry. Distinguish auth failures (401/403) from transient errors and surface them as a distinct warning that the binding remains live and the API key may need rotation.

🔗 Cross-repo: affects allora-sdk-py, forge-v2

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly intentional, partly a real gap — leaving this open for a product decision.

Intentional: the broad best-effort catch is deliberate (stated in the PR description and the comment on the clear_association block in remove_worker): decommission must remove the worker locally regardless of backend reachability, and because the backend get-or-create is idempotent per (user, topic), a stale binding is reused on the next deploy of the same topic rather than leaking.

Real gap you've correctly flagged: for a topic that is permanently decommissioned (never re-deployed), the (user, topic)→wallet binding stays live until manual cleanup, and a 401/403 is logged indistinguishably from a transient error. A durable fix — a pending_release row + a reconcile/janitor retry, and elevating auth failures so operators know to rotate the key — needs a new table and background loop, which is a design decision beyond this managed-custody draft. Leaving unresolved to track that follow-up rather than silently closing it.

Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py Outdated
Comment thread allora_forge_builder_kit/worker_manager.py Outdated
Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py
Comment thread allora_forge_builder_kit/worker_manager.py
…er env (synth-002, synth-014)

Pin the DB-stored signing_wallet_id into the managed subprocess env so the worker signs
with the exact provisioned wallet (deterministic) rather than re-deriving one via topic
get-or-create. Precheck the Forge api key / backend url / wallet id before spawning so a
misconfigured managed worker fails loudly via start_worker/reconcile instead of the
subprocess exiting right after the DB row is marked 'running'.
…001, synth-003)

Add tests that exercise the actual allora_sdk surface the managed lifecycle depends on so a
cross-repo contract break cannot hide behind the _FakeForgeClient stub or an argv-only check:
- assert the real ForgeBackendClient exposes provision_wallet and clear_association
- assert AlloraWalletConfig.from_env() defers (no crash) with only FORGE_API_KEY set
- drive from_env() with the exact env _build_run_command injects and assert it builds a
  wallet-backed config without raising (HTTP mocked).
…) (synth-004, synth-006)

status_worker() already returns custody and signing_wallet_id; mirror them in status_all()
so consumers iterating the list (dashboards, CLIs) can distinguish managed from local workers
without an N+1 per-worker round-trip.
…ynth-005, synth-007)

deploy_worker(custody='managed', ...) silently dropped address/mnemonic/identity_alias. Raise
a ValueError instead so an operator porting a local-deploy script doesn't silently orphan an
address or leak a mnemonic for inputs the backend-provisioned managed path never uses.
…synth-006, synth-008, synth-018)

_update_worker now writes reject_zero and signing_wallet_id when provided and returns the
materialized artifact path. The managed redeploy path passes the new reject_zero and the
freshly-provisioned wallet id (so a redeploy cannot leave a stale flag or wallet binding), and
both replace paths use the returned path instead of re-reading via status_worker (drops one DB
round-trip, synth-018). status_worker now also surfaces reject_zero so the spawned command
actually reflects it.
…synth-009)

A managed redeploy always rotates the artifact on the one-per-topic wallet, so returning
action='reused' in auto mode was misleading for callers that branch on the action to fire
notifications or downstream jobs. Always report 'replaced' when the artifact was rotated.
…-008, synth-012, synth-017)

Replace forge_client: Optional[Any] and the untyped _forge_client() return with a
ForgeClientProtocol (plus a ProvisionedWallet structural type for the provision result). This
captures the two-method contract the managed lifecycle depends on, type-checks the injected
client at the boundary without coupling to the concrete SDK class, and types the _managed_manager
test helper's client parameter.
…ynth-009, synth-011)

The custody discriminant is a fixed two-value set; annotate WorkerSpec.custody and
deploy_worker(custody=...) with a CustodyMode = Literal["local", "managed"] alias for
type-checker coverage and IDE completion while keeping it a plain str on the wire and in SQLite.
…10, synth-013)

Managed workers have no identities row, so storing the Privy wallet UUID in identity_ref made
the column masquerade as a local alias and duplicated signing_wallet_id without a coupling
constraint. Use a 'managed' sentinel and keep signing_wallet_id as the canonical wallet id.
…ynth-011, synth-014)

_forge_client() read-then-wrote self._forge_client_obj without the RLock every other state
mutation uses, so two concurrent managed deploys could both construct a client and leak the
loser's requests.Session. Build it inside `with self._lock:` (reentrant, so existing holders
don't deadlock).
…nth-012, synth-015)

The lazy `from allora_sdk.rpc_client.remote_signer import ForgeBackendClient` raised a raw
ModuleNotFoundError when the optional SDK is absent, misleading users into thinking their API
key was wrong. Catch ImportError and re-raise a clear "managed custody requires the allora-sdk
package" message.
…-013, synth-016)

_deploy_managed_worker used info.address / info.id without checking them. Raise RuntimeError if
the backend returns an empty address or id so a malformed response fails loudly instead of
inserting a broken worker row (which would skew _address_has_other_topics and pass a bad id to
clear_association on removal).
…(synth-016-info)

The managed wallet label fell back to 'worker-topic-N' even when the topic_desc_resolver could
supply a real name. Route it through _resolve_topic_desc (the same source the rest of the
registry uses) so the backend label is human-meaningful, keeping the topic_id fallback.
@TaniBuilds

Copy link
Copy Markdown
Contributor Author

Automated-review triage — PR #35 (managed-custody worker lifecycle)

Worked through all 34 open review threads (two review runs: 17 + 17). 32 resolved, 2 left open for a product decision. 13 commits pushed (fe11468..c47afae). Full suite: 24 passed (15 prior + 9 new). [tool.uv.sources] local SDK pin and the untracked uv.lock were left as-is (intentional draft state).

TL;DR on the two "criticals" — both are false positives

The reviewer matched the wrong sibling SDK branch (tani/ENGN-8615-sdk-privy-delegated-signing). The real dependency is allora-sdk-py #81 (tani/engn-8646-auto-provision-managed-wallets), where:

  • ForgeBackendClient.provision_wallet(topic_id, label=None) exists — remote_signer.py:138 — and clear_association(wallet_id)remote_signer.py:150 (committed a7575cc, f2fda7f). Import path + ctor match this file.
  • forge-v2 endpoints exist (tani/engn-8646-auto-provision-managed-privy-wallets-bound-to-a-topic-before): POST /api/v1/signing-wallets (topic_id body) → CreateSigningWallet/GetOrCreateForTopic (handler/signing_wallet.go:62-90, service/signing_wallet.go:371, tested), and POST /api/v1/signing-wallets/{id}/clear-associationClearSigningWalletAssociation (handler/signing_wallet.go:306-309). The frontend client hits the same routes (forge.ts:891,896,915).
  • Managed workers do not crash: AlloraWalletConfig.from_env() returns a deferred managed config with only FORGE_API_KEY set (config.py:58-66), and __post_init__ allows sources==0 when forge_api_key is present (config.py:83-84). worker_runtime.py:73 uses that path.

So the contract is satisfied end-to-end (kit → SDK → forge-v2). This remains a draft that can't merge until those stacked SDK/forge-v2 branches land, but the kit code is correct.

A) Fixed (commit → what)

Finding(s) Commit Change
synth-002, synth-014 31e0a5a Inject DB-stored FORGE_SIGNING_WALLET_ID into the managed subprocess env (deterministic signing) + precheck api key/url/wallet id and raise before Popen.
synth-001, synth-003 fc790be Real-SDK contract tests: hasattr on the actual ForgeBackendClient; from_env() defers without raising; full injected env builds a wallet config (HTTP mocked).
synth-004 / synth-006 (status) 7c24b95 status_all() now returns custody + signing_wallet_id, matching status_worker().
synth-005 / synth-007 (drop args) cad8eb4 deploy_worker(custody='managed') rejects address/mnemonic/identity_alias instead of silently dropping them.
synth-006 / synth-008 + synth-018 (redeploy) 4092848 Redeploy syncs reject_zero + freshly-provisioned signing_wallet_id; _update_worker returns the materialized path (drops a redundant read); status_worker surfaces reject_zero.
synth-007 / synth-009 (action) 8a51dba Managed redeploy reports action='replaced' (artifact is always rotated).
synth-008 / synth-012 (typing) bfb0956 ForgeClientProtocol + ProvisionedWallet replace Optional[Any]; test helper typed.
synth-009 / synth-011 (enum) d0e3053 CustodyMode = Literal["local","managed"] (kept as str on the wire/DB).
synth-010 / synth-013 (identity_ref) 61a2bfc 'managed' sentinel for identity_ref; signing_wallet_id stays canonical.
synth-011 / synth-014 (lock) b77ac11 _forge_client() builds under self._lock (no session leak race).
synth-012 / synth-015 (import) 3c4c885 Friendly ValueError when allora-sdk is missing.
synth-013 / synth-016 (validate) 6736bb3 RuntimeError on empty backend address/id.
synth-016 (label, INFO) c47afae Managed wallet label routes through _resolve_topic_desc.

B) Declined, with reason (resolved)

  • synth-001 / synth-002 (criticals) — false positives; refuted with file:line evidence above. Added the cheap contract test anyway (fc790be).
  • forge-v2 endpoint statusconfirmed present (handler + service + tests + frontend client, cited above), so the "no backing endpoints" half is also refuted.
  • synth-015 b1 (remove no-ops on missing worker) — intentional idempotency for safe retry of teardown/reconcile.
  • synth-017 b2 (https on forge_backend_url) — already enforced by the SDK at client construction (remote_signer.py:85-96,115), which runs at deploy time; not duplicating the policy in the kit.
  • synth-018 b2 (DB round-trips) — redeploy half eliminated in 4092848; the remove_worker SELECT is intentionally separate (not a hot path; folding it tangles the best-effort clear_association branch).
  • synth-005 b2 (status='running' before readiness) — its crash-immediate premise is the (false) synth-001/002; the residual eager-running is a pre-existing pattern shared with local custody; a readiness gate is a separate, all-custody change.

C) Unresolved — need a product/coordination decision

  1. synth-004 b2 — leaked signing-wallet binding on permanent decommission. Best-effort clear_association is intentional (local removal must always proceed), but a permanently decommissioned topic leaves the (user,topic)→wallet binding live until manual cleanup, and 401/403 vs transient isn't distinguished. A durable fix (a pending_release row + janitor retry + auth-failure elevation) is a schema/loop decision beyond this draft.
  2. synth-010 b2 — go/ts SDK parity. The Python SDK has provision_wallet/clear_association; allora-sdk-go/allora-sdk-ts do not yet. Whether to mirror now vs. when those consumers need it is an SDK-set coordination call (no builder-kit change).

D) Verification

  • python -m py_compile on changed files — clean; no linter diagnostics.
  • uv run pytest tests/test_worker_manager.py24 passed (added: signing-wallet-id injection + missing-id raise, two redeploy regressions, malformed-wallet guard, managed-arg rejection, status_all parity, identity_ref sentinel, and 3 real-SDK contract tests).
  • No ruff/pyright config in the repo, so neither was run; from __future__ import annotations keeps the new Protocol/Literal annotations import-safe.
  • Cross-repo claims were verified by reading the auto-provision allora-sdk-py and forge-v2 worktrees directly (branches/SHAs cited above), not the branch the bot matched.

@TaniBuilds TaniBuilds left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Multi-Agent Inline Review

16 inline findings posted as resolvable threads.

4 findings not on changed lines (see PR comment for full report): 🟠 1 high, 🟡 2 medium, 🔵 1 low

Generated by code-review-forge

register a managed worker against its backend-assigned address. One wallet per topic, so a
re-deploy refreshes the artifact against the same wallet rather than allocating a new one.
"""
client = self._forge_client()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM · 🔒 security · synth-p1-003

provision_wallet called before any local write — backend wallet binding is orphaned if artifact copy or DB INSERT fails

_deploy_managed_worker (worker_manager.py:482-534) executes:

  1. client.provision_wallet(topic_id, label=...) — backend now holds a (user, topic) → wallet binding
  2. _worker_exists check
  3. _update_worker or add_worker (which calls _materialize_artifact via shutil.copy2 + sqlite3 INSERT)

Step 3 can fail for many reasons: ENOSPC on shutil.copy2, sqlite3.OperationalError (database locked, disk full), sqlite3.IntegrityError from a concurrent deploy race, or SIGTERM/KeyboardInterrupt. When this happens, the backend binding survives (provision_wallet is idempotent at (user, topic) level, so a retry recovers the same wallet) BUT the operator only recovers the binding by re-running deploy_worker. If they instead run remove_worker to clean up, _get_custody returns ('local', None) (no row exists) and no clear_association is ever issued. The backend binding leaks permanently — the provisioned wallet_id was never persisted locally.

💡 Suggestion: Wrap the provision_wallet → add_worker sequence in a try/except: on any exception after a successful provision_wallet, log the provisioned wallet_id+address at WARN level (so an operator can grep logs) and best-effort call clear_association(info.id) to release the backend binding. Alternatively, stage a tombstone row (signing_wallet_id=NULL, custody='managed_provisioning') before the provision_wallet call, update it on success, and let reconcile() detect and clean up stale tombstones on restart.

🔗 Cross-repo: affects allora-sdk-py, forge-v2

@@ -175,18 +257,34 @@ def add_worker(self, spec: WorkerSpec) -> None:
def remove_worker(self, topic_id: int, address: str, force: bool = False) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM · 🔒 security · synth-p1-004

remove_worker deletes the local worker row BEFORE calling clear_association — a crash in this window orphans a live delegated-signing binding with no local retry record

remove_worker (worker_manager.py:257-279) executes:

  1. _get_custody reads signing_wallet_id into a local variable
  2. _archive_active_deployment
  3. DELETE FROM workers ... COMMIT
  4. clear_association(signing_wallet_id) ← only attempted after the row is gone

If the process is killed (SIGKILL, OOM, host reboot) between step 3 and step 4, the Forge backend retains an active (user, topic) → wallet_id binding that authorizes delegated Privy signing, while the local registry shows no worker and no pending-release record. There is no way to enumerate orphaned bindings from the kit; the backend binding leaks permanently.

This is distinct from the prior synth-004 concern (exception swallowing): even if clear_association succeeds, the window between DELETE and clear_association is non-retryable because signing_wallet_id is never persisted to a durable cleanup queue — it only exists as a local variable at that point.

Note: The author left synth-004 open as a 'product decision'; this finding adds the stronger ordering argument as a new angle.

Suggested change
def remove_worker(self, topic_id: int, address: str, force: bool = False) -> None:
def remove_worker(self, topic_id: int, address: str, force: bool = False) -> None:
if force:
self.stop_worker(topic_id, address)
custody, signing_wallet_id = self._get_custody(topic_id, address)
# For managed custody: release backend binding BEFORE local deletion so a crash
# cannot orphan a live delegated-signing binding with no local retry record.
if custody == "managed" and signing_wallet_id:
try:
self._forge_client().clear_association(signing_wallet_id)
logger.info("released managed wallet %s topic binding (topic %s)", signing_wallet_id, topic_id)
except Exception as e: # noqa: BLE001
logger.warning(
"clear-association failed for managed wallet %s (topic %s): %s; removed locally anyway",
signing_wallet_id, topic_id, e,
)
self._archive_active_deployment(topic_id, address)
with sqlite3.connect(self.db_path) as conn:
conn.execute("DELETE FROM workers WHERE topic_id=? AND address=?", (topic_id, address))
conn.commit()
self._monitor_disable(topic_id, address)

🔗 Cross-repo: affects allora-sdk-py, forge-v2

)
address = info.address

if self._worker_exists(topic_id, address):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM · 🔒 security · synth-p1-005

_update_worker silently overwrites signing_wallet_id on redeploy without releasing the prior wallet

In _deploy_managed_worker's redeploy path (lines 498-500), _update_worker is called with the freshly-provisioned info.id. _update_worker (worker_manager.py:996-1015) writes signing_wallet_id=COALESCE(?, signing_wallet_id) — when a non-NULL id is supplied, it overwrites the prior id with no diff check.

If the backend ever returns the same address but a different wallet_id for the same topic (wallet cleared/regenerated server-side, backend migration, DR event), _update_worker silently replaces the stored id. The previous wallet's backend binding is now invisible to the kit — clear_association can never be called on it because the id is gone, and no warning is logged. This is a reliance on a backend invariant (wallet_id stable per (user, topic)) that is not locally enforced.

While this backend invariant likely holds today, a future backend migration or incident could violate it silently, and the kit has no observability hook to detect the rotation.

💡 Suggestion: Before overwriting via _update_worker, read the current signing_wallet_id from the DB. If it is non-NULL and != info.id, log a WARN ('backend rotated wallet for topic {topic_id}: old={old_id} new={info.id}') and best-effort call clear_association(old_id) so the prior binding does not leak. Alternatively, refuse the redeploy and surface a clear error directing the operator to remove_worker + redeploy.

🔗 Cross-repo: affects allora-sdk-py, forge-v2

# Imported lazily: local-custody installs need not import the SDK signing client.
try:
from allora_sdk.rpc_client.remote_signer import ForgeBackendClient
except ImportError as e:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM · 🎨 style · synth-p1-007

ImportError is re-raised as ValueError in _forge_client() — misleads exception handlers and violates Python exception-type semantics

In _forge_client() (worker_manager.py:184-191), a missing optional dependency (allora_sdk) is caught as ImportError and re-raised as ValueError. This is semantically incorrect: ValueError conventionally signals that an argument has an invalid value, not that an optional package is missing. Callers writing except ImportError to detect missing-dependency situations will silently miss this exception; callers writing except ValueError for bad-argument handling will unexpectedly catch an import failure. The Allora Python style guide favours domain-specific exceptions; at minimum, re-raise as ImportError (preserving the chained cause) so the exception type communicates the root cause.

Suggested change
except ImportError as e:
except ImportError as e:
raise ImportError(
"managed custody requires the 'allora-sdk' package "
"(allora_sdk.rpc_client.remote_signer.ForgeBackendClient); install it to deploy "
"managed workers"
) from e

e,
)

def status_worker(self, topic_id: int, address: str) -> dict:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM · 🎨 style · synth-p1-008

status_worker and status_all use diverging magic column indices — reject_zero is at row[16] in one and row[14] in the other

The PR adds three new magic column accesses to status_worker (row[14]=custody, row[15]=signing_wallet_id, row[16]=reject_zero) and two to status_all (row[15]=custody, row[16]=signing_wallet_id). The reject_zero column lands at index 16 in status_worker but at index 14 in status_all because the two SELECT lists use different column orderings — a direct consequence of building the mapping by hand against positional indices.

The Allora Python style guide explicitly prefers structured types (dataclasses / TypedDict) over raw dicts with magic indices. A future column addition to either SELECT that misaligns the indices would be a silent wrong-value bug (no test failure, no type error). Using sqlite3.Row (name-based column access) or a WorkerStatus TypedDict would eliminate this fragility entirely.

💡 Suggestion: Set conn.row_factory = sqlite3.Row on the connection in both status_worker and status_all, then access columns by name (row['custody'], row['signing_wallet_id'], etc.) instead of index. Alternatively, define a WorkerStatus TypedDict and populate it from named fields to give callers static checks and IDE completion.

@@ -47,6 +57,31 @@ class DeployResult:
message: str

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 LOW · 🎨 style · synth-p1-016

DeployResult.action typed as bare str with inline comment — should use Literal["created", "reused", "replaced"] for consistency with CustodyMode

The PR introduces CustodyMode = Literal["local", "managed"] specifically to give type-checker coverage on a fixed two-value discriminant. However, DeployResult.action (line 56) still uses bare str with a trailing comment # created|reused|replaced. This is exactly the pattern CustodyMode was added to replace. Defining action: Literal["created", "reused", "replaced"] would give callers static guarantees, allow exhaustiveness checks, and remove the comment. The inconsistency is especially visible because both are introduced/touched in the same PR.

Suggested change
message: str
action: Literal["created", "reused", "replaced"]

register a managed worker against its backend-assigned address. One wallet per topic, so a
re-deploy refreshes the artifact against the same wallet rather than allocating a new one.
"""
client = self._forge_client()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 LOW · ⚡ performance · synth-p1-017

_deploy_managed_worker unconditionally calls provision_wallet before checking the local DB — redundant network round-trip on every redeploy

🤝 flagged by 2 agents: performance, security

_deploy_managed_worker unconditionally calls client.provision_wallet(topic_id, label=...) and only AFTERWARDS checks _worker_exists(topic_id, address). On the common redeploy path (same topic, refreshed artifact), a local DB row already exists with a stored signing_wallet_id and address. The backend call is idempotent but every managed redeploy pays a network round-trip to the Forge backend carrying FORGE_API_KEY even though the wallet binding is already stored locally.

Additionally, deploy_worker does not hold self._lock during this sequence, so two concurrent deploys for the same topic both call provision_wallet in parallel, doubling the API-key-bearing requests. The provision_wallet call is the only place the Forge API key leaves the process over the network, so minimizing its frequency reduces attack surface.

💡 Suggestion: Read the existing worker row for (topic_id) first; if a managed row with a non-null signing_wallet_id exists and replace=False, reuse the stored address/wallet_id and skip the provision_wallet call, calling the backend only when the row is missing or signing_wallet_id is null. If re-provisioning is required for correctness on every deploy, document it as an explicit contract and add a force_reprovision flag.

🔗 Cross-repo: affects allora-sdk-py, forge-v2

"re-deploy with custody='managed' to provision the backend wallet"
)
cmd.extend(["--custody", "managed"])
env = os.environ.copy()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 LOW · 🔒 security · synth-p1-018

_build_run_command copies the full parent process environment into managed worker subprocesses — principle-of-least-privilege violation

🤝 flagged by 2 agents: adversarial, performance

For managed workers, _build_run_command calls env = os.environ.copy() (line 573) on every invocation. This propagates EVERY environment variable the parent process carries — AWS_ACCESS_KEY_ID, DATABASE_URL, GITHUB_TOKEN, FORGE_OPERATOR_PRIVATE_KEY, etc. — into the managed subprocess, regardless of whether worker_runtime needs them. The blast radius is bounded (same codebase) but it is a least-privilege violation: a managed worker subprocess legitimately needs FORGE_API_KEY, FORGE_BACKEND_URL, FORGE_SIGNING_WALLET_ID, ALLORA_API_KEY, FEE_GRANTER, and standard infra vars (PATH, HOME).

Additionally, copying os.environ N times during start_all/reconcile for N managed workers is avoidable per-startup allocation (though low severity given typical N).

Suggested change
env = os.environ.copy()
cmd.extend(["--custody", "managed"])
allowlist = ("PATH", "HOME", "LANG", "LC_ALL", "PYTHONPATH", "PYTHONUNBUFFERED", "ALLORA_API_KEY", "FEE_GRANTER", "TZ")
env = {k: v for k, v in os.environ.items() if k in allowlist}
env["FORGE_API_KEY"] = self._forge_api_key
env["FORGE_BACKEND_URL"] = self._forge_backend_url
env["FORGE_SIGNING_WALLET_ID"] = signing_wallet_id

)
cmd.extend(["--mnemonic-file", str(key_file)])
env: Optional[dict[str, str]] = None
if status.get("custody") == "managed":

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 LOW · ✅ correctness · synth-p1-019

FEE_GRANTER is SDK-recommended for managed (Privy) signing but never plumbed by WorkerManager — operators get opaque tx-fee failures

The sibling allora-sdk-py's AlloraWalletConfig docstring explicitly states fee_granter 'is the recommended pairing for Privy-delegated (RemoteWallet) signing' — because a freshly-provisioned Privy wallet has zero ALLO and cannot pay gas fees without a granter. _build_run_command injects FORGE_API_KEY, FORGE_BACKEND_URL, FORGE_SIGNING_WALLET_ID — but not FEE_GRANTER. It falls through os.environ.copy(), so the SDK only sees a granter if the parent process happened to export it. There is no fee_granter parameter on WorkerManager.init and no precheck warns when managed custody is configured without a granter.

Failure mode: managed worker subprocess starts, RemoteWallet builds successfully, the first submission fails with an insufficient-fee error from the chain. reconcile() re-attempts forever (no terminal misconfigured state per synth-p1-006).

💡 Suggestion: Either (a) accept fee_granter on WorkerManager.init (defaulting to $FEE_GRANTER) and explicitly inject it into the managed env; or (b) emit logger.warning at deploy_worker(custody='managed') time when FEE_GRANTER is absent from the environment, naming the SDK recommendation. Add an equivalent precheck in build_run_command for parity with the existing FORGE* credential checks.

🔗 Cross-repo: affects allora-sdk-py

self.provisioned: list[tuple[int, str | None]] = []
self.cleared: list[str] = []

def provision_wallet(self, topic_id: int, label: str | None = None):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 LOW · 🎨 style · synth-p1-020

_FakeForgeClient.provision_wallet missing return type annotation in tests

The provision_wallet method in _FakeForgeClient (test file, line ~19) has parameter type hints but no return type annotation. The Allora Python style guide requires type hints on all functions. The return value is SimpleNamespace at runtime but should structurally satisfy ProvisionedWallet. Adding -> ProvisionedWallet (imported from worker_manager) would document the contract and let Pyright verify the test stub satisfies the Protocol.

Suggested change
def provision_wallet(self, topic_id: int, label: str | None = None):
def provision_wallet(self, topic_id: int, label: str | None = None) -> "ProvisionedWallet":

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant