Skip to content

HTTP API: gates

Completion as a state the repository decides rather than a claim a worker makes: the validation gates this repository declares in its CI model, which of them a task's own change set selects, what each one last reported, whether that verdict still describes the tree it was taken over, and therefore whether the task may be called finished. A gate that has never reported is `queued` and not `pass`, a run whose files have changed since is `stale` and refuses exactly as a failure does, and a gate the change cannot affect is `exempt` rather than unknown.

Rendered from docs/generated/openapi.json, which the executable generates from its capability registry and CI refuses stale. The raw document is at /openapi.json; a running server serves the same document at its own /openapi.json and Swagger UI at /swagger.

gates

Completion as a state the repository decides rather than a claim a worker makes: the validation gates this repository declares in its CI model, which of them a task's own change set selects, what each one last reported, whether that verdict still describes the tree it was taken over, and therefore whether the task may be called finished. A gate that has never reported is `queued` and not `pass`, a run whose files have changed since is `stale` and refuses exactly as a failure does, and a gate the change cannot affect is `exempt` rather than unknown.

GET /api/v1/gates gates.model

The CI model

Every validation gate this repository declares — the job that runs it, the command it runs, what it proves, whether every plan selects it and whether its runner can be had on demand at all — with the path classes that decide which change selects which gate, and, derived from those classes, the pathspecs each gate's evidence is taken over.

  • query
  • behaviorally_verified
  • benchmark required
  • cache process (max_entries=4, ttl_seconds=5)
  • MCP tool majordomus_gates
  • MCP resource {"name": "gates", "uri": "majordomus://gates"}

No parameters.

Try it

The port is whatever the server logged; 8741 is the one it asks for first. The values are the operation's first example.

curl -s "http://127.0.0.1:8741/api/v1/gates"

Tested by apps/majordomus-cli/tests/http_serve.rs, which replays the route with these values against a server on a real socket.

Responses

statusbodymeaning
200 GateModelReport The CI model: the result.
400 ErrorBody invalid_input: The input does not fit the schema: a parameter of the wrong type, an unknown one, or a value the capability rejects.
404 ErrorBody not_found: The input names something the repository does not hold.
500 ErrorBody internal: The capability failed for a reason of its own; the message names it.
default ErrorBody Any other failure the transport reports: 405 method_not_allowed for another method on this path, 400 invalid_input for a body that is not a JSON object.
GET /api/v1/gates/completion gates.completion

Whether this task may be called finished

The active task's own change set — from the commit it started at to the working tree, uncommitted files included — put through the CI model: which gates it selects and why, what each one last reported and over which files, which verdicts have gone stale because the tree moved underneath them, which required gates have never reported at all, and which obligations the change implies whether or not the task declared them. `finishable` is false only when a required gate is known to be failing, stale or blocked by one that is; absence of a verdict is reported as unverified rather than silently accepted.

  • query
  • behaviorally_verified
  • benchmark required
  • cache disabled
  • MCP tool majordomus_completion
  • MCP resource {"name": "completion", "uri": "majordomus://gates/completion"}

Query parameters

nametyperequireddescriptionexamples
changed array of string no The changed paths, repository-relative. Absent means the task's own change set: everything between the commit the task started at and the working tree. a-document: ["docs/CLI.md"]everything-on-demand: [".ai/repo/ci/gates.yaml"]one-source-file: ["apps/majordomus-cli/src/lib.rs"]
base string no The commit to compare with, when the task's own starting commit is not wanted.
on_demand boolean no Also plan the gates the model marks on-demand. Off by default, because a plan that selects a gate whose runner is unavailable is a plan whose verdict never arrives. everything-on-demand: true

Try it

The port is whatever the server logged; 8741 is the one it asks for first. The values are the operation's first example.

curl -s "http://127.0.0.1:8741/api/v1/gates/completion?changed=%5B%22docs%2FCLI.md%22%5D&on_demand=true"

Tested by apps/majordomus-cli/tests/http_serve.rs, which replays the route with these values against a server on a real socket.

Responses

