Skip to content

Executions

the execution plane: running a capability as work that can be watched — the model, the lifecycle, the typed event protocol, the live channel and its guarantees, what each interface offers, the limits and what happens at each, and what it deliberately does not do

Rendered from docs/EXECUTIONS.md — the same Markdown GitHub shows.

How the Rust executable under apps/majordomus-cli/ runs a capability as work that can be watched: the model, the lifecycle, the typed event protocol, the live channel, what each interface offers, and what it deliberately does not do. Behaviour as implemented and tested; where this document and the executable disagree, the document is wrong and changes in the same commit. The decision is ADR 22; the rules are project.execution-events-are-typed and project.executions-carry-no-secret; the registry it runs on is CAPABILITIES.md.

ONE CANONICAL DECLARATION   capability! { … handler: verify }.cancellable()

CAPABILITY REGISTRY         the same registry MCP, HTTP, OpenAPI, the CLI and the Cockpit read

EXECUTION ENGINE            validate · create · queue · run on a worker · catch · complete

EXECUTION STORE             the snapshot, the retained events, the subscribers — all bounded

DERIVED PROJECTIONS         the live channel (WebSocket) · executions.get · executions.events
                            · the Cockpit's Executions pages · `majordomus run` · MCP tools

There is no action registry, no action definition and no second implementation of anything. An execution is a capability call this process gave an identity to.

The model

Executionone call of one capability, with an id, a lifecycle, an input as it was stored, an output or an error, its steps, its progress, its diagnostics, who asked for it and which repository it ran against
Execution eventone typed fact about an execution, in an envelope with a protocol version and a dense sequence number
Execution policywhat running a capability means: its effect and whether two may overlap, classified from the kind; and whether cancelling it achieves anything, which only its handler can say
Progressthe handle every context carries; silent outside an execution, so a handler is written once for every caller

The lifecycle

  queued ──► running ──┬──► succeeded
     │                 ├──► failed
     │                 └──► cancelling ──┬──► cancelled
     │                                   ├──► succeeded   (it finished before it noticed)
     └──► cancelled                      └──► failed

ExecutionState::may_move_to is the whole contract and the store refuses anything else, so a client that reads a final state never sees it move again. There is no starting: an in-process engine claims an execution and enters its handler in the same instant, and a state nothing can reach is a state every client would have to handle and never see.

What is not here

An execution does not outlive the process that accepted it. There is no durable queue, no retry, no schedule and no execution that survives a restart. Every limit is typed on execution::store::Limits — how many executions are remembered, how many events each, how long a log line may be, how many run at once, how far a subscriber may fall behind — and each has a defined behaviour at the bound rather than an unwritten one. Nothing above the store names a Mutex, so a durable store is a substitution rather than a rewrite.

Running one

Any executable capability of the registry can be started as an execution. There is no second list of what may be run: capabilities.list already answers it, and the descriptor's own execution policy says what running it means.

majordomus run health.report                       # start it here and follow it to the end
majordomus run objects.verify --input '{"limit":50}' --format json
majordomus executions list                          # what the server serving this repository ran
majordomus executions show   <id>
majordomus executions events <id> --after 42
majordomus executions cancel <id>
majordomus executions protocol                      # the live channel's contract
POST /api/v1/executions/start        { "capability": "objects.verify", "input": { … } }
GET  /api/v1/executions              ?state=running&capability=objects.verify&limit=50
GET  /api/v1/executions/get          ?id=x-…
GET  /api/v1/executions/events       ?id=x-…&after=42&limit=500
POST /api/v1/executions/cancel       { "id": "x-…" }
GET  /api/v1/executions/protocol
GET  /api/v1/executions/demonstrate  ?steps=3&delay_ms=200
GET  /api/v1/objects/verify          ?kind=rule&limit=50

Over MCP the same capabilities are the tools majordomus_execution_start, majordomus_executions, majordomus_execution, majordomus_execution_events, majordomus_execution_cancel and majordomus_execution_protocol, with majordomus://executions and majordomus://executions/protocol as resources. An agent therefore discovers, starts, follows and reads an execution without parsing anything a person was meant to look at.

start answers at once with the execution and the links to follow it; nothing waits for the handler. The state in that first answer is queued, which is what it is: this server answers 200 with the truth rather than 202 with a promise.

The event protocol

One envelope, a closed set of payloads, a version. type and data are the only two members that vary, so a client switches on one string.

