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

proposal {} is stale; inspect and re-propose

Error message

proposal {} is stale; inspect and re-propose

What it means

Every update-kind proposal carries target_content_hash, the hash of the target SOP's content at capture time. At apply, if the SOP's current hash differs from the expected one, apply_proposal persists status Stale with status_reason 'target SOP changed since proposal capture' and refuses: the proposal was authored against content that no longer exists, so applying it would silently revert someone else's edits.

Source

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

    let current_target_hash = if target_dir.exists() {
        Some(hash_sop_dir(&target_dir)?)
    } else {
        None
    };
    match (&proposal.kind, &proposal.target_content_hash) {
        (ProposalKind::Create, None) if current_target_hash.is_some() => {
            proposal.status = ProposalStatus::Stale;
            proposal.updated_at = now_iso8601();
            proposal.status_reason = Some("target SOP was created after proposal capture".into());
            engine.save_proposal(&proposal)?;
            bail!("proposal {} is stale; target SOP now exists", proposal.id);
        }
        (_, Some(expected)) if current_target_hash.as_ref() != Some(expected) => {
            proposal.status = ProposalStatus::Stale;
            proposal.updated_at = now_iso8601();
            proposal.status_reason = Some("target SOP changed since proposal capture".into());
            engine.save_proposal(&proposal)?;
            bail!("proposal {} is stale; inspect and re-propose", proposal.id);
        }
        _ => {}
    }

    if let Some(reason) = scan_candidate(&proposal.manifest_toml, &proposal.procedure_markdown) {
        proposal.status = ProposalStatus::Quarantined;
        proposal.updated_at = now_iso8601();
        proposal.status_reason = Some(reason.clone());
        engine.save_proposal(&proposal)?;
        bail!("proposal {} quarantined: {reason}", proposal.id);
    }

    validate_candidate(
        &proposal.sop_name,
        &proposal.manifest_toml,
        &proposal.procedure_markdown,
    )?;
    let rollback = write_rollback(&sops_root, &target_dir, &proposal.id)?;

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Re-capture the proposal from the current SOP state and apply the fresh one.
  2. Stop editing the target SOP between propose and apply (or re-propose immediately after any edit).
  3. On this error, read the persisted status_reason and diff the SOP against the proposal content before deciding.

Example fix

// before: apply a proposal captured before an unrelated SOP edit
apply_proposal(&engine, install_root, id, None).await?; // hash mismatch -> Stale

// after: on stale, re-capture and apply the refreshed proposal
match apply_proposal(&engine, install_root, id, None).await {
    Ok(out) => out,
    Err(e) if e.to_string().contains("inspect and re-propose") => {
        let fresh = capture_successful_run(&engine, &run_id, None).await?;
        apply_proposal(&engine, install_root, &fresh.id, None).await?
    }
    Err(e) => return Err(e),
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Best-effort: freeze target SOP edits while proposals are pending, and on any
// mismatch re-capture instead of forcing the apply.
let proposal = engine.load_proposal(id)?.context("proposal missing")?;
if let Some(expected) = &proposal.target_content_hash {
    let current = current_target_hash(&engine, &proposal.sop_name); // helper over loaded SOP content
    if current.as_ref() != Some(expected) {
        anyhow::bail!("target changed since capture; re-propose");
    }
}

Try / catch

match apply_proposal(&engine, install_root, id, None).await {
    Ok(out) => out,
    Err(e) if e.to_string().contains("stale; inspect and re-propose") => {
        // engine persisted status Stale with status_reason; diff and re-capture
        let fresh = capture_successful_run(&engine, &run_id, None).await?;
        apply_proposal(&engine, install_root, &fresh.id, None).await?
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Editing the target SOP (manifest or procedure) between proposal capture and apply; two proposals targeting the same SOP where the first apply changes the hash for the second; re-applying an old proposal after the SOP evolved.

Common situations: Long review queues where the SOP is hotfixed while proposals wait; multiple operators proposing concurrently; stale proposal records replayed after a refactor.

Related errors


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