statusbodymeaning
200 Completion Whether this task may be called finished: the result.
400 ErrorBody invalid_input: The input does not fit the schema: a parameter of the wrong type, an unknown one, or a value the capability rejects.
404 ErrorBody not_found: The input names something the repository does not hold.
500 ErrorBody internal: The capability failed for a reason of its own; the message names it.
default ErrorBody Any other failure the transport reports: 405 method_not_allowed for another method on this path, 400 invalid_input for a body that is not a JSON object.

Schemas

The components only this tag's operations use. Each row is the type's own field, its doc comment the description; a type another page owns links there.

Completion

object
use majordomus_cli::gates::Completion;

// the document every surface reads, as `gates.completion` answers with it. The property
// that matters is the one asserted here: a gate that never reported leaves the task
// finishable and *unverified*, which is a different thing from a task that passed.
let c: Completion = serde_json::from_value(serde_json::json!({
    "present": true,
    "model": ".ai/repo/ci/gates.yaml",
    "plan": { "mode": "affected", "reason": "1 path in 1 class", "changed": ["docs/CLI.md"],
              "classes": [], "unclassified": [], "selected": {}, "excluded": {} },
    "finishable": true,
    "unverified": ["reference-check"],
    "tallies": { "queued": 1 },
    "gates": [],
    "obligations": [],
    "questions": []
}))
.unwrap();
assert!(c.finishable, "absence of a verdict does not refuse");
assert_eq!(c.unverified, ["reference-check"], "and it is never silently accepted");
assert!(c.blocking.is_empty());

Whether the active task may be called finished, and everything the answer rests on.

finishable is the field a caller acts on and the one thing no surface may recompute: the shell tool's check and finish read it, the HTTP API serves it, the Cockpit renders it, and all of them are reading this one execution.

propertytyperequireddescription
present boolean yes True when a task record was found in this checkout.
task ActiveTask | null → ActiveTask no The task, as its record declares it.
model string yes The CI model this was judged against.
plan GatePlan → GatePlan yes Affected or full, and why.
finishable boolean yes False when a required gate is known to be failing, stale, or blocked by one that is. Absence of a verdict does not make this false — it makes unverified non-empty.
blocking array of string no The required gates whose status refuses completion.
unverified array of string no The required gates that have never reported, or cannot be judged here. A task finished over these is finished unverified, and the record says so.
tallies map of integer (uint) yes How many gates stand in each status, by word.
gates array of Gate → Gate yes Every gate of the model, what refuses first.
obligations array of ImpliedObligation → ImpliedObligation yes Which obligations the change set implies, and whether the task promised them. The judgement of a declared one is obligations.closure's, not this module's.
questions array of DoneQuestion → DoneQuestion yes The done invariant: the questions a task must answer before anybody may call it finished, each with the source that answered it and the evidence it read. Composed from the obligation closure, the gates, the change set, the repository's convergence and the closing readings; a question nothing here reaches is unknown and names the command that would answer it.
findings array of string no What could not be established, each as one line. Never empty when something was skipped: a gap is reported rather than left to be inferred.

Used by gates.completion

DoneQuestion

object
use majordomus_cli::gates::{DoneQuestion, GateStatus};

let q = DoneQuestion {
    id: "pushed".into(),
    question: "Has the commit reached the remote?".into(),
    status: GateStatus::Queued,
    evidence: "the obligation is owed".into(),
    source: "obligation push (obligations.closure)".into(),
    remediation: "git push".into(),
};
// every question names what answered it and what would settle it: a question with
// neither is a checklist line, which is the artifact this type exists instead of
assert!(!q.source.is_empty() && !q.remediation.is_empty());
assert!(q.status.unverified());

One question of the done invariant, and what answered it.

propertytyperequireddescription
id string yes The question's identity, stable across reports.
question string yes The question, as a person would ask it.
status GateStatus → GateStatus yes The answer, in the same vocabulary every gate uses.
evidence string yes What was actually read — never a restatement of the status.
source string yes What answered it: an obligation token, a gate id, or the capability that would.
remediation string yes What would settle it, as a command.

Used by Completion

Gate

object
use majordomus_cli::gates::{Gate, GateStatus};

