shepherd-core 6.6.0

The harness-agnostic shepherd engine: domain types, configuration schema, and run state. Knows nothing about any CLI, harness, or process.
#![cfg(feature = "json")]

use shepherd_core::candidate::{
    CandidateCustody, CandidateRecord, CandidateState, CompilerDigests, FileReference,
    ProductArtifacts, ProductFile, ProductManifest, ProductSource, evidence_only_path,
    excluded_product_path, product_symlink_target,
};

fn file() -> FileReference {
    FileReference {
        path: "/external/component.wasm".into(),
        sha256: "b".repeat(64),
        bytes: 8,
    }
}

fn manifest() -> ProductManifest {
    ProductManifest {
        schema: "shepherd.product-manifest/1".into(),
        run: "v657".into(),
        source: ProductSource {
            root: "/source".into(),
            commit: "a".repeat(40),
            tree: "b".repeat(40),
        },
        files: vec![ProductFile {
            path: "Cargo.toml".into(),
            mode: "100644".into(),
            sha256: "c".repeat(64),
        }],
        compiler: CompilerDigests {
            claude: "d".repeat(64),
            codex: "e".repeat(64),
            pi: "f".repeat(64),
        },
        artifacts: ProductArtifacts {
            native_cli: file(),
            component_wasm: file(),
        },
    }
}

fn frozen() -> CandidateRecord {
    CandidateRecord::freeze(
        "d".repeat(64),
        "run-incarnation".into(),
        manifest(),
        "e".repeat(64),
    )
    .expect("measured native freeze")
}

#[test]
fn candidate_stage_order_and_pack_once_are_not_replayable() {
    let mut candidate = frozen();
    assert!(
        candidate.reserve_pack().is_err(),
        "pack requires source verification"
    );
    assert!(candidate.attest_packages(file()).is_err());
    assert!(candidate.attest_lifecycle(file()).is_err());
    candidate.verify_source().expect("source stage");
    candidate.reserve_pack().expect("one native reservation");
    assert!(candidate.reserve_pack().is_err(), "second pack must fail");
    candidate
        .attest_packages(file())
        .expect("packages measured");
    assert!(
        candidate.attest_packages(file()).is_err(),
        "package replay must fail"
    );
    candidate
        .attest_lifecycle(file())
        .expect("lifecycle measured");
    assert!(
        candidate.attest_lifecycle(file()).is_err(),
        "lifecycle replay must fail"
    );
    assert_eq!(candidate.state, CandidateState::Attested);
    candidate.validate().expect("complete typed record");
}

#[test]
fn revocation_is_terminal_and_retains_measured_proofs() {
    let mut candidate = frozen();
    candidate.verify_source().expect("verified");
    candidate.reserve_pack().expect("reserved");
    candidate.attest_packages(file()).expect("packages");
    candidate
        .revoke("product bytes changed".into())
        .expect("revoke");
    assert!(candidate.packages_manifest.is_some());
    assert_eq!(
        candidate.revocation_reason.as_deref(),
        Some("product bytes changed")
    );
    assert!(candidate.verify_source().is_err());
    assert!(candidate.reserve_pack().is_err());
    assert!(candidate.attest_lifecycle(file()).is_err());
    assert!(candidate.revoke("replay".into()).is_err());
}

#[test]
fn refreeze_requires_explicit_revocation_and_preserves_history() {
    let mut custody = CandidateCustody::new(frozen()).expect("new custody");
    assert!(custody.refreeze(frozen()).is_err());
    custody.current.revoke("new source".into()).expect("revoke");
    assert!(
        custody.refreeze(frozen()).is_err(),
        "candidate identity cannot replay"
    );
    let mut next = frozen();
    next.candidate_id = "f".repeat(64);
    custody.refreeze(next).expect("new identity after revoke");
    assert_eq!(custody.history.len(), 1);
    assert_eq!(custody.history[0].state, CandidateState::Revoked);
    custody.validate().expect("history retained");
}

#[test]
fn manifest_rejects_noncanonical_incomplete_or_excluded_shapes() {
    manifest().validate().expect("canonical manifest");
    for path in [
        "../source",
        "/source",
        "a//b",
        "a/./b",
        "a\\b",
        ".git/config",
        ".shepherd/runs/v657/run.json",
        ".shepherd/runs/v657/evidence/proof.json",
    ] {
        let mut input = manifest();
        input.files[0].path = path.into();
        assert!(input.validate().is_err(), "unsafe product path: {path}");
    }
    for mode in ["100600", "160000", "120000"] {
        let mut input = manifest();
        input.files[0].mode = mode.into();
        assert!(input.validate().is_err(), "unsupported mode: {mode}");
    }
    let mut input = manifest();
    input.files.push(input.files[0].clone());
    assert!(input.validate().is_err(), "duplicate product path");
    input.files.clear();
    assert!(input.validate().is_err(), "empty inventory");
    let mut input = manifest();
    input.source.commit = "origin/v6.5.7".into();
    assert!(input.validate().is_err(), "moving ref is not a commit id");
    input.source.commit = "A".repeat(40);
    assert!(input.validate().is_err(), "uppercase id");
}

