Skip to content

HTTP API: recover

The stray files a killed publish, an interrupted rename or an unfinished site build leave in the record stores, classified by reading the clock and the content, and swept exactly once. This is the capability that backs `majordomus recover` (ADR 0040): the command no longer decides what a stray is, it asks.

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.

recover

The stray files a killed publish, an interrupted rename or an unfinished site build leave in the record stores, classified by reading the clock and the content, and swept exactly once. This is the capability that backs `majordomus recover` (ADR 0040): the command no longer decides what a stray is, it asks.

POST /api/v1/recover/orphans recover.orphans

Classify every stray file of the record stores, and sweep the ones that are nobody's

Walk the publish temps of every record store, the rename temps under the layer's local half and the tracked sessions section, and the site generator's staging directories at the repository root; give each a verdict by reading its age and then its content; and, unless the call is a check, remove the ones the verdict says nothing is holding. Nothing is deleted for being unrecognised: a temp holding the only copy of a record is reported as `incomplete` and left for the caller to publish, content this version cannot classify is `foreign` and left exactly where it is, a candidate whose age this platform cannot read is `unmeasurable` and never a candidate, and no directory is ever removed. The threshold is required and has no default, because a sweep measuring against an absent threshold would find every file stale.

  • command
  • behaviorally_verified
  • benchmark required
  • cache disabled
  • MCP tool majordomus_recover_orphans

Request body — RecoverOrphansInput, JSON

example check-one-second

{"older_than_seconds":1,"check":true}

example check-twelve-hours

