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

candidate SOP has no parsed steps

Error message

candidate SOP has no parsed steps

What it means

validate_candidate requires the round-trip-loaded candidate SOP to contain at least one parsed step. The loader parsed the SOP.md (name matched, manifest was fine) but produced an empty steps list, meaning the procedure markdown contains no steps in a format the loader recognizes. Proposals without executable steps are rejected because an applied SOP would be a no-op.

Source

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

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) {
        LeakResult::Clean => None,
        LeakResult::Detected { patterns, .. } => Some(format!(
            "credential-like content detected: {}",
            patterns.join(", ")
        )),
    }
}

fn read_or_default_manifest(sop: &Sop) -> Result<String> {
    if let Some(location) = &sop.location {

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Rewrite SOP.md with a '## Steps' section containing numbered list items in the loader's format: 1. **Title** - body (see default_procedure_markdown for the exact shape)
  2. Verify the fix by round-trip loading the candidate in a temp dir and asserting steps is non-empty before re-submitting the proposal
  3. For captured runs, check the source SOP's SOP.md on disk - fix it there, then re-run capture_successful_run

Example fix

// before
let md = "# Deploy check\n\nWe verify the deploy and report.\n";

// after
let md = "# Deploy check\n\n## Steps\n\n1. **Verify** - run `deploy --check`\n2. **Report** - summarize the output\n";
Defensive patterns

Strategy: validation

Validate before calling

// Cheap structural pre-check for parseable steps before proposing:
fn has_numbered_steps(md: &str) -> bool {
    let in_steps = md.split("## Steps").nth(1).unwrap_or("");
    in_steps.lines().any(|l| l.trim_start().starts_with("1."))
}

Try / catch

Err(e) if e.to_string().contains("no parsed steps") => {
    // send SOP.md back for step formatting; keep the manifest untouched
}

Prevention

When it happens

Trigger: create_proposal or apply_proposal with a procedure_markdown that has no numbered steps under the expected '## Steps' section - only prose, headings, or bullet points the parser ignores. Via capture_successful_run: the source SOP's SOP.md lost its numbered steps (e.g. someone replaced it with narrative docs), so append_captured_notes inherits a step-less document.

Common situations: Writing SOP.md as narrative documentation instead of the step format produced by default_procedure_markdown ('## Steps' followed by '1. **Title** - body' items), or a markdown reformatting tool renumbering/reformatting steps out of the recognizable shape.

Related errors


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