#[test]
fn only_five_canonical_symlinks_have_product_authority() {
    for (path, target) in [
        ("CLAUDE.md", "AGENTS.md"),
        ("plugins/shepherd/agents", "../../agents"),
        (
            "plugins/shepherd/hooks/hooks.json",
            "../../../hooks/hooks.json",
        ),
        ("plugins/shepherd/hooks/scripts", "../../../hooks/scripts"),
        ("plugins/shepherd/skills", "../../skills"),
    ] {
        assert_eq!(product_symlink_target(path), Some(target));
        let mut input = manifest();
        input.files[0].path = path.into();
        input.files[0].mode = "120000".into();
        input.validate().expect("explicit product symlink");
    }
    assert_eq!(product_symlink_target("plugins/shepherd/other"), None);
}

#[test]
fn attestation_allowlist_never_excludes_another_runs_product() {
    for path in [
        ".shepherd/runs/v657/evidence/one.json",
        ".shepherd/runs/v657/lanes/lane-a/evidence/two.json",
        ".shepherd/runs/v657/lanes/lane-a/handoff.md",
        ".shepherd/runs/v657/close.md",
        ".shepherd/runs/v657/handoff.md",
    ] {
        assert!(evidence_only_path("v657", path), "{path}");
        assert!(excluded_product_path(path), "{path}");
    }
    for path in [
        ".shepherd/runs/v656/evidence/one.json",
        ".shepherd/runs/v657/seed.md",
        ".shepherd/runs/v657/plan.md",
        ".shepherd/runs/v657/lanes/lane-a/plan.md",
        ".shepherd/runs/v657/reports/review.md",
        ".shepherd/runs/v657/evidence/../seed.md",
        "src/evidence/fake.json",
        ".shepherd/runs/v657/run.json",
    ] {
        assert!(!evidence_only_path("v657", path), "{path}");
    }
    assert!(!excluded_product_path(".shepherd/runs/v656/seed.md"));
    assert!(!excluded_product_path(".shepherd/shepherd.toml"));
}

/// `.artifacts/` is generated output, never product source.
///
/// It is this project's home for generated things with no natural default the
/// way `target/` and `node_modules/` have one -- rendered docs for Pages,
/// incidental binaries. A candidate's frozen hash must not move because docs
/// were rendered.
///
/// Attestations are deliberately NOT here. They are keyed by shepherd run id
/// and verified against shepherd's run ledger, so an attestation is meaningless
/// outside `.shepherd/` and stays run-scoped inside it -- which is what makes
/// an attestation belonging to ANOTHER run detectable as foreign after a
/// freeze.
#[test]
fn generated_artifacts_are_never_product_and_attestations_stay_run_scoped() {
    use shepherd_core::candidate::{evidence_only_path, excluded_product_path};

    for generated in [
        ".artifacts/docs/index.html",
        ".artifacts/attestations/v660/attestation.json",
    ] {
        assert!(
            excluded_product_path(generated),
            "{generated} is generated, not product"
        );
    }

    // Attestations remain shepherd-namespaced and run-scoped.
    assert!(evidence_only_path(
        "v660",
        ".shepherd/runs/v660/evidence/attestation.json"
    ));
    assert!(
        !evidence_only_path("v660", ".shepherd/runs/v659/evidence/attestation.json"),
        "another run's attestation must stay detectable as foreign"
    );
    // `.artifacts` is not an evidence channel: it cannot advance a frozen run.
    assert!(!evidence_only_path(
        "v660",
        ".artifacts/attestations/v660/attestation.json"
    ));

    // Flat in the run directory, beside close.md and handoff.md which were
    // already flat. A subdirectory for one file bought nothing.
    assert!(evidence_only_path(
        "v660",
        ".shepherd/runs/v660/attestation.json"
    ));
    assert!(
        !evidence_only_path("v660", ".shepherd/runs/v659/attestation.json"),
        "the run id in the path is what makes a foreign attestation detectable"
    );
    // Still narrow: the run directory is not a blanket evidence licence.
    assert!(!evidence_only_path("v660", ".shepherd/runs/v660/run.json"));
    assert!(!evidence_only_path("v660", ".shepherd/runs/v660/plan.md"));
}