{
  "schema_version": "1",
  "event_id": "x-20260908T010203Z-0a1b2c3d#17",
  "execution_id": "x-20260908T010203Z-0a1b2c3d",
  "sequence": 17,
  "timestamp": "2026-09-08T01:02:07Z",
  "type": "execution.progress",
  "data": { "current": 17, "total": 42, "message": "137 file(s) read" }
}
typewhen
execution.createdit exists; carries the capability and the input as it was stored
execution.queuedit is waiting for a worker, and how many are ahead of it
execution.startedthe handler was entered
execution.progresshow far along, with a total when the handler knows one
execution.step.started, execution.step.completeda named phase was entered, and how it went
execution.loga line of output, from the handler or from a process it ran
execution.diagnostica structured finding, distinct from the outcome
execution.cancelling, execution.cancelledit was asked to stop; it stopped
execution.completedit finished, with what the handler returned
execution.failedit finished, with a typed error and a correlation id

The output travels on execution.completed rather than on an event of its own, so no client can read "succeeded" and find no result. sequence is dense and starts at 1 within an execution: a client that has seen n knows it missed something when the next it reads is not n + 1, and asks for the gap by cursor rather than reloading the world.

The schema is derived from the Rust enum by schemars, exactly as every capability's input and output schemas are, and is served — with the whole contract — by executions.protocol. Nothing is written down twice.

A client must ignore a type it does not know. That is what lets a variant be added without a version change; schema_version moves only for a change a version-1 client could not ignore safely.

The live channel

GET /events                                  every execution of this process, live
GET /events?execution=<id>                   one execution: its retained history, then live
GET /events?execution=<id>&after=<sequence>   the same, from a cursor — what a reconnect sends

A native WebSocket (RFC 6455), upgraded on the same socket the rest of the surface is served on: one process, one port, one startup path, no second daemon and no Socket.IO. The subscription is the request target, so there is nothing to send and nothing to acknowledge.

The server writes one JSON document per text frame: either an execution event, or one of three control messages — stream.ready (the first frame: what was subscribed to, how much was replayed, whether the history before it had already been dropped, the heartbeat interval), stream.lagged (this client fell behind and events were dropped rather than buffered; read the snapshot and the history again from the last sequence seen) and stream.closing.

Order and gaps. A connection subscribes first and replays second, and skips in the live stream anything the replay already wrote. There is therefore no gap between the history and the stream, and nothing is written twice.

Heartbeat and disconnection. The server pings a quiet connection every twenty seconds and never reads: tiny_http hands over one Read + Write value on upgrade that cannot be split, so a reader thread would hold the lock the writer needs. A client that goes away is noticed when the next write fails, which the heartbeat guarantees happens; what a client sends is ignored. A close is therefore observed within one heartbeat rather than instantly.

Backpressure. A subscriber that falls behind its queue is never waited for: events are dropped for that client alone, it is told stream.lagged, and the execution is not slowed by anyone watching it. A subscriber whose reader has gone is forgotten at the next event.

Refusals. A plain GET /events answers 426 and says where the same events are readable over HTTP. A request from another origin is refused 403 before the handshake — a page elsewhere can open a WebSocket to a loopback server, and the same-origin policy does not stop it. An execution parameter that is not an execution id is 400, refused by shape before anything is looked up.

In the Cockpit

/cockpit/executions lists what this process has run, with the counts beside it; /cockpit/executions/<id> is one execution, at a stable URL a person can reload, share locally or open several of in tabs.

Both pages are complete HTML before any script runs — the state, the steps, the progress, the diagnostics, the log, the output and the input are what the server rendered — and the scripts add that they stop being a photograph. socket.js is the only place in the browser that opens a socket: it reconnects with exponential backoff, a cap and jitter, resubscribes from the sequence the page was rendered at, and shows the connection as a word and a shape rather than as a colour alone. Every URL it uses was rendered into the markup by the server; no path is written down in the browser.

On a capability's own page, Run as an execution sits beside the direct runner. Both use the same generated form, and the button asks for confirmation when — and only when — the descriptor's effect says something changes. What a page does with an event it does not recognise is show it, not drop it.

A log line reaches the page through textContent, never innerHTML, and the server has already removed control characters and terminal escape sequences from it. The log scrolls inside its own box and is bounded in the browser as well as on the server, so a long path in a line wraps rather than widening the page.

Adding a capability that can be watched

Nothing extra. Write the capability! block as CAPABILITIES.md describes, and it can be started as an execution the moment it exists. Two optional things make it a better one:

  1. Report. ctx.progress carries step, step_done, progress, log, stream and diagnostic. All are free to call outside an execution, so a handler reports unconditionally and is written once for every caller.
  2. Declare it cancellable.cancellable() after the capability! block — when the handler checks ctx.progress.cancelled() and stops. The Cockpit then offers a Cancel button that does something, executions.cancel says so in its answer, and a handler that ignores its flag never produces a control that lies.
