Problem
Production promotion currently exists only as the Promote event in /admin/forge. The LiveView checks Accounts.admin?/1 and calls OpenAgents.Forge.Targets.promote/4, but no authenticated API exposes the same operation. Operators therefore need a browser session or browser automation for a control-plane action that should support scripts, release tooling, and incident recovery.
The current API token model cannot safely fill this gap. Every token has the single forge:write scope, and ordinary active users can obtain it. That scope authorizes repository and issue writes, so it must never authorize deployment of the OpenAgents production fleet.
Decision
Add a dedicated, operator-only JSON API that creates and observes Forge promotion targets. Keep OpenAgents.Forge.Targets as the single backend for both the LiveView and the API. Do not duplicate deployment logic in a controller or shell command.
Use a new high-risk scope such as deployments:promote. Authorization must require both conditions on every request:
- The bearer token carries the exact privileged scope.
- The token owner is an active user for whom
Accounts.admin?/1 is true at request time.
This second check makes operator removal effective immediately, including for unexpired tokens. Never infer authority from a username, repository membership, the existing forge:write scope, or possession of a Git push credential.
API contract
Add an explicitly classified operator API pipeline and routes such as:
POST /api/v3/admin/forge/targets creates a promotion.
GET /api/v3/admin/forge/targets/:id returns its current lifecycle state.
GET /api/v3/admin/forge/targets lists a bounded recent history for operational tooling.
The create request must include:
- The canonical repository identity.
- A full 40-character commit SHA. Reject branch names, tags, abbreviations, and unpushed commits.
- The environment, initially restricted to
production for the production deployment installation.
- A caller-generated idempotency key.
Create the target through OpenAgents.Forge.Targets.promote/4 so the existing WAL-backed commit check, builder wake-up, target lifecycle, receipts, and deployment safety gates remain authoritative. Return 202 Accepted with the immutable target ID, exact SHA, initial state, status URL, and request ID. The endpoint must not wait for the build or deployment.
Use compare-and-set or equivalent transaction semantics to prevent two concurrent requests from unknowingly superseding each other. Accept an optional expected-current-target ID and return 409 Conflict when it no longer matches. Replaying the same idempotency key with the same payload returns the original target; replaying it with a different payload returns 409 Conflict.
Return bounded, machine-readable errors for missing credentials, insufficient scope, non-operator users, unknown SHAs, invalid repositories, stale targets, and idempotency conflicts. Do not expose internal node identities, filesystem paths, tokens, or unrestricted deployment failure details.
Privileged credential issuance
Extend the API-token and device-authorization model without making privileged scopes generally selectable:
- Permit
deployments:promote only when the identified user is a current operator.
- Recheck operator authority when the credential is used.
- Give privileged credentials a shorter maximum lifetime than ordinary
forge:write tokens.
- Record creation, use, revocation, and failed authorization in the audit log without recording plaintext credentials.
- Support scope request and approval through the device authorization flow so an operator can bootstrap a CLI without creating the deployment through the UI.
- Display the requested privileged scope clearly during device approval.
Database constraints must admit the new scope while preserving a deny-by-default allowed-scope list.
Audit and operational behavior
Record the operator user ID, API token ID, request ID, idempotency key digest, repository, exact SHA, environment, source channel, target ID, and timestamp. Never store the plaintext token or idempotency key.
The API creates a promotion intent only. The existing builder, classifier, direct-load, relup, and rolling-replacement lanes remain responsible for execution. Status responses must show enough typed state for automation to distinguish live, failed, reverted, and needs_rolling_replace without granting infrastructure shell access.
Tests
Add focused controller, authentication, context, and route-authority tests that prove:
- An operator with
deployments:promote can promote an exact pushed SHA.
- A non-operator with that scope is denied.
- An operator with only
forge:write is denied.
- Revoked, expired, and malformed tokens are denied.
- Removing an operator from the configured allowlist invalidates later requests from an existing privileged token.
- Branch names, abbreviated SHAs, unknown SHAs, and unauthorized repositories are rejected.
- Identical idempotent retries return one target and conflicting retries return
409 Conflict.
- Concurrent requests honor the expected-current-target precondition.
- API and LiveView promotion use the same context path and produce the same target lifecycle event.
- Route authority classifies these routes as operator mutations with
deployments:promote, not under the generic /api/v3 write catch-all.
Documentation
Update the deployment runbook, API authentication guide, CLI API reference, and authority inventory. Document credential bootstrap, promotion, polling, revocation, expected error responses, and incident use. State explicitly that forge:write cannot promote a fleet target.
Acceptance criteria
- An operator can authenticate a non-browser client, promote an exact pushed SHA, and follow the target to a terminal state without using
/admin/forge.
- The LiveView and API share
OpenAgents.Forge.Targets and the same deployment lifecycle.
- No ordinary user credential can acquire or exercise production promotion authority.
- Every attempt produces bounded audit evidence.
mix precommit passes.
Problem
Production promotion currently exists only as the Promote event in
/admin/forge. The LiveView checksAccounts.admin?/1and callsOpenAgents.Forge.Targets.promote/4, but no authenticated API exposes the same operation. Operators therefore need a browser session or browser automation for a control-plane action that should support scripts, release tooling, and incident recovery.The current API token model cannot safely fill this gap. Every token has the single
forge:writescope, and ordinary active users can obtain it. That scope authorizes repository and issue writes, so it must never authorize deployment of the OpenAgents production fleet.Decision
Add a dedicated, operator-only JSON API that creates and observes Forge promotion targets. Keep
OpenAgents.Forge.Targetsas the single backend for both the LiveView and the API. Do not duplicate deployment logic in a controller or shell command.Use a new high-risk scope such as
deployments:promote. Authorization must require both conditions on every request:Accounts.admin?/1is true at request time.This second check makes operator removal effective immediately, including for unexpired tokens. Never infer authority from a username, repository membership, the existing
forge:writescope, or possession of a Git push credential.API contract
Add an explicitly classified operator API pipeline and routes such as:
POST /api/v3/admin/forge/targetscreates a promotion.GET /api/v3/admin/forge/targets/:idreturns its current lifecycle state.GET /api/v3/admin/forge/targetslists a bounded recent history for operational tooling.The create request must include:
productionfor the production deployment installation.Create the target through
OpenAgents.Forge.Targets.promote/4so the existing WAL-backed commit check, builder wake-up, target lifecycle, receipts, and deployment safety gates remain authoritative. Return202 Acceptedwith the immutable target ID, exact SHA, initial state, status URL, and request ID. The endpoint must not wait for the build or deployment.Use compare-and-set or equivalent transaction semantics to prevent two concurrent requests from unknowingly superseding each other. Accept an optional expected-current-target ID and return
409 Conflictwhen it no longer matches. Replaying the same idempotency key with the same payload returns the original target; replaying it with a different payload returns409 Conflict.Return bounded, machine-readable errors for missing credentials, insufficient scope, non-operator users, unknown SHAs, invalid repositories, stale targets, and idempotency conflicts. Do not expose internal node identities, filesystem paths, tokens, or unrestricted deployment failure details.
Privileged credential issuance
Extend the API-token and device-authorization model without making privileged scopes generally selectable:
deployments:promoteonly when the identified user is a current operator.forge:writetokens.Database constraints must admit the new scope while preserving a deny-by-default allowed-scope list.
Audit and operational behavior
Record the operator user ID, API token ID, request ID, idempotency key digest, repository, exact SHA, environment, source channel, target ID, and timestamp. Never store the plaintext token or idempotency key.
The API creates a promotion intent only. The existing builder, classifier, direct-load, relup, and rolling-replacement lanes remain responsible for execution. Status responses must show enough typed state for automation to distinguish
live,failed,reverted, andneeds_rolling_replacewithout granting infrastructure shell access.Tests
Add focused controller, authentication, context, and route-authority tests that prove:
deployments:promotecan promote an exact pushed SHA.forge:writeis denied.409 Conflict.deployments:promote, not under the generic/api/v3write catch-all.Documentation
Update the deployment runbook, API authentication guide, CLI API reference, and authority inventory. Document credential bootstrap, promotion, polling, revocation, expected error responses, and incident use. State explicitly that
forge:writecannot promote a fleet target.Acceptance criteria
/admin/forge.OpenAgents.Forge.Targetsand the same deployment lifecycle.mix precommitpasses.