let gate = Gate {
    id: "rust-check".into(),
    title: "the crate builds, is formatted and passes its tests".into(),
    scope: "apps/majordomus-cli".into(),
    required: true,
    status: GateStatus::Queued,
    reason: "the plan selects it and no run has ever reported".into(),
    remediation: "majordomus evidence --gate rust-check --exit 0".into(),
    source: "the plan".into(),
    evidence: None,
    inputs: vec!["apps/majordomus-cli/**".into()],
    inputs_hash: "3f0a".into(),
    evaluated_at: "2026-01-01T00:00:00Z".into(),
};
// silence does not refuse and is not a pass either: it is a debt, reported as one
assert!(!gate.status.refuses());
assert!(gate.status.unverified());
assert!(gate.evidence.is_none());

One completion gate: what it is, whether it applies, what it said, and what to do.

propertytyperequireddescription
id string yes The gate's identity in the model.
title string yes One line naming it, from the model's own summary.
scope string yes What it is a gate over: the CI job that runs it.
required boolean yes True when the plan selects it and its verdict therefore decides completion. False for a gate the plan left out, and for one whose runner is only had on demand.
status GateStatus → GateStatus yes What is known about it.
reason string yes Why it is in this status, in words a worker can act on.
remediation string yes What would settle it, as a command.
source string yes Where the gate is declared: the model, and the command it runs.
evidence GateRun | null → GateRun no The run that last reported, when one has.
inputs array of string no The pathspecs its evidence is taken over, derived from the model.
inputs_hash string no The hash of those files as they stand now, when it could be taken.
evaluated_at string yes When this judgement was made, RFC 3339 UTC. A judgement is about a moment.

Used by Completion

GateClass

object
use majordomus_cli::gates::GateClass;

// one path class as the CI model declares it: which paths select which gates
let class: GateClass = serde_json::from_value(serde_json::json!({
    "id": "docs",
    "paths": ["docs/**"],
    "gates": ["reference-check"]
}))
.unwrap();
assert_eq!(class.id, "docs");
assert_eq!(class.paths, ["docs/**"]);

One path class: the paths it covers and the gates a change to them selects.

propertytyperequireddescription
id string yes The class's identity.
paths array of string no The pathspecs it covers, in the subset the model's header defines.
except array of string no Single paths its patterns cover and the class does not: a data file under an escalating directory, read by named gates only. Each one is a path, never a pattern, so a file that appears under the directory tomorrow still falls in it.
gates GateClassSelects | null → GateClassSelects no full, or the gates it names.

Used by GateModelReport

GateClassMatch

object
use majordomus_cli::gates::GateClassMatch;

let hit = GateClassMatch { id: "docs".into(), paths: vec!["docs/CLI.md".into()] };
// the class, and the files of the change that put it in the plan — never the class alone,
// because a plan a reader cannot check is a plan a reader has to believe
assert_eq!(hit.paths.len(), 1);

One class a change fell in, and the paths that put it there.

propertytyperequireddescription
id string yes The class's identity.
paths array of string yes The changed paths this class covers.

Used by GatePlan

GateClassSelects

string | array of string
What a class selects: the word full, or the gates it names.

Used by GateClass

GateModelEntry

object
use majordomus_cli::capability::builtin::gates::GateModelEntry;
use majordomus_cli::gates::GateDecl;

let decl: GateDecl = serde_json::from_value(serde_json::json!({
    "id": "site-check",
    "job": "site",
    "runs": "scripts/site-check",
    "summary": "the site is whole"
}))
.unwrap();
let entry = GateModelEntry { declared: decl, inputs: vec!["site/**".into()] };
// the declaration as the file carries it, plus the paths its evidence is taken over —
// derived from the classes that select it, never written down a second time
assert_eq!(entry.declared.id, "site-check");
assert_eq!(entry.inputs, ["site/**"]);

One gate of the model, with what the model implies about it.

propertytyperequireddescription
id string yes The gate's identity, unique in the model.
job string yes The CI job that runs it.
runs string yes The command it runs.
summary string no One line saying what it proves.
always boolean no True when every plan selects it, whatever changed.
on-demand boolean no True when only a plan that asks for it selects it: its runner cannot be had on demand, so planning it on every push is planning a verdict that never arrives.
at-finish boolean no True when the gate measures the deployment rather than the tree, so no change to any file can select it and the path classes can never reach it. finish asks it instead: the question "is what the public is being served still a projection of the trunk" has no answer a commit can give, and its moment is when a session claims to be done.
implies array of string no Gates that are selected whenever this one is.
requires array of string no Gates this one cannot run without.
inputs array of string yes The pathspecs a run of this gate is evidence over, derived from the classes that select it. A change to any of them makes a recorded run stale.

