Summary
RFC 0082 defined the agent deployment lifecycle at v1. Its transition rules lived in spec/v1/agent-deployment.md, which is frozen, and v2 carried them only in a schema description. The v2 suite's v2-deployment-lifecycle scenario found eight places where the rules gave no answer. This RFC answers them for v2:
- the error for a transition the record's state refuses;
- whether
promotemay skip a step; - how a paused version resumes;
- what an absent
statesadvertisement means; - what
deploy:*names; - which version a rollback restores;
- where the
deployment.*events can be read; - what a gated transition returns.
Motivation
openwop-app is about to implement agents.deployment (openwop-app#5393), and the white-label MyndHyve will inherit it. The legacy MyndHyve runtime, the only host that ever served the facet, retired on 2026-10-29. No host advertises the facet today, so tightening it now breaks no one. Leaving the eight questions open would let the first implementation become the de-facto rule.
Three of the gaps are also security-relevant:
- A
promotethat skips fromdrafttoactivewould bypass thetestandstagedpoints where an eval gate or approval gate attaches. Invariantdeployment-promotion-fail-closedforbids that bypass, but no rule said the skip is refused. - The
deployment.*events had no portable read, so invariantdeployment-event-no-content-leakhad no behavioural witness. deployment-promotion-fail-closedis protocol-tier, but its only public test ran at major 1.
Proposal
The rules live in the agents.deployment facet description, which spec/v2/declaration.json names as the agents family's normative text. The core word budget is at its cap, so they do not go in a core doc. This follows RFC 0249 §F.
§A — Transitions
The legal edges are:
| From | To | Transition |
|---|---|---|
draft | test | promote |
test | staged | promote |
staged | active | promote |
active | paused | pause |
paused | active | promote |
active | deprecated | deprecate |
active | rolled-back | rollback |
adjust-canary changes an active version's canaryPercent and does not change its state. A resume records deployment.state.changed, as pause and deprecate do; deployment.promoted records only a forward step.
promotemoves one step. An absenttoStatemeans the next state. AtoStatethat is not the next state is refused.- Refusal. A transition the record's current state does not allow MUST be refused
409 deployment_state_conflict, withdetails.state(the current state) anddetails.transition, and the record MUST NOT change. - Order of checks. A target state or
canaryPercentthe host does not advertise is400 validation_error, and is checked before the state machine. A request that can never succeed on this host is a validation error; a request the record's current state refuses is a conflict.
§B — Advertised states
- An absent
statesmeans all seven. - An advertised subset MUST include
draftandactive. - Over a subset, the legal edges are §A's with the unadvertised states removed, and
promotemoves to the next advertised state indraft → test → staged → activeorder.
§C — Rollback
- The request's
versionnames theactiveversion being rolled back. - The host restores the most recent other version that was
activeon the same channel before it and is not itselfrolled-back. Normally that is the incumbent, the same version that takes the canary remainder. rollbackPointernames the restored version, which isactiveafterwards.- With no such version, the rollback is
409 deployment_state_conflict.
§D — The management run
- Every transition the host accepts runs on a deployment-management run.
- The deployment record's new optional
transitionRunIdnames that run, and a host MUST set it on every record a transition created or changed. - The run carries the transition's
authorization.decided, any approval interrupt, and itsdeployment.*event. A caller withruns:readin the same tenant reads them through the ordinary run reads. - A refused transition creates no run, and its
authorization.decidedgoes only to the audit log. - A gated transition answers
202(RFC 0051): the run suspends on its approval interrupt, and the response is the record unchanged apart fromtransitionRunId.
§E — The scope
deploy: is one scope, as the v2 operation's security requirement and the OAuth2 scope list already name it. A 403 for it names scope="deploy:" (identity.md §4). The v1 per-verb scopes (deploy:promote, deploy:rollback, deploy:pause) are not part of the v2 wire; a host MAY map roles to them internally.
Wire shape
// schemas/v2/agent-deployment.schema.json — one new optional property
"transitionRunId": { "$ref": "ids.schema.json#/$defs/runId" }
// spec/v2/errors.json — one new code
{ "code": "deployment_state_conflict", "httpStatus": 409,
"details": { "required": ["state", "transition"] } }
// transitionAgentDeployment at v2 — two new responses
"202": AgentDeployment // gated: record unchanged except transitionRunId
"409": Error // deployment_state_conflict
Both deployment schemas leave the v1 seed set (scripts/derive-v2-schemas.mjs), and this RFC owns their v2 edits.
Examples
promote {version: "2.0.0", toState: "active"}on adraftrecord →409 deployment_state_conflict,details: {state: "draft", transition: "promote"}; the record is unchanged.promote {version: "2.0.0"}on adraftrecord of a host advertisingstates: ["draft", "active"]→200, stateactive.rollback {version: "2.0.0"}while1.0.0was active onstablebefore it →200.2.0.0isrolled-backwithrollbackPointer: "1.0.0", and1.0.0isactive.- A promote through an approval gate →
202. The record is unchanged; itstransitionRunIdnames a run suspended on anapprovalinterrupt.
Compatibility
Additive (COMPATIBILITY.md §2.4):
- one new registered error code;
- one optional property on a closed v2 object;
- two responses added to an operation no host serves;
- rules for transitions the v2 corpus left unspecified.
No existing MUST is relaxed and no field changes type. The v1 wire and spec/v1/agent-deployment.md are unchanged.
Conformance
v2-deployment-lifecycle (suite 2.46.35) carries the legs. It is gated on agents.deployment and the conformance-agent-channel-dispatch fixture. Rows that were deliberately loose in #2060 are tightened to the rulings, and new legs cover the step-skip refusal, the resume, the management-run link and its recorded event. deployment-promotion-fail-closed lists the scenario as its v2 public test.
Falsifiability — one row per normative requirement
| Requirement | Observable — what an outside party sees | Who can cause the condition | Verdict |
|---|---|---|---|
| §A an illegal edge is a 409 with the state | the status, error.code and details.state; the record unchanged | the suite | witnessable-gated: openwop.requirement.0082.transition-illegal-refused |
§A promote moves one step | draft→active refused 409, record unchanged | the suite | witnessable-gated: openwop.requirement.0082.promote-skip-refused |
§A paused→active is promote | pause, then promote, returns to active | the suite, on a host advertising paused | witnessable-gated: openwop.requirement.0082.resume-from-paused |
| §A unadvertised state is a 400, checked first | 400 validation_error, record unchanged | the suite, on a host advertising a subset | witnessable — gated (host-pending, G5): needs a host advertising a states subset — openwop.requirement.0082.state-unadvertised-refused |
§B absent states means all seven | the walk runs and passes with states absent | the suite | witnessable-gated: openwop.requirement.0082.transition-legal |
| §C rollback restores the incumbent | rollbackPointer equals the version active before the promote, and it is active | the suite, on a host advertising rollback | witnessable-gated: openwop.requirement.0082.rollback-pointer |
| §D every accepted transition names its run | transitionRunId on each 200 record | the suite | witnessable-gated: openwop.requirement.0082.transition-run-linked |
§D the run carries the deployment.* event | the event in the run's log, its payload valid and content-free | the suite | witnessable-gated: openwop.requirement.0082.transition-event-recorded |
| §D a gated transition answers 202 | 202 and a suspended run | a host policy gate only | witnessable — gated on a host fixture; none exists (G2) |
| §D a refused transition creates no run | no run exists | nobody from outside | unwitnessable: a run that does not exist cannot be listed portably |
§E a 403 names deploy:* | 403 forbidden; the WWW-Authenticate scope when present | the suite, with a low-scope key | witnessable-gated: openwop.requirement.0082.scope-fail-closed |
Security
- Step-skipping would bypass the gates a host attaches to
testandstaged. §A refuses it, and0082.promote-skip-refusedwitnesses that. - **The
deployment.*events become readable** through §D. They stay content-free, asrun-event-payloads.schema.jsonrequires.0082.transition-event-recordedis the first behavioural witness ofdeployment-event-no-content-leak. - The management run is tenant-scoped like every run: only a caller with
runs:readin the same tenant reads it.
Alternatives considered
400 validation_errorfor an illegal edge. Rejected. The request is well-formed and could succeed against a record in another state. That is a state conflict, whichrun_state_conflictandproposal_state_conflictalready answer with 409.- Let
promotejump to any later state. Rejected: it would skip the states where gates attach. - A new
resumetransition verb. Rejected.promotealready means "move toward serving", and a new enum member adds a request value every host must accept without adding meaning. - A
rollbackTorequest field. Rejected for now. Restoring the previous incumbent is the recovery operators expect, and it is the version that already holds the canary remainder. A host that needs an arbitrary target can promote that version instead. - Return the deployment events in the transition response. Rejected. The events belong to a run, with its interrupts and audit trail, so linking the run reuses every existing read.
- Per-verb
deploy:promote/deploy:rollback/deploy:pausescopes at v2. Rejected for now. The v2 wire already namesdeploy:*in the operation's security requirement and the OAuth2 scope list; splitting it would change a published scope set.
Unresolved questions
None. The eight questions from #2060 are ruled in §A–§E.
Implementation notes (non-normative)
- openwop-app#5393 implements the facet. The fixture contract is in
conformance/fixtures.md, in theconformance-agent-channel-dispatchmajor-2 subsection. - The canary draw and the run-start pin are unchanged from RFC 0082 §B.
Acceptance criteria
- [x] The rules are in the
agents.deploymentfacet description;deployment_state_conflictis registered;transitionRunIdis on the record; the v2 operation answers 202 and 409. - [x]
v2-deployment-lifecyclewitnesses §A–§E, and every new or tightened leg is sabotage-proved on a scratch host. - [x]
deployment-promotion-fail-closedlists the v2 scenario. - [x] A host advertising
agents.deploymentrecords the0082.*legsexecuted-passon a certified cut. openwop-app's 2.46.35 production cut (openwop-workflow-engine-2.46.35-72e610b3d.json, 524/0/0/377/11, signature verified) passes 14 of 16. The other two,0082.state-unadvertised-refusedand0082.canary-unsupported-refused, refuse what a host leaves unadvertised. That host advertises all seven states and canary, so they recordinapplicable, as this RFC's falsifiability table says ("on a host advertising a subset"). Their witness waits for such a host (G5).
References
RFCS/0082-agent-deployment-lifecycle.md— the v1 lifecycle this completes at v2.RFCS/0249-proposal-operations-and-install-readback.md— §F, rules in a facet description when the core budget is full;proposal_state_conflict.spec/v2/core/runs.md§Pause and resume —run_state_conflict.spec/v2/core/identity.md§4 — theinsufficient_scopechallenge.