fn verify(ctx: &Context, input: VerifyInput) -> Result<Report, CapabilityError> {
    let p = &ctx.progress;
    p.step("read", "Reading the layer");
    for (n, file) in files.iter().enumerate() {
        if p.cancelled() {
            return Err(p.cancellation());
        }
        p.progress(n as u64 + 1, Some(total), format!("read {}", file.path));
    }
    p.step_done("read", true, None);
    Ok(report)
}

A cached capability run as an execution runs. The cache makes one answer indistinguishable from another, which is right for an answer and wrong for a record of work: an execution answered from a cache would report no steps and claim to have succeeded at something that did not happen. Context::execute_observed is the path the engine takes, and it steps over the cache in both directions.

Safety

Only registered capabilities run. The plane takes a capability id and an input; there is no endpoint that takes a command, and the input a client may send is two fields whose schema is in the OpenAPI document. A capability that one day runs a process would be a capability! block with an allow-list of its own, reviewed like any other.

Repository isolation. A process serves the repository it read at start-up, and every execution is stamped with it. Nothing can run a capability against another checkout, and nothing a client sends names a directory.

Secrets. A value the input schema marks sensitive — format: "password", writeOnly, or x-majordomus-sensitive — is replaced once, in the engine, before the input is stored, so no snapshot, event, history, page, listing or generated document can carry it. A field's name never decides this: password_policy may be a document, and a name-matching redactor destroys it while missing the next field spelled differently.

Failures. A panicking handler is caught: the execution fails with internal, the correlation id and a suggestion, and the panic itself goes to this process's log where the operator can read it. No backtrace and no path inside this crate reaches a client.

Origin. The live channel and every state-changing route refuse a request from another origin. The Cockpit's content-security policy allows connections to this origin only.

Limits, and what happens at each

limitdefaultat the bound
executions remembered200the oldest finished one is forgotten; a running one never is
events retained per execution2000the oldest are dropped, and the snapshot and every history page say truncated
log line2000 characterscut, with a marker
executions running at once4further ones wait queued, and execution.queued says how many are ahead
a subscriber's queue512events are dropped for that client, which is told stream.lagged
live channels64a further connection is refused 503 with the reason
a serial capability (a command)one at a timea second execution of it waits for the first

The defaults are for a development server a person is watching, and executions.protocol answers the ones this process is running with.

When something fails

messagemeaningremedy
no capability 'x'; the executable ones are listed by capabilities.listthe id is not in the registrycheck majordomus capabilities list
not executable: 'x' is a resource: it is read, never executeda declarative object was namedread it with objects.get
invalid input: /steps: "many" is not of type "integer"the input does not satisfy the capability's own schemathe schema is in the OpenAPI document and on the capability's Cockpit page
no execution x-… in this processit never ran here, or it has been forgottenan execution lives in the process that accepted it; executions.list says what is remembered
426 … /events is a WebSocketit was opened with a plain GETopen it with a WebSocket client, or read executions.events
403 … a WebSocket from origin '…' is refuseda page on another origin tried to follow this serveropen the Cockpit from this server's own address
stream.laggedthis client could not keep upread the snapshot and the history again from the last sequence seen
an execution that reports no stepsthe capability does not report anythat is a fact about the capability, not about the plane; executions.demonstrate shows what one that does looks like

Stability

status
the state machine, its transitions and the refusal of an invalid onebehaviourally verified (execution::model, execution::store)
the store's bounds and the behaviour at each: eviction, truncation, lag, a gone subscriberbehaviourally verified (execution::store)
redaction from the schema, through references, arrays and mapsbehaviourally verified (execution::redact)
the handshake, the framing and SHA-1 against the published vectorsbehaviourally verified (http::ws)
start, follow, reload, reconnect with a cursor, cancel, fail — over a real socket with a real WebSocket clientbehaviourally verified (tests/executions.rs)
an existing capability observable with no second implementationbehaviourally verified (tests/executions.rs)
an execution never answered from the cachebehaviourally verified (tests/executions.rs)
the whole path from outside the executablebehaviourally verified (test/cases/100_execution_plane.sh)
durability, retry, scheduling, an execution outliving its processnot implemented, and Limits is where a durable store would be substituted
a capability backed by a process, with its output as stdout/stderr log eventsnot implemented; the event model carries the streams for it
reading the client's frames, and therefore an immediate closenot implemented; the reason is in http::ws