Used by GateModelReport

GateModelReport

object
The CI model this repository declares: what may refuse a change, and what selects each.

Two things about this report are worth knowing before reading one. count is measured from gates rather than written down, so the two can never disagree the way a hand-maintained total does. And unreadable is how a reader tells "this repository declares no CI model" from "the call failed": a repository of the layer is not obliged to declare gates, so the empty model is an answer, and it says why it is empty.

use majordomus_cli::capability::builtin::gates::GateModelReport;
use serde_json::json;

// the shape every surface serves: `unreadable` is skipped when the model was read
let r: GateModelReport = serde_json::from_value(json!({
    "version": 1,
    "source": ".ai/repo/ci/gates.yaml",
    "count": 0,
    "on_demand": [],
    "gates": [],
    "classes": [],
})).unwrap();
assert_eq!(r.count, r.gates.len(), "the total is measured, never declared");
assert!(r.unreadable.is_none(), "read, and it declares nothing");

// and the other empty answer, which a reader must not confuse with that one
let absent: GateModelReport = serde_json::from_value(json!({
    "version": 0,
    "source": ".ai/repo/ci/gates.yaml",
    "count": 0,
    "on_demand": [],
    "gates": [],
    "classes": [],
    "unreadable": "no such file",
})).unwrap();
assert_eq!(absent.unreadable.as_deref(), Some("no such file"));
propertytyperequireddescription
version integer (uint32) yes The schema version the file states.
source string yes Where it was read from, repository-relative.
count integer (uint) yes How many gates it declares; measured, not written down.
on_demand array of string yes The gates whose runner cannot be had on demand, so no routine plan selects them. A gate that never runs protects nothing, and this is where that is said out loud.
gates array of GateModelEntry → GateModelEntry yes Every gate, in declaration order, with the pathspecs its evidence is taken over.
classes array of GateClass → GateClass yes Every path class, in declaration order.
unreadable string | null no Why there is no model to report, when there is none. A repository of the layer does not have to declare a CI model, and a reader is entitled to the difference between "this repository declares no gates" and "this call failed".

Used by gates.model

GatePlan

object
use majordomus_cli::gates::{GateClassMatch, GatePlan, GatePlanMode};
use std::collections::BTreeMap;

let plan = GatePlan {
    mode: GatePlanMode::Affected,
    reason: "3 path(s) fall in 1 class".into(),
    changed: vec!["docs/CLI.md".into()],
    classes: vec![GateClassMatch { id: "docs".into(), paths: vec!["docs/CLI.md".into()] }],
    unclassified: Vec::new(),
    selected: BTreeMap::from([("reference-check".to_string(), "class docs".to_string())]),
    excluded: BTreeMap::new(),
};
// the plan says what it selected and why, so a reader can check it rather than believe it
assert_eq!(plan.selected["reference-check"], "class docs");
assert!(!plan.selected.contains_key("rust-check"), "a document change cannot break the crate");

Which gates a change must pass, and why each one is in or out.

propertytyperequireddescription
mode GatePlanMode → GatePlanMode yes Affected, or full.
reason string yes Why, in words.
changed array of string yes The changed paths the plan was computed over, sorted.
classes array of GateClassMatch → GateClassMatch yes The classes they fall in.
unclassified array of string yes Changed paths no class of the model covers; each one escalates the plan.
selected map of string yes Gate id to the reason it was selected, for the gates in the plan.
excluded map of string yes Gate id to the reason it was left out, for the gates that are not.

Used by Completion

GatePlanMode

"affected" | "full"
use majordomus_cli::gates::GatePlanMode;

// a plan is either the gates the change selects, or everything the runners allow
assert_eq!(GatePlanMode::Affected.as_str(), "affected");
assert_eq!(GatePlanMode::Full.as_str(), "full");
assert_ne!(GatePlanMode::Affected, GatePlanMode::Full);

Why the plan selects what it selects.

  • affectedThe gates of the classes the changed paths fall in.
  • fullEvery gate but the on-demand ones: asked for, or escalated to.

