From ef2f488be8aa01d427a85e204430d29cc8cba802 Mon Sep 17 00:00:00 2001 From: Karn Date: Fri, 17 Jul 2026 20:23:34 +0530 Subject: [PATCH 1/2] fix: correct stale skill exclusion text and mutation state display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deterministic candidate template stamped "advisory until replay and shadow evaluation pass" into every package exclusion, and the installer rendered it verbatim into SKILL.md — a self-contradiction, since installation only happens after both stages passed. New candidates now carry stage-neutral phrasing; the installer recognizes the exact legacy line in already-registered (immutable) packages and renders the corrected text instead. propose/synthesize rows also printed the registration outcome's initial state, so an already shadow_passed or retired mutation displayed as "candidate". Both commands now fetch and print each mutation's current stored state; the JSON reports gain an additive current_states map. Co-Authored-By: Claude Fable 5 --- crates/autophagy-cli/src/main.rs | 83 ++++++++++++++++- crates/autophagy-cli/tests/cli.rs | 92 +++++++++++++++++++ crates/autophagy-install/src/lib.rs | 28 +++++- crates/autophagy-install/tests/codex_skill.rs | 72 ++++++++++++++- crates/autophagy-mutations/src/generate.rs | 24 ++++- crates/autophagy-mutations/src/lib.rs | 5 +- .../autophagy-mutations/tests/candidates.rs | 34 ++++++- crates/autophagy-store/src/store.rs | 24 +++++ crates/autophagy-store/tests/store.rs | 22 +++++ 9 files changed, 371 insertions(+), 13 deletions(-) diff --git a/crates/autophagy-cli/src/main.rs b/crates/autophagy-cli/src/main.rs index e6d7529..3ce3716 100644 --- a/crates/autophagy-cli/src/main.rs +++ b/crates/autophagy-cli/src/main.rs @@ -8,7 +8,7 @@ mod status; mod watch; use std::{ - collections::BTreeSet, + collections::{BTreeMap, BTreeSet}, fmt::Write as _, fs::{self, File}, io::{self, BufRead, BufReader, Write}, @@ -870,6 +870,15 @@ struct MutationProposalReport { dry_run: bool, generated: Vec, registrations: Vec, + /// Each generated candidate's CURRENT registry lifecycle state, keyed by + /// mutation ID, fetched from the store after the registration attempt + /// above. Deterministic generation re-derives the same mutation ID for + /// evidence that was already registered, so a candidate can already be + /// `shadow_passed`, `retired`, etc.; this map is the source of truth the + /// text and JSON renderers use instead of assuming every row is still + /// `candidate`. Absent entries mean the mutation has never been + /// registered (dry-run preview of a brand-new candidate). + current_states: BTreeMap, } #[derive(Debug, Serialize)] @@ -890,6 +899,10 @@ struct MutationSynthesisReport { total_completion_tokens: Option, synthesized: Vec, registrations: Vec, + /// See [`MutationProposalReport::current_states`]: each synthesized + /// candidate's CURRENT registry lifecycle state, fetched after the + /// registration attempt above, keyed by mutation ID. + current_states: BTreeMap, } #[derive(Debug, Serialize)] @@ -1983,10 +1996,18 @@ fn execute_mutation_action( registrations.push(store.register_mutation(®istration)?); } } + let current_states = mutation_states_by_id( + &store, + generated.iter().filter_map(|outcome| match outcome { + GenerationOutcome::Candidate { package } => Some(package.mutation_id.as_str()), + GenerationOutcome::InsufficientEvidence { .. } => None, + }), + )?; Ok(CommandReport::MutationProposal(MutationProposalReport { dry_run, generated, registrations, + current_states, })) } MutationAction::Synthesize { @@ -2121,6 +2142,15 @@ fn execute_mutation_action( registrations.push(store.register_mutation(®istration)?); } } + let current_states = mutation_states_by_id( + &store, + synthesized.iter().filter_map(|outcome| match outcome { + SynthesisOutcome::Candidate { package, .. } => { + Some(package.mutation_id.as_str()) + } + _ => None, + }), + )?; Ok(CommandReport::MutationSynthesis(MutationSynthesisReport { dry_run, provider: synthesis_provider.name().to_owned(), @@ -2134,6 +2164,7 @@ fn execute_mutation_action( total_completion_tokens, synthesized, registrations, + current_states, })) } MutationAction::List => Ok(CommandReport::MutationList(store.list_mutations()?)), @@ -3121,6 +3152,19 @@ fn write_mutation_lesson(writer: &mut impl Write, details: &MutationDetails) -> Ok(()) } +/// Look up a mutation's current display state, falling back to `candidate` +/// only when the ID has no stored state yet (never registered — a dry-run +/// preview of brand-new evidence, which will in fact become `candidate` the +/// moment it is registered). +fn display_state<'a>( + current_states: &'a BTreeMap, + mutation_id: &'a str, +) -> &'a str { + current_states + .get(mutation_id) + .map_or("candidate", String::as_str) +} + fn write_mutation_proposal( writer: &mut impl Write, report: &MutationProposalReport, @@ -3132,10 +3176,11 @@ fn write_mutation_proposal( match outcome { GenerationOutcome::Candidate { package } => writeln!( writer, - "{}\t{}\t{} evidence\tzero permissions\tcandidate", + "{}\t{}\t{} evidence\tzero permissions\t{}", package.mutation_id, package.title, - package.hypothesis.supporting_event_ids.len() + package.hypothesis.supporting_event_ids.len(), + display_state(&report.current_states, &package.mutation_id), )?, GenerationOutcome::InsufficientEvidence { finding_id, reason } => { writeln!(writer, "{finding_id}\tinsufficient evidence\t{reason}")?; @@ -3150,6 +3195,33 @@ fn write_mutation_proposal( Ok(()) } +/// Fetch each mutation's CURRENT registry lifecycle state, keyed by mutation +/// ID, for every ID a `propose`/`synthesize` pass generated a candidate for. +/// +/// Generation is deterministic: the same finding always re-derives the same +/// mutation ID, so re-running `propose`/`synthesize` over evidence that was +/// registered in an earlier pass reproduces an identical `GenerationOutcome:: +/// Candidate`/`SynthesisOutcome::Candidate` even after that mutation has been +/// challenged, replayed, shadow-evaluated, promoted, or retired. Looking the +/// state up fresh from the store — rather than assuming every generated row +/// is still `candidate` — is what keeps the displayed state honest. IDs never +/// registered (a dry-run preview of brand-new evidence) are simply absent. +/// +/// # Errors +/// Returns [`CliError`] when the store cannot be queried. +fn mutation_states_by_id<'a>( + store: &EventStore, + mutation_ids: impl Iterator, +) -> Result, CliError> { + let mut states = BTreeMap::new(); + for mutation_id in mutation_ids { + if let Some(state) = store.mutation_state(mutation_id)? { + states.insert(mutation_id.to_owned(), state); + } + } + Ok(states) +} + fn aggregate_usage(outcomes: &[SynthesisOutcome]) -> (Option, Option) { let mut prompt: Option = None; let mut completion: Option = None; @@ -3207,10 +3279,11 @@ fn write_mutation_synthesis( match outcome { SynthesisOutcome::Candidate { package, .. } => writeln!( writer, - "{}\t{}\t{} evidence\tzero permissions\tcandidate", + "{}\t{}\t{} evidence\tzero permissions\t{}", package.mutation_id, package.title, - package.hypothesis.supporting_event_ids.len() + package.hypothesis.supporting_event_ids.len(), + display_state(&report.current_states, &package.mutation_id), )?, SynthesisOutcome::InsufficientEvidence { finding_id, reason } => { writeln!(writer, "{finding_id}\tinsufficient evidence\t{reason}")?; diff --git a/crates/autophagy-cli/tests/cli.rs b/crates/autophagy-cli/tests/cli.rs index 4ae4dda..f3eb436 100644 --- a/crates/autophagy-cli/tests/cli.rs +++ b/crates/autophagy-cli/tests/cli.rs @@ -726,6 +726,98 @@ fn milestone_demo_digests_exports_deletes_and_prunes_offline() { ); } +/// `propose`/`synthesize` regenerate the exact same deterministic candidate +/// for evidence that was already registered in an earlier pass. Once that +/// mutation moves past `candidate` (rejected, shadow-evaluated, retired, ...) +/// re-running `propose`/`synthesize` must display its ACTUAL current state, +/// never the stale, generation-time `candidate` label. +#[test] +fn propose_and_synthesize_display_current_mutation_state_not_stale_candidate() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("autophagy.db"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../evals/fixtures/findings/deterministic.jsonl"); + run_json(&database, ["import", fixture.to_str().expect("UTF-8 path")]); + + let proposed = run_json(&database, ["mutations", "propose"]); + let generated = proposed["result"]["generated"] + .as_array() + .expect("generated"); + assert_eq!(generated.len(), 2); + let mutation_id = generated[0]["package"]["mutation_id"] + .as_str() + .expect("mutation id") + .to_owned(); + // Freshly generated and just registered: both fixture rows are still + // literally `candidate`. + assert_eq!( + proposed["result"]["current_states"][mutation_id.as_str()], + "candidate" + ); + + // Move the mutation past `candidate` with an auditable rejection. + let rejected = command(&database) + .args([ + "mutations", + "reject", + &mutation_id, + "--reason", + "superseded by manual review", + ]) + .output() + .expect("reject"); + assert!( + rejected.status.success(), + "reject failed: {}", + String::from_utf8_lossy(&rejected.stderr) + ); + + let registry = run_json(&database, ["mutations", "list"]); + let stored_state = registry["result"] + .as_array() + .expect("registry") + .iter() + .find(|mutation| mutation["mutation_id"] == mutation_id) + .expect("mutation still registered")["state"] + .clone(); + assert_eq!(stored_state, "rejected"); + + // Re-running `propose` deterministically re-derives the identical + // candidate for the same evidence (same mutation ID, same content), so + // registration collapses to a no-op `duplicate` outcome. The JSON + // `current_states` map must reflect the mutation's real state, not the + // package's generation-time classification. + let reproposed = run_json(&database, ["mutations", "propose"]); + assert_eq!( + reproposed["result"]["current_states"][mutation_id.as_str()], + "rejected" + ); + + // `mutations synthesize` (deterministic provider) must show the same fix. + let synthesized = run_json(&database, ["mutations", "synthesize"]); + assert_eq!( + synthesized["result"]["current_states"][mutation_id.as_str()], + "rejected" + ); + + // The human-readable text row must print the real state too — never the + // hardcoded `candidate` literal the audit found. + let text_output = command(&database) + .args(["mutations", "propose"]) + .output() + .expect("run propose text"); + assert!(text_output.status.success()); + let stdout = String::from_utf8_lossy(&text_output.stdout); + let row = stdout + .lines() + .find(|line| line.starts_with(&mutation_id)) + .unwrap_or_else(|| panic!("no row for {mutation_id} in:\n{stdout}")); + assert!( + row.ends_with("\trejected"), + "row must show the actual current state, not a stale 'candidate': {row}" + ); +} + #[test] fn recovery_motif_is_detected_and_registered_end_to_end() { let directory = tempfile::tempdir().expect("temporary directory"); diff --git a/crates/autophagy-install/src/lib.rs b/crates/autophagy-install/src/lib.rs index 96dc3ca..80ce499 100644 --- a/crates/autophagy-install/src/lib.rs +++ b/crates/autophagy-install/src/lib.rs @@ -24,7 +24,9 @@ use std::{ path::{Path, PathBuf}, }; -use autophagy_mutations::MutationPackage; +use autophagy_mutations::{ + ADVISORY_EXCLUSION, LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION, MutationPackage, +}; use sha2::{Digest, Sha256}; /// Supported repo-scoped skill installation targets. @@ -313,7 +315,7 @@ fn render_skill(package: &MutationPackage, skill_name: &str, target: InstallTarg rendered.push_str(&package.intervention.instruction); rendered.push_str("\n\n## Do not use\n\n"); for exclusion in &package.exclusions { - writeln!(rendered, "- {exclusion}").expect("String write"); + writeln!(rendered, "- {}", installed_exclusion_text(exclusion)).expect("String write"); } rendered.push_str("\nThis skill was installed only after challenge, replay, shadow evaluation, and explicit user approval.\n"); if target == InstallTarget::ClaudeCode { @@ -322,6 +324,28 @@ fn render_skill(package: &MutationPackage, skill_name: &str, target: InstallTarg rendered } +/// Render one exclusion for the installed `SKILL.md`, correcting the single +/// pipeline-stage exclusion that stops being true once a package reaches +/// installation. +/// +/// Registered mutation packages are immutable and audit-logged — this crate +/// never rewrites the stored package — so a package registered before +/// [`ADVISORY_EXCLUSION`] replaced the old phrasing can still carry +/// [`LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION`] verbatim. Installation only +/// happens after replay and shadow evaluation both passed, so rendering that +/// exact historical line as-is would contradict the "installed only after +/// challenge, replay, shadow evaluation" statement two lines below it in the +/// same file. The match is exact/structural (not fuzzy) so it only ever +/// touches that one known template string; every other exclusion, including +/// any future or user-authored wording, is rendered verbatim. +fn installed_exclusion_text(exclusion: &str) -> &str { + if exclusion == LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION { + ADVISORY_EXCLUSION + } else { + exclusion + } +} + fn render_evidence_footer(package: &MutationPackage, target: InstallTarget) -> String { let mut footer = String::from("\n## Evidence\n\n"); writeln!( diff --git a/crates/autophagy-install/tests/codex_skill.rs b/crates/autophagy-install/tests/codex_skill.rs index 413accb..240e53e 100644 --- a/crates/autophagy-install/tests/codex_skill.rs +++ b/crates/autophagy-install/tests/codex_skill.rs @@ -7,7 +7,10 @@ use autophagy_install::{ InstallError, InstallTarget, materialize, plan_claude_code_skill, plan_codex_skill, unmaterialize, }; -use autophagy_mutations::{GenerationOutcome, MutationPackage, generate_candidates}; +use autophagy_mutations::{ + ADVISORY_EXCLUSION, GenerationOutcome, LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION, MutationPackage, + generate_candidates, +}; use autophagy_patterns::{DetectorConfig, detect}; use autophagy_store::EventStore; @@ -174,6 +177,73 @@ fn install_targets_round_trip_registry_identifiers() { assert_eq!(InstallTarget::from_registry_id("vscode_repo_skill"), None); } +#[test] +fn generated_skill_has_no_self_contradicting_exclusion() { + let repository = tempfile::tempdir().expect("repository"); + fs::create_dir(repository.path().join(".git")).expect("git marker"); + let package = command_failure_package(); + + // A freshly generated candidate already uses the corrected template + // phrasing, so the installed SKILL.md must not carry the old claim that + // this instruction is merely "advisory until replay and shadow evaluation + // pass" — installation only happens after both already passed, and the + // file says so two lines below the exclusion list. + let plan = plan_claude_code_skill(&package, repository.path()).expect("plan"); + assert!( + !plan + .content + .contains(LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION), + "freshly generated SKILL.md must not carry the stale pipeline-stage exclusion: {}", + plan.content + ); + assert!( + plan.content.contains(ADVISORY_EXCLUSION), + "freshly generated SKILL.md should render the corrected advisory exclusion" + ); + + let codex_plan = plan_codex_skill(&package, repository.path()).expect("codex plan"); + assert!( + !codex_plan + .content + .contains(LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION) + ); + assert!(codex_plan.content.contains(ADVISORY_EXCLUSION)); +} + +#[test] +fn installer_tolerates_legacy_advisory_exclusion_phrasing() { + let repository = tempfile::tempdir().expect("repository"); + fs::create_dir(repository.path().join(".git")).expect("git marker"); + + // Registered mutation packages are immutable and audit-logged, so a + // package minted before the template fix can still carry the old exact + // phrasing forever. The installer must render it sanely at + // materialization time without rewriting the stored package. + let mut package = command_failure_package(); + let other_exclusion = + "Do not intervene when equivalent inputs already succeeded in the current context." + .to_owned(); + package.exclusions = vec![ + LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION.to_owned(), + other_exclusion.clone(), + ]; + + let plan = plan_claude_code_skill(&package, repository.path()).expect("plan"); + assert!( + !plan + .content + .contains(LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION), + "installer must not render the stale pipeline-stage claim for legacy packages: {}", + plan.content + ); + assert!( + plan.content.contains(ADVISORY_EXCLUSION), + "installer should substitute the corrected advisory phrasing" + ); + // Every other exclusion is kept verbatim. + assert!(plan.content.contains(&other_exclusion)); +} + fn command_failure_package() -> MutationPackage { let mut store = EventStore::open_in_memory().expect("store"); import_jsonl( diff --git a/crates/autophagy-mutations/src/generate.rs b/crates/autophagy-mutations/src/generate.rs index 01894bc..a391669 100644 --- a/crates/autophagy-mutations/src/generate.rs +++ b/crates/autophagy-mutations/src/generate.rs @@ -10,6 +10,24 @@ use crate::{ TriggerKind, }; +/// Exclusion text stamped into every failure-recovery candidate generated by +/// this crate. It must stay true at every lifecycle stage a package can reach +/// — including after it has been installed, long past replay and shadow +/// evaluation — because the exclusion is part of the immutable package and is +/// never rewritten once registered (see `crates/autophagy-install`, which +/// renders it verbatim into the installed `SKILL.md`). +pub const ADVISORY_EXCLUSION: &str = "Do not block execution; this instruction is advisory."; + +/// The exclusion text this crate generated before [`ADVISORY_EXCLUSION`] was +/// introduced. Already-registered mutation packages are immutable and audit- +/// logged, so this historical phrasing can still appear in stored packages; +/// exported so `crates/autophagy-install` can recognize it structurally (exact +/// match, not fuzzy) and avoid rendering the now-false "advisory until replay +/// and shadow evaluation pass" claim for packages installed after those stages +/// already passed. +pub const LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION: &str = + "Do not block execution; this candidate is advisory until replay and shadow evaluation pass."; + /// Deterministic generation result, including honest refusal. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "snake_case")] @@ -120,13 +138,13 @@ fn failure_candidate(finding: &EvidencePacket) -> Option { "Matching `{command}` attempts avoid repeated exit-code-{exit_code} failures without blocking valid executions." ), vec![ - "The command can fail transiently even when all preconditions are satisfied.".to_owned(), + "The command can fail transiently even when all preconditions are satisfied." + .to_owned(), "The same command text can represent different intent or repository state.".to_owned(), "The advisory could interrupt a legitimate immediate retry.".to_owned(), ], vec![ - "Do not block execution; this candidate is advisory until replay and shadow evaluation pass." - .to_owned(), + ADVISORY_EXCLUSION.to_owned(), "Do not intervene when equivalent inputs already succeeded in the current context." .to_owned(), ], diff --git a/crates/autophagy-mutations/src/lib.rs b/crates/autophagy-mutations/src/lib.rs index d4880b9..31742d6 100644 --- a/crates/autophagy-mutations/src/lib.rs +++ b/crates/autophagy-mutations/src/lib.rs @@ -4,7 +4,10 @@ mod generate; mod model; mod validate; -pub use generate::{GenerationOutcome, equivalence_key, generate_candidate, generate_candidates}; +pub use generate::{ + ADVISORY_EXCLUSION, GenerationOutcome, LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION, equivalence_key, + generate_candidate, generate_candidates, +}; pub use model::{ CandidateHypothesis, GeneratedBy, Intervention, InterventionKind, LifecycleState, MutationPackage, MutationSpecVersion, PermissionManifest, PromotionPolicy, Provenance, Trigger, diff --git a/crates/autophagy-mutations/tests/candidates.rs b/crates/autophagy-mutations/tests/candidates.rs index 304cd0b..ac45eec 100644 --- a/crates/autophagy-mutations/tests/candidates.rs +++ b/crates/autophagy-mutations/tests/candidates.rs @@ -4,7 +4,8 @@ use std::io::Cursor; use autophagy_core::{ImportOptions, import_jsonl}; use autophagy_mutations::{ - GenerationOutcome, LifecycleState, generate_candidate, generate_candidates, + ADVISORY_EXCLUSION, GenerationOutcome, LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION, LifecycleState, + generate_candidate, generate_candidates, }; use autophagy_patterns::{DetectorConfig, detect}; use autophagy_store::EventStore; @@ -40,6 +41,37 @@ fn findings_generate_stable_zero_permission_candidates() { } } +#[test] +fn generated_exclusions_stay_true_at_every_lifecycle_stage() { + // The exclusion template must not claim a pipeline stage ("advisory until + // replay and shadow evaluation pass") that is already behind the package + // by the time it is challenged, replayed, shadowed, or installed — every + // one of which happens strictly after generation. Assert the new + // candidates never regress to the old, stage-bound phrasing and instead + // use the stage-independent `ADVISORY_EXCLUSION` wording. + let mut saw_advisory_exclusion = false; + for outcome in generate_candidates(&fixture_findings()) { + let GenerationOutcome::Candidate { package } = outcome else { + panic!("fixture finding should generate a candidate") + }; + assert!( + package + .exclusions + .iter() + .all(|exclusion| exclusion != LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION), + "newly generated packages must not carry the stale pipeline-stage exclusion" + ); + saw_advisory_exclusion |= package + .exclusions + .iter() + .any(|exclusion| exclusion == ADVISORY_EXCLUSION); + } + assert!( + saw_advisory_exclusion, + "the repeated-command-failure fixture candidate should use the stage-independent advisory exclusion" + ); +} + #[test] fn weak_or_malformed_findings_produce_insufficient_evidence() { let mut finding = fixture_findings().remove(0); diff --git a/crates/autophagy-store/src/store.rs b/crates/autophagy-store/src/store.rs index 7aa8a8d..2367f83 100644 --- a/crates/autophagy-store/src/store.rs +++ b/crates/autophagy-store/src/store.rs @@ -953,6 +953,30 @@ impl EventStore { Ok(rows.collect::>()?) } + /// Return the current lifecycle state of one registered mutation, or + /// `None` when no candidate with that ID has ever been registered. + /// + /// This is the cheap, single-column lookup callers use to display a + /// mutation's *current* state (e.g. after a `propose`/`synthesize` pass + /// re-derives the same deterministic candidate for evidence that was + /// already registered and has since moved past `candidate`) without + /// paying for the full [`Self::get_mutation`] package and transition + /// history. + /// + /// # Errors + /// + /// Returns [`StoreError`] when `SQLite` cannot execute the query. + pub fn mutation_state(&self, mutation_id: &str) -> Result, StoreError> { + Ok(self + .connection + .query_row( + "SELECT state FROM mutation_candidates WHERE mutation_id = ?1", + [mutation_id], + |row| row.get::<_, String>(0), + ) + .optional()?) + } + /// Count registered mutation candidates grouped by lifecycle state. /// /// Read-only COUNT aggregation; states with no candidates are absent. diff --git a/crates/autophagy-store/tests/store.rs b/crates/autophagy-store/tests/store.rs index e23e53d..c16bbce 100644 --- a/crates/autophagy-store/tests/store.rs +++ b/crates/autophagy-store/tests/store.rs @@ -846,6 +846,22 @@ fn mutation_registry_is_idempotent_audited_and_evidence_bound() { assert!(!details.replays[0].passed); assert!(details.replays[1].passed); + // The cheap single-column lookup callers use to display a mutation's + // CURRENT state (e.g. `mutations propose`/`synthesize` re-deriving the + // same deterministic candidate for already-registered evidence) must + // reflect this exact lifecycle progression, not the package's + // generation-time `candidate` classification. + assert_eq!( + store.mutation_state("mut_registry").expect("state lookup"), + Some("retired".to_owned()) + ); + assert_eq!( + store + .mutation_state("mut_never_registered") + .expect("missing lookup"), + None + ); + let deleted = store .delete_session("ses_replay-only") .expect("delete replay evidence"); @@ -854,6 +870,12 @@ fn mutation_registry_is_idempotent_audited_and_evidence_bound() { store.get_mutation("mut_registry"), Err(StoreError::MutationNotFound { .. }) )); + assert_eq!( + store + .mutation_state("mut_registry") + .expect("state lookup after delete"), + None + ); } #[test] From 0418988fa2a78b4a53e3bc0126c949b688e9d2ec Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 18 Jul 2026 02:50:26 +0530 Subject: [PATCH 2/2] fix: keep propose/synthesize batches alive across template conflicts Deterministic generation re-derives the same mutation id from the same evidence, so a template-text change between releases makes regeneration conflict with the immutable stored package. The registry correctly refuses the overwrite, but propose/synthesize turned that refusal into a hard error that aborted the whole batch on any database with candidates registered by an earlier template. Catch the content conflict per candidate, keep the stored package authoritative, surface a warning in text and JSON output, and let the rest of the batch register. Co-Authored-By: Claude Fable 5 --- crates/autophagy-cli/src/main.rs | 38 +++++++++++++- crates/autophagy-cli/tests/cli.rs | 83 +++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/crates/autophagy-cli/src/main.rs b/crates/autophagy-cli/src/main.rs index 3ce3716..8d5f747 100644 --- a/crates/autophagy-cli/src/main.rs +++ b/crates/autophagy-cli/src/main.rs @@ -870,6 +870,11 @@ struct MutationProposalReport { dry_run: bool, generated: Vec, registrations: Vec, + /// Non-fatal registration notes, currently only immutable-package template + /// conflicts (see [`template_conflict_warning`]). Skipped from JSON when + /// empty so the machine surface is unchanged for conflict-free runs. + #[serde(skip_serializing_if = "Vec::is_empty")] + warnings: Vec, /// Each generated candidate's CURRENT registry lifecycle state, keyed by /// mutation ID, fetched from the store after the registration attempt /// above. Deterministic generation re-derives the same mutation ID for @@ -1974,6 +1979,7 @@ fn execute_mutation_action( .findings; let generated = generate_candidates(&findings); let mut registrations = Vec::new(); + let mut warnings = Vec::new(); if !dry_run { for outcome in &generated { let GenerationOutcome::Candidate { package } = outcome else { @@ -1993,7 +1999,13 @@ fn execute_mutation_action( .counterexample_event_ids .clone(), }; - registrations.push(store.register_mutation(®istration)?); + match store.register_mutation(®istration) { + Ok(outcome) => registrations.push(outcome), + Err(StoreError::MutationContentConflict { mutation_id }) => { + warnings.push(template_conflict_warning(&mutation_id)); + } + Err(error) => return Err(error.into()), + } } } let current_states = mutation_states_by_id( @@ -2007,6 +2019,7 @@ fn execute_mutation_action( dry_run, generated, registrations, + warnings, current_states, })) } @@ -2139,7 +2152,13 @@ fn execute_mutation_action( .counterexample_event_ids .clone(), }; - registrations.push(store.register_mutation(®istration)?); + match store.register_mutation(®istration) { + Ok(outcome) => registrations.push(outcome), + Err(StoreError::MutationContentConflict { mutation_id }) => { + warnings.push(template_conflict_warning(&mutation_id)); + } + Err(error) => return Err(error.into()), + } } } let current_states = mutation_states_by_id( @@ -3165,10 +3184,25 @@ fn display_state<'a>( .map_or("candidate", String::as_str) } +/// Warning for a candidate whose evidence re-derived an already-registered +/// mutation ID with different package content. Registered packages are +/// immutable, so the stored package stays authoritative; this happens when the +/// deterministic template evolved between the original registration and this +/// run, and it must not abort the rest of the batch. +fn template_conflict_warning(mutation_id: &str) -> String { + format!( + "mutation '{mutation_id}' is already registered with earlier-template content; \ + the stored immutable package is kept" + ) +} + fn write_mutation_proposal( writer: &mut impl Write, report: &MutationProposalReport, ) -> io::Result<()> { + for warning in &report.warnings { + writeln!(writer, "warning: {warning}")?; + } if report.generated.is_empty() { writeln!(writer, "no mutation candidates above evidence threshold")?; } diff --git a/crates/autophagy-cli/tests/cli.rs b/crates/autophagy-cli/tests/cli.rs index f3eb436..742f1f2 100644 --- a/crates/autophagy-cli/tests/cli.rs +++ b/crates/autophagy-cli/tests/cli.rs @@ -2634,3 +2634,86 @@ fn command(database: &Path) -> Command { } command } + +#[test] +fn propose_and_synthesize_survive_immutable_template_conflicts() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("autophagy.db"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../evals/fixtures/findings/deterministic.jsonl"); + run_json(&database, ["import", fixture.to_str().expect("UTF-8 path")]); + + // Preview the exact candidate generation would register today. + let preview = run_json(&database, ["mutations", "propose", "--dry-run"]); + let mut package: autophagy_mutations::MutationPackage = + serde_json::from_value(preview["result"]["generated"][0]["package"].clone()) + .expect("generated package"); + let mutation_id = package.mutation_id.clone(); + + // Register it as an earlier template revision would have: identical + // identity and evidence, different exclusion phrasing. The registry is + // immutable, so a later propose regenerating today's phrasing cannot + // overwrite it — and must not abort the batch over it either. + package.exclusions[0] = autophagy_mutations::LEGACY_ADVISORY_UNTIL_REPLAY_EXCLUSION.to_owned(); + let mut store = autophagy_store::EventStore::open(&database).expect("store"); + store + .register_mutation(&autophagy_store::MutationRegistration { + mutation_id: package.mutation_id.clone(), + source_finding_id: package.source_finding_id.clone(), + source_detector: package.source_detector.as_str().to_owned(), + equivalence_key: autophagy_mutations::equivalence_key(&package), + spec_version: package.spec_version.as_str().to_owned(), + semantic_version: package.version.clone(), + package: serde_json::to_value(&package).expect("package JSON"), + supporting_event_ids: package.hypothesis.supporting_event_ids.clone(), + counterexample_event_ids: package.hypothesis.counterexample_event_ids.clone(), + }) + .expect("register earlier-template package"); + drop(store); + + // Text mode: the run succeeds, warns about the one conflict, and still + // lists every candidate with its real stored state. + let output = command(&database) + .args(["mutations", "propose"]) + .output() + .expect("propose"); + assert!( + output.status.success(), + "propose failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("earlier-template content"), + "missing conflict warning: {stdout}" + ); + + // JSON mode: the warning is machine-visible and the conflicted mutation + // still reports its current stored state. + let proposed = run_json(&database, ["mutations", "propose"]); + let warnings = proposed["result"]["warnings"].as_array().expect("warnings"); + assert!( + warnings.iter().any(|warning| warning + .as_str() + .expect("warning text") + .contains(&mutation_id)), + "warnings missing conflicted id: {warnings:?}" + ); + assert_eq!( + proposed["result"]["current_states"][mutation_id.as_str()], + "candidate" + ); + + // The deterministic synthesize path takes the same non-fatal branch. + let synthesized = run_json(&database, ["mutations", "synthesize"]); + assert!( + synthesized["result"]["warnings"] + .as_array() + .expect("synthesis warnings") + .iter() + .any(|warning| warning + .as_str() + .expect("warning text") + .contains(&mutation_id)) + ); +}