plan.transition
Record that execution of an issue began (`start`), that implementation is complete with evidence outstanding (`verify`), or that it is finished (`done`). One field of the issue's own record is stamped, the seal of its event is written beside it, and the event is appended to the ledger; a stamp without that seal moves no status. The move is refused when the model says it is illegal — an issue that is not READY cannot start, one that was never ACTIVE cannot be verified, and one that was never started, whose dependencies are unfinished or whose required evidence is absent cannot be done — and the refusal names what is in the way. The status that comes back is derived from the record afterwards, not announced by the move: a `done` whose evidence is missing leaves the issue in VERIFY and says so.
Record that execution of an issue began (`start`), that implementation is complete with evidence outstanding (`verify`), or that it is finished (`done`). One field of the issue's own record is stamped, the seal of its event is written beside it, and the event is appended to the ledger; a stamp without that seal moves no status. The move is refused when the model says it is illegal — an issue that is not READY cannot start, one that was never ACTIVE cannot be verified, and one that was never started, whose dependencies are unfinished or whose required evidence is absent cannot be done — and the refusal names what is in the way. The status that comes back is derived from the record afterwards, not announced by the move: a `done` whose evidence is missing leaves the issue in VERIFY and says so.
- command
- behaviorally_verified
- module plan
- #plan
- #project
- #issues
- #lifecycle
Exposure
Where this one definition is reachable. Absence is explicit: a surface not listed does not carry it.
| surface | as |
|---|---|
| MCP | tool majordomus_plan_transition |
| HTTP | POST /api/v1/plan/transition operationId plan.transition |
| command line | — |
Schemas
The canonical input and output, from the Rust types; the MCP tool schema and the OpenAPI parameters and responses are derived from these.
input · PlanTransitionInput
plan.transition: which issue to move, and which way.
Two fields and no third. There is no timestamp — the move is stamped with the moment it happened, never with one the caller supplies, because a lifecycle a caller can backdate is not a record of what happened. There is no force or reason field either: a transition the model refuses is refused, and the way to make it legal is to satisfy the guard rather than to ask past it.
use majordomus_cli::capability::builtin::plan::PlanTransitionInput;
use majordomus_cli::plan::Transition;
// The wire form an MCP client or an HTTP POST sends.
let input: PlanTransitionInput =
serde_json::from_str(r#"{"issue":"I0001","transition":"done"}"#).unwrap();
assert_eq!(input.issue, "I0001");
assert_eq!(input.transition, Transition::Done);
assert_eq!(input.transition.field(), "completed_at");
assert_eq!(input.transition.event(), "plan_done");
// Unknown fields are refused rather than ignored: a caller that misspells `transition`
// is told so instead of having its move silently dropped.
assert!(serde_json::from_str::<PlanTransitionInput>(
r#"{"issue":"I0001","transition":"done","force":true}"#).is_err());| property | type | required | description |
|---|---|---|---|
| issue | string | yes | The issue to move, by its id — which is also its file name. |
| transition | reference | yes | Which move: start, verify or done. |
JSON Schema
{
"$defs": {
"PlanTransition": {
"description": "One lifecycle move.\n\nThree, not four: `evidence` appends a record rather than moving an issue, and it belongs\nwith the record it proves rather than with the moves it unblocks.\n\n```\nuse majordomus_cli::plan::Transition;\n\n// The wire spelling is the one a caller types on the command line.\nlet t: Transition = serde_json::from_str(\"\\\"done\\\"\").unwrap();\nassert_eq!(t, Transition::Done);\nassert_eq!(serde_json::to_string(&Transition::Start).unwrap(), \"\\\"start\\\"\");\n\n// Each move owns the field it stamps and the event it records; nothing else decides\n// either, so a new move cannot be added without saying both.\nfor (t, field, event) in [\n (Transition::Start, \"started_at\", \"plan_start\"),\n (Transition::Verify, \"verified_at\", \"plan_verify\"),\n (Transition::Done, \"completed_at\", \"plan_done\"),\n] {\n assert_eq!(t.field(), field);\n assert_eq!(t.event(), event);\n}\n```",
"oneOf": [
{
"const": "start",
"description": "Execution began. Refused unless the issue is READY.",
"type": "string"
},
{
"const": "verify",
"description": "Implementation is complete and the evidence is still outstanding.",
"type": "string"
},
{
"const": "done",
"description": "Complete. Refused while anything it waits on is unfinished, or while any evidence\nits own record requires is absent.",
"type": "string"
}
]
}
},
"additionalProperties": false,
"description": "The input of `plan.transition`: which issue to move, and which way.\n\nTwo fields and no third. There is no timestamp — the move is stamped with the moment it\nhappened, never with one the caller supplies, because a lifecycle a caller can backdate\nis not a record of what happened. There is no force or reason field either: a transition\nthe model refuses is refused, and the way to make it legal is to satisfy the guard rather\nthan to ask past it.\n\n```\nuse majordomus_cli::capability::builtin::plan::PlanTransitionInput;\nuse majordomus_cli::plan::Transition;\n\n// The wire form an MCP client or an HTTP POST sends.\nlet input: PlanTransitionInput =\n serde_json::from_str(r#\"{\"issue\":\"I0001\",\"transition\":\"done\"}\"#).unwrap();\nassert_eq!(input.issue, \"I0001\");\nassert_eq!(input.transition, Transition::Done);\nassert_eq!(input.transition.field(), \"completed_at\");\nassert_eq!(input.transition.event(), \"plan_done\");\n\n// Unknown fields are refused rather than ignored: a caller that misspells `transition`\n// is told so instead of having its move silently dropped.\nassert!(serde_json::from_str::<PlanTransitionInput>(\n r#\"{\"issue\":\"I0001\",\"transition\":\"done\",\"force\":true}\"#).is_err());\n```",
"properties": {
"issue": {
"description": "The issue to move, by its id — which is also its file name.",
"type": "string"
},
"transition": {
"$ref": "#/$defs/PlanTransition",
"description": "Which move: `start`, `verify` or `done`."
}
},
"required": [
"issue",
"transition"
],
"title": "PlanTransitionInput",
"type": "object"
}output · PlanTransitionResult
from and to are both derived statuses, read before and after the write. They are
reported rather than assumed because the move does not determine the status on its own:
done on a record whose evidence is incomplete leaves it in VERIFY, and the caller is
told that rather than told it succeeded.
use majordomus_cli::capability::builtin::plan::PlanTransitionResult;
// What a `done` looks like when the record's required evidence is not all present: the
// call succeeded, the field was written, and the issue is still in VERIFY. A caller that
// read only the absence of an error would report this as finished; a caller that reads
// `to` knows better, which is why the field is here.
let held: PlanTransitionResult = serde_json::from_str(r#"{
"issue":"I0001","transition":"done","from":"VERIFY","to":"VERIFY",
"field":"completed_at","at":"2026-09-11T12:00:00Z","event":"plan_done"
}"#).unwrap();
assert_eq!(held.to, "VERIFY", "the status is derived from the record, not from the move");
// `next_ready` is absent when the plan has none left to hand out, which is ordinary
// rather than an error, so it is omitted rather than spelled as an empty string.
assert!(held.next_ready.is_none());| property | type | required | description |
|---|---|---|---|
| at | string | yes | The timestamp written into it, and into updated_at. |
| event | string | yes | The ledger event appended. |
| field | string | yes | The field that was stamped. |
| from | string | yes | The status the issue was in before. |
| issue | string | yes | The issue that moved. |
| next_ready | string | null | no | The issue a worker should take next, derived after the move. Absent when the plan
has none — which after a start is the ordinary case, because the issue just taken
is no longer READY. |
| to | string | yes | The status it is in now, derived from the record as written. |
| transition | reference | yes | The move that was performed. |
JSON Schema
{
"$defs": {
"PlanTransition": {
"description": "One lifecycle move.\n\nThree, not four: `evidence` appends a record rather than moving an issue, and it belongs\nwith the record it proves rather than with the moves it unblocks.\n\n```\nuse majordomus_cli::plan::Transition;\n\n// The wire spelling is the one a caller types on the command line.\nlet t: Transition = serde_json::from_str(\"\\\"done\\\"\").unwrap();\nassert_eq!(t, Transition::Done);\nassert_eq!(serde_json::to_string(&Transition::Start).unwrap(), \"\\\"start\\\"\");\n\n// Each move owns the field it stamps and the event it records; nothing else decides\n// either, so a new move cannot be added without saying both.\nfor (t, field, event) in [\n (Transition::Start, \"started_at\", \"plan_start\"),\n (Transition::Verify, \"verified_at\", \"plan_verify\"),\n (Transition::Done, \"completed_at\", \"plan_done\"),\n] {\n assert_eq!(t.field(), field);\n assert_eq!(t.event(), event);\n}\n```",
"oneOf": [
{
"const": "start",
"description": "Execution began. Refused unless the issue is READY.",
"type": "string"
},
{
"const": "verify",
"description": "Implementation is complete and the evidence is still outstanding.",
"type": "string"
},
{
"const": "done",
"description": "Complete. Refused while anything it waits on is unfinished, or while any evidence\nits own record requires is absent.",
"type": "string"
}
]
}
},
"description": "What one transition did.\n\n`from` and `to` are both derived statuses, read before and after the write. They are\nreported rather than assumed because the move does not determine the status on its own:\n`done` on a record whose evidence is incomplete leaves it in VERIFY, and the caller is\ntold that rather than told it succeeded.\n\n```\nuse majordomus_cli::capability::builtin::plan::PlanTransitionResult;\n\n// What a `done` looks like when the record's required evidence is not all present: the\n// call succeeded, the field was written, and the issue is still in VERIFY. A caller that\n// read only the absence of an error would report this as finished; a caller that reads\n// `to` knows better, which is why the field is here.\nlet held: PlanTransitionResult = serde_json::from_str(r#\"{\n \"issue\":\"I0001\",\"transition\":\"done\",\"from\":\"VERIFY\",\"to\":\"VERIFY\",\n \"field\":\"completed_at\",\"at\":\"2026-09-11T12:00:00Z\",\"event\":\"plan_done\"\n}\"#).unwrap();\nassert_eq!(held.to, \"VERIFY\", \"the status is derived from the record, not from the move\");\n\n// `next_ready` is absent when the plan has none left to hand out, which is ordinary\n// rather than an error, so it is omitted rather than spelled as an empty string.\nassert!(held.next_ready.is_none());\n```",
"properties": {
"at": {
"description": "The timestamp written into it, and into `updated_at`.",
"type": "string"
},
"event": {
"description": "The ledger event appended.",
"type": "string"
},
"field": {
"description": "The field that was stamped.",
"type": "string"
},
"from": {
"description": "The status the issue was in before.",
"type": "string"
},
"issue": {
"description": "The issue that moved.",
"type": "string"
},
"next_ready": {
"description": "The issue a worker should take next, derived after the move. Absent when the plan\nhas none — which after a `start` is the ordinary case, because the issue just taken\nis no longer READY.",
"type": [
"string",
"null"
]
},
"to": {
"description": "The status it is in now, derived from the record as written.",
"type": "string"
},
"transition": {
"$ref": "#/$defs/PlanTransition",
"description": "The move that was performed."
}
},
"required": [
"issue",
"transition",
"from",
"to",
"field",
"at",
"event"
],
"title": "PlanTransitionResult",
"type": "object"
}Policies
- benchmark
- required_when — a target on every transport the exposure declares; the cases are the input type's
- cache
- disabled — every call runs the handler
Benchmark targets
Derived from the registry for this repository: one requirement per transport, and the cases the input type provides. The whole matrix is on the benchmarks page.
| transport | state | cases | targets |
|---|---|---|---|
| direct | covered | 1 | refused-by-status |
| mcp | covered | 1 | refused-by-status |
| http | covered | 1 | refused-by-status |