Used by GatePlan

GateRun

object
use majordomus_cli::gates::GateRun;

// what `majordomus evidence --gate` writes: a run, and the tree it was taken over
let run = GateRun {
    recorded_at: "2026-01-01T00:00:00Z".into(),
    head: "9b1e2d4".into(),
    branch: "master".into(),
    exit: 0,
    command: "scripts/rust-check --ci".into(),
    inputs_hash: "3f0a".into(),
    session: "s-1".into(),
};
// the hash is what makes a run go stale rather than merely old: the same run read
// against a different tree is evidence about a tree that no longer exists
assert_eq!(run.exit, 0);
assert_ne!(run.inputs_hash, "0000");

The task.gate ledger line that last reported a gate, as the ledger holds it.

propertytyperequireddescription
recorded_at string yes When it was recorded, RFC 3339 UTC.
head string yes The commit the ledger's envelope stamped on it.
branch string yes The branch it was recorded on.
exit integer (int64) yes The exit status the gate reported. 0 is a pass.
command string no The command that produced it — narrative is not evidence.
inputs_hash string no The hash of the files that select the gate, as they stood when it ran.
session string no The session that recorded it.

Used by Gate

GateStatus

"pass" | "fail" | "stale" | "blocked" | "queued" | "exempt" | "unknown"
use majordomus_cli::gates::GateStatus;

// the three answers a reader is entitled to, and they are different answers
assert!(GateStatus::Fail.refuses(), "a run that failed refuses");
assert!(GateStatus::Queued.unverified(), "asked and unanswered is a debt");
assert!(!GateStatus::Exempt.refuses() && !GateStatus::Exempt.unverified());

// and the one that is neither: a run whose tree has moved proves nothing about this one
assert!(GateStatus::Stale.refuses());
assert_eq!(GateStatus::Stale.as_str(), "stale");

What is known about one gate. Every word is one this repository's design tokens already carry (share/design/tokens.yaml, status.states), so a surface renders it without being taught a sixth vocabulary and the design gate does not refuse it.

  • passIt ran over these inputs and succeeded. The only word that discharges anything.
  • failIt ran over these inputs and failed. Completion is refused.
  • staleIt ran, and the files that select it have changed since; what it proved is about a tree that no longer exists. Completion is refused, because the alternative is treating a claim about the past as a claim about the present.
  • blockedSomething it cannot run without has not passed, so its own silence means nothing.
  • queuedThe plan selects it and no run has ever reported. Not unknown: the question was asked and no answer came, which is a debt somebody owes, and not pass, which is the mistake this word exists to make impossible.
  • exemptThe plan does not select it: nothing the task changed can make it true or false.
  • unknownIt cannot be judged here at all — no model, no git, a ledger that would not read. An honest gap, and never a pass.

Used by DoneQuestion Gate

ImpliedObligation

object
use majordomus_cli::gates::ImpliedObligation;

let owed = ImpliedObligation {
    id: "tests".into(),
    title: "The cases the change obliges were run".into(),
    applicable: true,
    declared: false,
    reason: "lib/check.sh is an input of this obligation".into(),
    because: vec!["lib/check.sh".into()],
    remediation: "majordomus start --requires tests".into(),
};
// applicable and undeclared is the gap nothing else reports: the change owes it and
// the task promised nothing, so no validator holds anybody to it
assert!(owed.applicable && !owed.declared);

One obligation the change set implies, and whether the task promised it.

Applicability is derived and nothing else: a token's own inputs in share/obligations.yaml name the files it is about, so a change to one of them is what makes the token owed. A token with no inputs names a fact outside the tree — a push, a publication, a deployment — which no change to a tree can imply, so those are owed when the task declares them and reported as not applicable when it does not.

propertytyperequireddescription
id string yes The token.
title string yes The obligation as a heading, from the vocabulary.
applicable boolean yes True when the change set, or the task's own declaration, makes it owed.
declared boolean yes True when the task's requires names it, so finish already holds the task to it.
reason string yes Why it applies, or why it does not.
because array of string no The changed paths that implied it, when paths did.
remediation string yes What would discharge it.

Used by Completion

Searches the titles and descriptions of every page this site publishes.