{"older_than_seconds":43200,"check":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 -X POST "http://127.0.0.1:8741/api/v1/recover/orphans" -H "content-type: application/json" -d '{"older_than_seconds":1,"check":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 RecoverOrphansResult Classify every stray file of the record stores, and sweep the ones that are nobody's: 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.
422 ErrorBody refused: The command was understood and turned down for the reason the message gives.
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.

RecoverOrphansInput

object
The input of recover.orphans: how old a stray must be, and whether to act.

use majordomus_cli::capability::builtin::recover::RecoverOrphansInput;

// The wire form the shell tool, an MCP client or an HTTP POST sends.
let act: RecoverOrphansInput =
    serde_json::from_str(r#"{"older_than_seconds":43200,"check":false}"#).unwrap();
assert_eq!(act.older_than_seconds, 43_200);
assert!(!act.check);

// `check` defaults to true, and that direction is deliberate: this capability deletes
// files, and a caller that forgets the field gets a plan rather than a sweep.
let forgot: RecoverOrphansInput =
    serde_json::from_str(r#"{"older_than_seconds":43200}"#).unwrap();
assert!(forgot.check, "the safe value is the default one");

// The threshold has no default at all. `session.stranded_after` is a policy value, and
// a reader that carried its own copy would be a second place the number lives.
assert!(serde_json::from_str::<RecoverOrphansInput>(r#"{"check":true}"#).is_err());

// Unknown fields are refused rather than ignored: a caller that misspells `check` is
// told so instead of having a sweep silently run.
assert!(serde_json::from_str::<RecoverOrphansInput>(
    r#"{"older_than_seconds":1,"chek":true}"#).is_err());
propertytyperequireddescription
older_than_seconds integer (uint64) yes How long a stray file must have been untouched before it is a candidate at all, in seconds. Required, and refused at zero: the shell tool resolves it from session.stranded_after with mj_pol_req, which fails closed, and nothing here invents one.
check boolean no Classify and report, and write nothing. The default, because the alternative deletes files.

Used by recover.orphans

RecoverOrphansResult

object
What one sweep found and what it did about it.

The counts are derived from strays rather than accumulated beside it, so a reader can check the report against itself and a surface that renders the list cannot disagree with the surface that renders the total.

use majordomus_cli::capability::builtin::recover::RecoverOrphansResult;

let plan: RecoverOrphansResult = serde_json::from_str(r#"{
    "older_than_seconds": 43200, "check": true, "strays": [],
    "removable": 0, "removed": 0, "unmeasurable": 0
}"#).unwrap();
// a check never removes anything, whatever it found
assert_eq!(plan.removed, 0);
assert!(plan.check);
propertytyperequireddescription
older_than_seconds integer (uint64) yes The threshold this sweep measured against, echoed back so that a refusal or a verdict names the value the caller actually got rather than the one it believes it sent.
check boolean yes Whether this was a check. true means nothing was written.
strays array of Stray → Stray yes Every stray found, in path order.
removable integer (uint) yes How many of them this call would remove, or did.
removed integer (uint) yes How many it actually removed. Always 0 on a check.
unmeasurable integer (uint) yes How many candidates could not be measured and were therefore left alone.

Used by recover.orphans

Stray

object
One stray file, its verdict, and the evidence that produced it.

Every field that decided something is carried, because the rule this command is shaped around requires the value that decided a verdict to be visible before the verdict acts: a surface that printed "orphan" without the age, the episode and the file that already holds it would be asking to be trusted rather than read.

use majordomus_cli::capability::builtin::recover::{Stray, StrayKind, StrayVerdict};
use serde_json::json;

let s: Stray = serde_json::from_value(json!({
    "path": ".ai/repo/sessions/.tmp.a1b2c3",
    "kind": "publish_temp",
    "verdict": "orphan",
    "age_seconds": 90_000,
    "session_id": "s-0007",
    "published_at": ".ai/repo/sessions/2026-09-11--s-0007.md",
    "removable": true,
    "removed": false,
})).unwrap();
assert_eq!(s.verdict, StrayVerdict::Orphan);
assert_eq!(s.kind, StrayKind::PublishTemp);
// removable and removed are different facts: a check run reports the first and never
// the second, which is what makes `--check` readable as a plan rather than a report.
assert!(s.removable && !s.removed);

// and an unmeasurable one carries no age at all, rather than a zero that would read as
// "brand new" to anything that compared it with a threshold
let u: Stray = serde_json::from_value(json!({
    "path": ".mj-stage.xyz", "kind": "staging_dir", "verdict": "unmeasurable",
    "removable": false, "removed": false,
})).unwrap();
assert!(u.age_seconds.is_none());
propertytyperequireddescription
path string yes The path, repository-relative. Never the absolute one: a finding that names a disk is a finding nobody else can act on (project.no-machine-paths).
kind StrayKind → StrayKind yes Which of the three shapes this is.
verdict StrayVerdict → StrayVerdict yes What reading it decided.
age_seconds integer | null no How old it is, in seconds. Absent when the platform could not say, which is the Unmeasurable verdict and never a zero.
session_id string no The episode this publish temp carries, when it carries one.
published_at string no The record that already holds that episode, when one does. Its presence is what turns an Incomplete publish temp into an Orphan.
target string no The file a rename temp was going to become. Its presence is what turns an Incomplete rename temp into an Orphan.
removable boolean yes Whether this call would remove it. Decided by the verdict and by remove_stray's own pattern guard, never by the caller.
removed boolean yes Whether this call did remove it. Always false on a check.

Used by RecoverOrphansResult

StrayKind

"publish_temp" | "rename_temp" | "staging_dir"
What kind of stray a path is, decided by where it is and how it is named rather than by what is in it. The content decides the verdict; this decides which question to ask.

The word a kind is reported under is also the word it is read back from, and a word this version does not know is refused rather than read as the nearest kind:

use majordomus_cli::capability::builtin::recover::StrayKind;
for kind in [StrayKind::PublishTemp, StrayKind::RenameTemp, StrayKind::StagingDir] {
    let word = serde_json::to_value(kind).unwrap();
    assert_eq!(serde_json::from_value::<StrayKind>(word).unwrap(), kind);
}
assert!(serde_json::from_value::<StrayKind>(serde_json::json!("temp")).is_err());
  • publish_tempA .tmp.XXXXXX in a record store: mj_publish_record makes them, and only a process that died between the mktemp and the rm leaves one.
  • rename_tempA <target>.mj-tmp: written and renamed in one &&, so one that exists means the write failed or the process died mid-write.
  • staging_dirA .mj-stage.XXXXXX at the repository root: scripts/generate-site-data stages into one. Always a directory, and therefore never removed here.

Used by Stray

StrayVerdict

"live" | "orphan" | "incomplete" | "foreign" | "unmeasurable"
What this stray is, decided by reading it.

The five words are lib/recover.sh's, deliberately: a person who has read the command's output should not have to learn the same five facts twice because a second surface chose its own vocabulary.

That vocabulary is closed. Every verdict reads back as itself, and there is no sixth word: a surface that invents one gets an error rather than a verdict nobody meant.

use majordomus_cli::capability::builtin::recover::StrayVerdict::{self, *};
let all = [Live, Orphan, Incomplete, Foreign, Unmeasurable];
let words: Vec<&str> = all.iter().map(|v| v.as_str()).collect();
assert_eq!(words, ["live", "orphan", "incomplete", "foreign", "unmeasurable"]);
for v in all {
    let word = serde_json::json!(v.as_str());
    assert_eq!(serde_json::from_value::<StrayVerdict>(word).unwrap(), v);
}
assert!(serde_json::from_value::<StrayVerdict>(serde_json::json!("stale")).is_err());
  • liveYounger than the threshold: a session close or a scripts/derive running right now owns it. Measured, reported, never touched.
  • orphanIts work completed, or there was never anything in it. Removable — unless it is a directory, which this runtime never removes.
  • incompleteIt holds something no other file holds: a record nobody published, or a write whose target never arrived. Never removed here; the caller decides.
  • foreignNot written by this tool at all, or written by a version that shaped it differently. Never touched; reported with what it is.
  • unmeasurableIts age could not be read on this platform, so nothing here treats it as stale. The guard project.destructive-sweeps-fail-closed asks for: a predicate that cannot measure its input is false, never the most extreme value in the set.

Used by Stray

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