{"record":{"id":"cbf9e4d101d305c4","repo":"zeroclaw-labs/zeroclaw","slug":"proposal-is-not-pending","errorCode":null,"errorMessage":"proposal {} is {:?}, not pending","messagePattern":"proposal (.+?) is (.+?), not pending","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-runtime/src/sop/procedural_memory.rs","lineNumber":133,"sourceCode":"            sop_name: sop.name.clone(),\n            description: sop.description.clone(),\n            manifest_toml: Some(manifest_toml),\n            procedure_markdown,\n            source_run_id: Some(run_id.to_string()),\n            requested_by,\n        },\n    )\n}\n\npub fn apply_proposal(\n    engine: &mut SopEngine,\n    install_root: &Path,\n    proposal_id: &str,\n    applied_by: Option<String>,\n) -> Result<ApplyOutcome> {\n    let mut proposal = load_required(engine, proposal_id)?;\n    if proposal.status != ProposalStatus::Pending {\n        bail!(\n            \"proposal {} is {:?}, not pending\",\n            proposal.id,\n            proposal.status\n        );\n    }\n\n    let sops_root = super::resolve_sops_dir(install_root, engine.config().sops_dir.as_deref());\n    // An Update must land on the currently-loaded SOP's actual directory, which\n    // the loader sets from on-disk layout and need not match a slug of the name.\n    // Create has no loaded SOP, so derive the new directory from the name.\n    let target_dir = match proposal.kind {\n        ProposalKind::Update => match engine\n            .get_sop(&proposal.sop_name)\n            .and_then(|sop| sop.location.clone())\n        {\n            Some(location) => contained_existing_dir(&sops_root, &location)?,\n            None => contained_sop_dir(&sops_root, &proposal.sop_name)?,\n        },","sourceCodeStart":115,"sourceCodeEnd":151,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-runtime/src/sop/procedural_memory.rs#L115-L151","documentation":"apply_proposal loads the proposal record and requires status Pending before doing anything. Proposals that were already applied, marked stale, quarantined, or rejected are refused; the message names the proposal id and its actual status. Applying is a one-shot state transition, not an idempotent retry.","triggerScenarios":"Calling apply twice (double-click, queue redelivery); applying a proposal that a previous apply already transitioned to Applied; applying after a stale/quarantine marker was set by an earlier failed attempt.","commonSituations":"Retried RPC deliveries from channels; operators re-running an apply script; dashboards that resubmit on timeout even though the first apply succeeded.","solutions":["Load the proposal first (engine.load_proposal(id)) and only apply when status is Pending.","If it is already Applied, treat the retry as success (idempotency in the caller).","If Stale or Quarantined, read status_reason and create a fresh proposal instead of re-applying."],"exampleFix":"// before: apply retried on timeout, but the first attempt already succeeded\napply_proposal(&engine, install_root, proposal_id, None).await?;\n\n// after: make apply idempotent on the caller side\nlet p = engine.load_proposal(proposal_id)?.context(\"proposal missing\")?;\nmatch p.status {\n    ProposalStatus::Pending => { apply_proposal(&engine, install_root, proposal_id, None).await?; }\n    ProposalStatus::Applied => { tracing::info!(\"already applied\"); }\n    other => anyhow::bail!(\"proposal {other:?} not appliable; re-propose\"),\n}","handlingStrategy":"validation","validationCode":"let proposal = engine\n    .load_proposal(proposal_id)?\n    .ok_or_else(|| anyhow::anyhow!(\"proposal not found: {proposal_id}\"))?;\nanyhow::ensure!(\n    proposal.status == ProposalStatus::Pending,\n    \"proposal is {:?}; only Pending proposals apply\",\n    proposal.status\n);","typeGuard":"fn is_pending(p: &ProposalRecord) -> bool {\n    p.status == ProposalStatus::Pending\n}","tryCatchPattern":"match apply_proposal(&engine, install_root, id, None).await {\n    Err(e) if e.to_string().contains(\", not pending\") => {\n        // reload the record: Applied -> treat as done; Stale/Quarantined -> re-propose\n    }\n    other => other?,\n}","preventionTips":["Make apply callers idempotent: Applied on retry is success.","Check proposal status before applying and after any apply error.","Guard against duplicate apply deliveries from queues/UI retries."],"tags":["procedural-memory","proposal","apply","status"],"backgroundTag":"invalid-state-transition","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}