zeroclaw-labs/zeroclaw · error · anyhow::Error

candidate SOP did not validate as exactly one loadable SOP

Error message

candidate SOP did not validate as exactly one loadable SOP

What it means

ZeroClaw's procedural-memory pipeline validates every SOP candidate before it is persisted or applied: validate_candidate writes the proposed SOP.toml and SOP.md into a temp directory (under slugify(sop_name)) and loads it back through the real loader, load_sops_from_directory, in supervised mode. This error means the loader returned something other than exactly one SOP from that directory - almost always zero, because the manifest is invalid TOML or does not satisfy the loader's schema. It fires from create_proposal and apply_proposal.

Source

Thrown at crates/zeroclaw-runtime/src/sop/procedural_memory.rs:241

    Ok(proposal)
}

fn load_required(engine: &SopEngine, proposal_id: &str) -> Result<ProposalRecord> {
    engine
        .load_proposal(proposal_id)
        .map_err(anyhow::Error::new)?
        .ok_or_else(|| anyhow::Error::msg(format!("proposal not found: {proposal_id}")))
}

fn validate_candidate(sop_name: &str, manifest_toml: &str, procedure_markdown: &str) -> Result<()> {
    let tmp = tempfile::tempdir()?;
    let sop_dir = tmp.path().join(slugify(sop_name));
    fs::create_dir_all(&sop_dir)?;
    fs::write(sop_dir.join("SOP.toml"), manifest_toml)?;
    fs::write(sop_dir.join("SOP.md"), procedure_markdown)?;
    let sops = load_sops_from_directory(tmp.path(), super::parse_execution_mode("supervised"));
    if sops.len() != 1 {
        bail!("candidate SOP did not validate as exactly one loadable SOP");
    }
    if sops[0].name != sop_name {
        bail!(
            "candidate manifest name '{}' does not match proposal target '{}'",
            sops[0].name,
            sop_name
        );
    }
    if sops[0].steps.is_empty() {
        bail!("candidate SOP has no parsed steps");
    }
    Ok(())
}

fn scan_candidate(manifest_toml: &str, procedure_markdown: &str) -> Option<String> {
    let detector = LeakDetector::new();
    let content = format!("{manifest_toml}\n{procedure_markdown}");
    match detector.scan(&content) {

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Reproduce the failure directly: write the candidate SOP.toml/SOP.md into an empty temp dir and run load_sops_from_directory(tmp, parse_execution_mode("supervised")) - any load error points at the exact manifest problem
  2. Fix the manifest TOML: valid syntax and a [sop] table with the keys the loader requires (name, description, version), plus at least one [[triggers]] entry (see default_manifest_toml for the shape)
  3. If the manifest is auto-generated (draft.manifest_toml None/blank -> default_manifest_toml) and it still fails, check that slugify(sop_name) is non-empty so the loader actually discovers the directory
  4. After a ZeroClaw upgrade changed the SOP format, discard stale Pending proposals and re-run create_proposal instead of applying the old record

Example fix

// before
let manifest = r#"[sop]
title = "Deploy check"
"#;
create_proposal(&engine, ProposalDraft {
    sop_name: "deploy-check".into(),
    description: "Verify deploy".into(),
    manifest_toml: Some(manifest.into()),
    procedure_markdown: md,
    source_run_id: None,
    requested_by: None,
})?;

// after
let manifest = r#"[sop]
name = "deploy-check"
description = "Verify deploy"
version = "0.1.0"

[[triggers]]
type = "manual"
"#;
create_proposal(&engine, ProposalDraft { /* same fields with fixed manifest */ ..draft });
Defensive patterns

Strategy: validation

Validate before calling

// Pre-validate a candidate the same way the pipeline does, before persisting a proposal:
fn candidate_round_trips(sop_name: &str, manifest: &str, md: &str) -> bool {
    let tmp = tempfile::tempdir().expect("tmpdir");
    let dir = tmp.path().join(slugify(sop_name));
    std::fs::create_dir_all(&dir).unwrap();
    std::fs::write(dir.join("SOP.toml"), manifest).unwrap();
    std::fs::write(dir.join("SOP.md"), md).unwrap();
    let sops = load_sops_from_directory(tmp.path(), parse_execution_mode("supervised"));
    sops.len() == 1 && sops[0].name == sop_name && !sops[0].steps.is_empty()
}

Try / catch

match create_proposal(&engine, draft) {
    Ok(proposal) => { /* store proposal.id for review */ }
    Err(e) if e.to_string().contains("did not validate as exactly one loadable SOP") => {
        // manifest itself is unloadable: return it to the author for repair; do not blind-retry
    }
    Err(e) => return Err(e.context("sop proposal rejected")),
}

Prevention

When it happens

Trigger: Calling sop::procedural_memory::create_proposal(engine, draft) or apply_proposal(...) with a draft.manifest_toml (or a stored proposal's manifest) that fails to parse as TOML, is missing the [sop] table or required keys, so load_sops_from_directory returns 0 SOPs; or a directory layout that the loader expands into more than one SOP (sops.len() > 1).

Common situations: Hand-written SOP.toml with a typo (unbalanced quote, wrong key such as title instead of name), a manifest copied from an older ZeroClaw version whose loader schema changed, or a Pending proposal persisted before an upgrade and applied after the loader's requirements tightened.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/a1d7df90544e2a8d. Report an issue: GitHub.