diff --git a/Cargo.lock b/Cargo.lock index 20b6c7f..7cfda70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,9 +173,11 @@ dependencies = [ "autophagy-core", "autophagy-efficacy", "autophagy-events", + "autophagy-genome", "autophagy-install", "autophagy-mutations", "autophagy-patterns", + "autophagy-redaction", "autophagy-replay", "autophagy-shadow", "autophagy-store", @@ -229,6 +231,20 @@ dependencies = [ "time", ] +[[package]] +name = "autophagy-genome" +version = "0.1.0" +dependencies = [ + "autophagy-events", + "autophagy-mutations", + "autophagy-redaction", + "jsonschema", + "serde", + "serde_json", + "sha2", + "thiserror", +] + [[package]] name = "autophagy-install" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 83d82bb..a4c087f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/autophagy-core", "crates/autophagy-efficacy", "crates/autophagy-events", + "crates/autophagy-genome", "crates/autophagy-install", "crates/autophagy-mutations", "crates/autophagy-patterns", diff --git a/crates/autophagy-cli/Cargo.toml b/crates/autophagy-cli/Cargo.toml index 23df930..507bc3c 100644 --- a/crates/autophagy-cli/Cargo.toml +++ b/crates/autophagy-cli/Cargo.toml @@ -20,7 +20,9 @@ autophagy-adapter-pi = { path = "../../adapters/pi" } autophagy-core = { path = "../autophagy-core" } autophagy-efficacy = { path = "../autophagy-efficacy" } autophagy-events = { path = "../autophagy-events" } +autophagy-genome = { path = "../autophagy-genome" } autophagy-install = { path = "../autophagy-install" } +autophagy-redaction = { path = "../autophagy-redaction" } autophagy-mutations = { path = "../autophagy-mutations" } autophagy-patterns = { path = "../autophagy-patterns" } autophagy-replay = { path = "../autophagy-replay" } diff --git a/crates/autophagy-cli/src/main.rs b/crates/autophagy-cli/src/main.rs index dcb9755..70e9a65 100644 --- a/crates/autophagy-cli/src/main.rs +++ b/crates/autophagy-cli/src/main.rs @@ -37,6 +37,10 @@ use autophagy_efficacy::{ InsufficientReason, MatchingRule, Verdict, WindowBounds, evaluate as evaluate_efficacy, }; use autophagy_events::Event; +use autophagy_genome::{ + AttestationInput, AttestationKind, GenomeBundle, GenomeSource, GenomeTransition, + build as build_genome, parse as parse_genome, +}; use autophagy_install::{ InstallError, InstallTarget, InstalledArtifact, SkillPlan, SupervisorError, materialize, plan_skill, unmaterialize, @@ -47,6 +51,7 @@ use autophagy_mutations::{ use autophagy_patterns::{ DetectionDiagnostics, DetectionReport, DetectorConfig, EvidencePacket, Observation, UnmetGate, }; +use autophagy_redaction::PrivacyPolicy; use autophagy_replay::{ CounterfactualOutcome, ExpectedAction, ReplayDraftError, ReplayEvaluationError, ReplayReport, ReplaySuite, evaluate, extract_review_draft, @@ -56,11 +61,13 @@ use autophagy_shadow::{ evaluate as evaluate_shadow, extract_observation_draft, }; use autophagy_store::{ - DeleteAllSummary, DeleteSummary, EfficacyRegisterOutcome, EfficacyRegistration, EventStore, - InstallationRegistration, InstallationTransitionOutcome, MutationDetails, MutationRecord, - MutationRegisterOutcome, MutationRegistration, MutationTransitionOutcome, PruneSummary, - ReplayRegisterOutcome, ReplayRegistration, RetrievalHit, RetrievalOutcome, RetrievalQuery, - SessionSummary, ShadowRegisterOutcome, ShadowRegistration, StoreError, + AttestationRegisterOutcome, AttestationRegistration, DeleteAllSummary, DeleteSummary, + EfficacyRegisterOutcome, EfficacyRegistration, EventStore, InsertOutcome, + InstallationRegistration, InstallationTransitionOutcome, MutationAttestationRecord, + MutationDetails, MutationRecord, MutationRegisterOutcome, MutationRegistration, + MutationTransitionOutcome, PruneSummary, ReplayRegisterOutcome, ReplayRegistration, + RetrievalHit, RetrievalOutcome, RetrievalQuery, SearchProjection, SessionSummary, + ShadowRegisterOutcome, ShadowRegistration, SourceIdentity, StoreError, }; use autophagy_synthesis::{ AgentCliProvider, Capability, DeterministicReferenceProvider, EndpointLocality, ManifestError, @@ -254,6 +261,18 @@ enum Commands { action: MutationAction, }, + /// Export and import portable mutation genomes between machines. + /// + /// A genome is a self-contained, redaction-gated file carrying one verified + /// mutation, its redacted evidence, and the origin's verification reports as + /// display-only attestations. Trust is never transplanted: an imported + /// mutation always lands as a fresh review-only candidate that must be + /// re-verified locally. + Genome { + #[command(subcommand)] + action: GenomeAction, + }, + /// Export redacted canonical AEP events as JSONL to standard output. Export { /// Limit export to one exact project path. @@ -664,6 +683,32 @@ enum MutationAction { }, } +#[derive(Debug, Subcommand)] +enum GenomeAction { + /// Export one mutation as a redaction-gated genome file. + Export { + /// Stable mutation identity (accepts a unique `mut_` short-id prefix). + mutation_id: String, + + /// Destination path for the `.genome.json` bundle. + #[arg(long, value_name = "PATH")] + out: PathBuf, + + /// Replace an existing destination file. + #[arg(long)] + force: bool, + }, + /// Import a genome as a fresh review-only candidate with attestations. + Import { + /// Path to the `.genome.json` bundle to import. + path: PathBuf, + + /// Show the full import plan and write nothing. + #[arg(long)] + dry_run: bool, + }, +} + /// Coding-agent installation target selectable on the command line. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] #[serde(rename_all = "kebab-case")] @@ -766,7 +811,7 @@ enum CommandReport { #[serde(rename = "mutations_synthesize")] MutationSynthesis(MutationSynthesisReport), #[serde(rename = "mutations_list")] - MutationList(Vec), + MutationList(MutationListReport), #[serde(rename = "mutations_show")] MutationShow(MutationDetails), #[serde(rename = "mutations_transition")] @@ -785,6 +830,10 @@ enum CommandReport { MutationInstall(MutationInstallReport), #[serde(rename = "mutations_uninstall")] MutationUninstall(InstallationTransitionOutcome), + #[serde(rename = "genome_export")] + GenomeExport(GenomeExportReport), + #[serde(rename = "genome_import")] + GenomeImport(GenomeImportReport), Export(Vec), Prune(PruneSummary), DeleteSession(DeleteSummary), @@ -816,6 +865,22 @@ impl Serialize for SearchReport { } } +/// Registered candidates plus the set of ids carrying imported attestations. +/// +/// The imported flag is a text-only affordance: the JSON surface stays the bare +/// record array (like [`SearchReport`]), so machine consumers are unchanged. +#[derive(Debug)] +struct MutationListReport { + mutations: Vec, + imported: BTreeSet, +} + +impl Serialize for MutationListReport { + fn serialize(&self, serializer: S) -> Result { + self.mutations.serialize(serializer) + } +} + #[derive(Debug, Serialize)] struct ReindexReport { index_tool_input: bool, @@ -1011,6 +1076,55 @@ struct MutationInstallReport { transition: Option, } +#[derive(Debug, Serialize)] +struct GenomeExportReport { + path: String, + genome_id: String, + mutation_id: String, + event_count: usize, + attestation_count: usize, + redacted_fields: u64, +} + +/// Import outcome for the mutation itself, mirroring the store's registration +/// verdicts plus a dry-run preview form. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum GenomeMutationOutcome { + /// The candidate was newly registered (or would be, in a dry run). + Registered, + /// The identical package already existed: a no-op. + Duplicate, + /// An equivalent candidate already exists under another id; nothing written. + EquivalentExisting, + /// The same id exists with different content; nothing written. + ContentConflict, +} + +#[derive(Debug, Serialize)] +struct ImportedAttestation { + kind: String, + id: String, + passed: bool, + hash_verified: bool, + /// Whether it was newly stored (false for a duplicate or a dry run). + stored: bool, +} + +#[derive(Debug, Serialize)] +struct GenomeImportReport { + dry_run: bool, + genome_id: String, + origin_instance: String, + mutation_id: String, + mutation_outcome: GenomeMutationOutcome, + #[serde(skip_serializing_if = "Option::is_none")] + equivalent_mutation_id: Option, + events_inserted: usize, + events_duplicate: usize, + attestations: Vec, +} + #[derive(Debug, Serialize)] #[serde(untagged)] enum ImportReport { @@ -1056,6 +1170,8 @@ impl CommandReport { | Self::MutationShadowDraft(_) | Self::MutationInstall(_) | Self::MutationUninstall(_) + | Self::GenomeExport(_) + | Self::GenomeImport(_) | Self::Export(_) | Self::Prune(_) | Self::DeleteSession(_) @@ -1246,6 +1362,27 @@ enum CliError { }, #[error("id prefix '{query}' is ambiguous; it matches {matches}")] AmbiguousId { query: String, matches: String }, + #[error(transparent)] + GenomeBuild(#[from] autophagy_genome::GenomeBuildError), + #[error(transparent)] + GenomeParse(#[from] autophagy_genome::GenomeParseError), + #[error(transparent)] + Redaction(#[from] autophagy_redaction::PrivacyError), + #[error( + "genome evidence event '{event_id}' conflicts with a different local event of the same id; \ + its evidence integrity cannot be satisfied, so the import was aborted with nothing written" + )] + GenomeEvidenceConflict { + /// The conflicting evidence event's identity. + event_id: String, + }, + #[error( + "genome evidence event '{0}' is excluded by the local path policy; \ + its evidence cannot be stored under import.exclude_paths, so the import was aborted" + )] + GenomeExcludedEvidence(String), + #[error("genome cites evidence event '{0}', which is not present locally to export")] + GenomeMissingEvidence(String), } /// The built-in reference manifest for the offline `deterministic` provider. @@ -1422,6 +1559,99 @@ fn write_mutation_efficacy( ) } +/// Render the origin-claimed attestation section of `mutations show`. +/// +/// The wording is deliberately blunt: these reports were produced by the +/// genome's origin, not reproduced locally, and they do not advance the +/// lifecycle. `integrity` reports only whether the carried report bytes still +/// hash to their carried fingerprint (a transit check), never a re-verification. +fn write_mutation_attestations( + writer: &mut impl Write, + attestations: &[MutationAttestationRecord], +) -> io::Result<()> { + if attestations.is_empty() { + return Ok(()); + } + writeln!(writer)?; + writeln!( + writer, + "attestations (origin-claimed, not locally verified — re-run challenge, replay, and shadow locally to activate):" + )?; + for attestation in attestations { + writeln!( + writer, + " {} from {}\tclaimed_passed={} · integrity={}", + attestation.kind, + attestation.origin_instance, + attestation.passed, + if attestation.hash_verified { + "ok" + } else { + "FAILED" + }, + )?; + } + Ok(()) +} + +/// Render the `genome import` result: mutation verdict, event counts, and each +/// origin-claimed attestation with its honest status. +fn write_genome_import(writer: &mut impl Write, report: &GenomeImportReport) -> io::Result<()> { + let plan = if report.dry_run { " (dry run)" } else { "" }; + let verdict = match report.mutation_outcome { + GenomeMutationOutcome::Registered => { + if report.dry_run { + "would register as a fresh candidate".to_owned() + } else { + "registered as a fresh candidate".to_owned() + } + } + GenomeMutationOutcome::Duplicate => "already present (no-op)".to_owned(), + GenomeMutationOutcome::EquivalentExisting => { + report.equivalent_mutation_id.as_deref().map_or_else( + || "equivalent candidate already exists; nothing written".to_owned(), + |id| format!("equivalent to {}; nothing written", short_id(id)), + ) + } + GenomeMutationOutcome::ContentConflict => { + "same id exists with different content; nothing written".to_owned() + } + }; + writeln!( + writer, + "{}\tfrom {}\t{verdict}{plan}", + short_id(&report.mutation_id), + report.origin_instance, + )?; + writeln!( + writer, + " evidence: {} new · {} already present", + report.events_inserted, report.events_duplicate + )?; + for attestation in &report.attestations { + writeln!( + writer, + " {} attestation {}\tclaimed_passed={} · integrity={} · {}", + attestation.kind, + short_id(&attestation.id), + attestation.passed, + if attestation.hash_verified { + "ok" + } else { + "FAILED" + }, + if attestation.stored { + "stored (origin-claimed, not verified locally)" + } else if report.dry_run { + "would be stored (origin-claimed)" + } else { + "not stored" + }, + )?; + } + Ok(()) +} + /// Rewrite an absolute project path into a `~`-relative form when it lives under /// the user's home directory, so session rows stay short without becoming /// ambiguous. Paths outside home (and non-UTF-8 homes) are returned unchanged. @@ -1933,6 +2163,7 @@ fn execute( Commands::Mutations { action } => { execute_mutation_action(cli.database, action, leaf, config) } + Commands::Genome { action } => execute_genome_action(cli.database, action, config), Commands::Export { project } => { let database = resolve_database_path(cli.database)?; let store = open_store(&database)?; @@ -2331,7 +2562,10 @@ fn execute_mutation_action( current_states, })) } - MutationAction::List => Ok(CommandReport::MutationList(store.list_mutations()?)), + MutationAction::List => Ok(CommandReport::MutationList(MutationListReport { + mutations: store.list_mutations()?, + imported: store.mutations_with_attestations()?, + })), MutationAction::Show { mutation_id } => { let mutation_id = resolve_mutation_id(&store, &mutation_id)?; Ok(CommandReport::MutationShow( @@ -2734,6 +2968,343 @@ fn execute_mutation_action( } } +/// Every exact evidence id a report cites, gathered from its `results` array. +fn report_event_ids(report: &serde_json::Value) -> impl Iterator + '_ { + report + .get("results") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|result| { + result + .get("source_event_ids") + .and_then(serde_json::Value::as_array) + }) + .flatten() + .filter_map(serde_json::Value::as_str) + .map(str::to_owned) +} + +/// Compile the redaction policy used to gate both genome export and import from +/// the effective `import.exclude_paths`. +fn genome_policy(config: &config::Config) -> Result { + let exclude = config.import_exclude_paths.clone().unwrap_or_default(); + Ok(PrivacyPolicy::new(&exclude)?) +} + +/// Origin identity for an exported genome: the distinct source instance keys the +/// evidence came from, joined stably. A single-source mutation (the common case) +/// yields exactly that source's key. +fn origin_instance_key(store: &EventStore, events: &[Event]) -> Result { + let mut sessions = BTreeSet::new(); + for event in events { + sessions.insert(event.session_id.as_str().to_owned()); + } + let mut instances = BTreeSet::new(); + for session_id in &sessions { + if let Some(summary) = store.get_session(session_id)? { + instances.insert(summary.instance_key); + } + } + if instances.is_empty() { + Ok("unknown".to_owned()) + } else { + Ok(instances.into_iter().collect::>().join("+")) + } +} + +/// Write a genome bundle as pretty JSON with a trailing newline, refusing to +/// clobber an existing file unless `force` (the draft-export house style). +fn write_bundle(path: &Path, bundle: &GenomeBundle, force: bool) -> Result<(), CliError> { + let mut options = fs::OpenOptions::new(); + options.write(true); + if force { + options.create(true).truncate(true); + } else { + options.create_new(true); + } + let mut destination = options.open(path)?; + serde_json::to_writer_pretty(&mut destination, bundle)?; + writeln!(destination)?; + Ok(()) +} + +#[allow(clippy::too_many_lines)] +fn execute_genome_action( + database: Option, + action: GenomeAction, + config: &config::Config, +) -> Result { + let database = resolve_database_path(database)?; + match action { + GenomeAction::Export { + mutation_id, + out, + force, + } => { + let store = open_store(&database)?; + let mutation_id = resolve_mutation_id(&store, &mutation_id)?; + let details = store.get_mutation(&mutation_id)?; + let package: MutationPackage = + serde_json::from_value(details.mutation.package.clone())?; + + // The union of every exact evidence id the hypothesis and the + // verification reports cite. These events must travel so the FK wall + // and content-hash-locked reports stay valid on the receiver. + let mut event_ids: BTreeSet = BTreeSet::new(); + event_ids.extend(package.hypothesis.supporting_event_ids.iter().cloned()); + event_ids.extend(package.hypothesis.counterexample_event_ids.iter().cloned()); + for replay in &details.replays { + event_ids.extend(report_event_ids(&replay.report)); + } + for shadow in &details.shadows { + event_ids.extend(report_event_ids(&shadow.report)); + } + let mut events = Vec::with_capacity(event_ids.len()); + for event_id in &event_ids { + let event = store + .get_event(event_id)? + .ok_or_else(|| CliError::GenomeMissingEvidence(event_id.clone()))?; + events.push(event); + } + + let mut attestations = Vec::new(); + for replay in &details.replays { + attestations.push(AttestationInput { + kind: AttestationKind::Replay, + id: replay.replay_id.clone(), + set_hash: replay.scenario_set_hash.clone(), + report: replay.report.clone(), + passed: replay.passed, + created_at: replay.created_at.clone(), + }); + } + for shadow in &details.shadows { + attestations.push(AttestationInput { + kind: AttestationKind::Shadow, + id: shadow.shadow_id.clone(), + set_hash: shadow.observation_set_hash.clone(), + report: shadow.report.clone(), + passed: shadow.passed, + created_at: shadow.created_at.clone(), + }); + } + let transitions = details + .transitions + .iter() + .map(|transition| GenomeTransition { + from_state: transition.from_state.clone(), + to_state: transition.to_state.clone(), + reason: transition.reason.clone(), + occurred_at: transition.occurred_at.clone(), + }) + .collect(); + + let policy = genome_policy(config)?; + let source = GenomeSource { + origin_instance_key: origin_instance_key(&store, &events)?, + autophagy_version: env!("CARGO_PKG_VERSION").to_owned(), + exported_at: OffsetDateTime::now_utc().format(&Rfc3339)?, + package, + events, + attestations, + transitions, + }; + let bundle = build_genome(source, &policy)?; + write_bundle(&out, &bundle, force)?; + Ok(CommandReport::GenomeExport(GenomeExportReport { + path: out.to_string_lossy().into_owned(), + genome_id: bundle.genome_id.clone(), + mutation_id, + event_count: bundle.evidence_events.len(), + attestation_count: bundle.attestations.len(), + redacted_fields: bundle.redaction.redacted_fields, + })) + } + GenomeAction::Import { path, dry_run } => { + let bytes = fs::read(&path)?; + let bundle = parse_genome(&bytes)?; + let policy = genome_policy(config)?; + let mut store = open_store(&database)?; + + // Re-run the local redaction policy over every event before it is + // stored — the receiver never trusts that the sender redacted + // correctly. A path-excluded event aborts the import. + let mut sanitized = Vec::with_capacity(bundle.evidence_events.len()); + for event in &bundle.evidence_events { + match policy.apply(event).event { + Some(event) => sanitized.push(event), + None => { + return Err(CliError::GenomeExcludedEvidence( + event.event_id.as_str().to_owned(), + )); + } + } + } + + let package: MutationPackage = bundle.mutation.clone(); + let attestation_previews = bundle + .attestations + .iter() + .map(|attestation| ImportedAttestation { + kind: attestation.kind.as_str().to_owned(), + id: attestation.id.clone(), + passed: attestation.passed, + hash_verified: attestation.hash_matches(), + stored: false, + }) + .collect::>(); + + if dry_run { + let (outcome, equivalent) = preview_mutation_outcome(&store, &package)?; + let mut events_inserted = 0; + let mut events_duplicate = 0; + for event in &sanitized { + if store.get_event(event.event_id.as_str())?.is_some() { + events_duplicate += 1; + } else { + events_inserted += 1; + } + } + return Ok(CommandReport::GenomeImport(GenomeImportReport { + dry_run: true, + genome_id: bundle.genome_id, + origin_instance: bundle.origin.instance_key, + mutation_id: package.mutation_id, + mutation_outcome: outcome, + equivalent_mutation_id: equivalent, + events_inserted, + events_duplicate, + attestations: attestation_previews, + })); + } + + // Ingest evidence through the normal path: validation, idempotency, + // and quarantine semantics are preserved. A conflicting reuse of an + // event id aborts the import cleanly. + let mut events_inserted = 0; + let mut events_duplicate = 0; + for event in &sanitized { + let identity = SourceIdentity::new( + event.source.clone(), + genome_instance(&bundle.origin.instance_key), + ); + match store.insert_event(&identity, event, &SearchProjection::default())? { + InsertOutcome::Inserted { .. } => events_inserted += 1, + InsertOutcome::Duplicate { .. } => events_duplicate += 1, + InsertOutcome::ConflictQuarantined { .. } => { + return Err(CliError::GenomeEvidenceConflict { + event_id: event.event_id.as_str().to_owned(), + }); + } + } + } + + let registration = 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: equivalence_key(&package), + spec_version: package.spec_version.as_str().to_owned(), + semantic_version: package.version.clone(), + package: serde_json::to_value(&package)?, + supporting_event_ids: package.hypothesis.supporting_event_ids.clone(), + counterexample_event_ids: package.hypothesis.counterexample_event_ids.clone(), + }; + let (outcome, equivalent, attach) = match store.register_mutation(®istration) { + Ok(MutationRegisterOutcome::Inserted { .. }) => { + (GenomeMutationOutcome::Registered, None, true) + } + Ok(MutationRegisterOutcome::Duplicate { .. }) => { + (GenomeMutationOutcome::Duplicate, None, true) + } + Ok(MutationRegisterOutcome::EquivalentExisting { + existing_mutation_id, + .. + }) => ( + GenomeMutationOutcome::EquivalentExisting, + Some(existing_mutation_id), + false, + ), + Err(StoreError::MutationContentConflict { .. }) => { + (GenomeMutationOutcome::ContentConflict, None, false) + } + Err(error) => return Err(error.into()), + }; + + // Attestations only attach when the bundle's own mutation now exists + // locally (registered fresh or an identical duplicate). They are + // display-only and never advance the lifecycle. + let mut attestations = attestation_previews; + if attach { + for (index, attestation) in bundle.attestations.iter().enumerate() { + let content_hash: [u8; 32] = + Sha256::digest(serde_json::to_vec(&attestation.report_json)?).into(); + let stored = matches!( + store.register_attestation(&AttestationRegistration { + mutation_id: package.mutation_id.clone(), + kind: attestation.kind.as_str().to_owned(), + origin_instance: bundle.origin.instance_key.clone(), + set_hash: attestation.set_hash.clone(), + report: attestation.report_json.clone(), + content_hash, + passed: attestation.passed, + hash_verified: attestation.hash_matches(), + })?, + AttestationRegisterOutcome::Inserted { .. } + ); + attestations[index].stored = stored; + } + } + + Ok(CommandReport::GenomeImport(GenomeImportReport { + dry_run: false, + genome_id: bundle.genome_id, + origin_instance: bundle.origin.instance_key, + mutation_id: package.mutation_id, + mutation_outcome: outcome, + equivalent_mutation_id: equivalent, + events_inserted, + events_duplicate, + attestations, + })) + } + } +} + +/// The genome source instance key for imported evidence: `genome:`. +fn genome_instance(origin_instance_key: &str) -> String { + format!("genome:{origin_instance_key}") +} + +/// Predict, without writing, which registration verdict a package would receive, +/// mirroring `register_mutation`'s exact precedence (id match first, then +/// equivalence-key / source-finding collision). +fn preview_mutation_outcome( + store: &EventStore, + package: &MutationPackage, +) -> Result<(GenomeMutationOutcome, Option), CliError> { + let equivalence = equivalence_key(package); + let package_value = serde_json::to_value(package)?; + for record in store.list_mutations()? { + if record.mutation_id == package.mutation_id { + if record.package == package_value { + return Ok((GenomeMutationOutcome::Duplicate, None)); + } + return Ok((GenomeMutationOutcome::ContentConflict, None)); + } + if record.equivalence_key == equivalence + || record.source_finding_id == package.source_finding_id + { + return Ok(( + GenomeMutationOutcome::EquivalentExisting, + Some(record.mutation_id), + )); + } + } + Ok((GenomeMutationOutcome::Registered, None)) +} + fn install_report( plan: &SkillPlan, dry_run: bool, @@ -2882,14 +3453,23 @@ fn write_report( CommandReport::MutationSynthesis(report) => { write_mutation_synthesis(&mut writer, report)?; } - CommandReport::MutationList(mutations) => { - if mutations.is_empty() { + CommandReport::MutationList(report) => { + if report.mutations.is_empty() { writeln!(writer, "no registered mutation candidates")?; } - for mutation in mutations { + for mutation in &report.mutations { + // An "(imported)" tag marks candidates carrying origin-claimed + // attestations, so a reviewer knows the reports were not + // produced locally and the candidate still needs local + // re-verification. + let imported = if report.imported.contains(&mutation.mutation_id) { + "\t(imported)" + } else { + "" + }; writeln!( writer, - "{}\t{}\t{}", + "{}\t{}\t{}{imported}", mutation.mutation_id, mutation.state, mutation.package["title"].as_str().unwrap_or("untitled") @@ -2956,7 +3536,19 @@ fn write_report( latest.efficacy_id, )?; } + write_mutation_attestations(&mut writer, &details.attestations)?; } + CommandReport::GenomeExport(report) => writeln!( + writer, + "{}\texported {}\t{} events · {} attestations · {} fields redacted\t{}", + report.genome_id, + short_id(&report.mutation_id), + report.event_count, + report.attestation_count, + report.redacted_fields, + report.path, + )?, + CommandReport::GenomeImport(report) => write_genome_import(&mut writer, report)?, CommandReport::MutationTransition(report) => writeln!( writer, "{}\t{} -> {}\tchanged={}", diff --git a/crates/autophagy-cli/tests/cli.rs b/crates/autophagy-cli/tests/cli.rs index 7fd5b77..c9b67cb 100644 --- a/crates/autophagy-cli/tests/cli.rs +++ b/crates/autophagy-cli/tests/cli.rs @@ -2748,3 +2748,233 @@ fn propose_and_synthesize_survive_immutable_template_conflicts() { .contains(&mutation_id)) ); } + +/// Drive one mutation to `shadow_passed` (candidate → challenge → replay → +/// shadow) so it carries both a replay and a shadow report, then export it as a +/// genome, import it into a fresh database, and confirm the round-trip preserves +/// evidence and carries the reports as display-only attestations. +#[test] +#[allow(clippy::too_many_lines)] +fn genome_export_import_round_trip_preserves_evidence_and_attestations() { + let directory = tempfile::tempdir().expect("temporary directory"); + let origin_db = directory.path().join("origin.db"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../evals/fixtures/findings/deterministic.jsonl"); + run_json( + &origin_db, + ["import", fixture.to_str().expect("UTF-8 path")], + ); + run_json(&origin_db, ["mutations", "propose"]); + + let registry = run_json(&origin_db, ["mutations", "list"]); + let mutation_id = registry["result"] + .as_array() + .expect("registry") + .iter() + .find(|mutation| mutation["source_detector"] == "repeated_command_failure") + .expect("failure mutation")["mutation_id"] + .as_str() + .expect("mutation id") + .to_owned(); + + run_json( + &origin_db, + [ + "mutations", + "challenge", + &mutation_id, + "--check", + "coincidence-considered", + "--check", + "sessions-comparable", + "--check", + "trigger-observable", + "--check", + "legitimate-uses-bounded", + "--check", + "equivalent-searched", + "--check", + "counterexamples-reviewed", + ], + ); + let passing_replay = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../evals/fixtures/replay/command-preflight-pass.json"); + run_json( + &origin_db, + [ + "mutations", + "replay", + &mutation_id, + "--suite", + passing_replay.to_str().expect("UTF-8 path"), + ], + ); + let passing_shadow = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../evals/fixtures/shadow/command-preflight-pass.json"); + run_json( + &origin_db, + [ + "mutations", + "shadow", + &mutation_id, + "--suite", + passing_shadow.to_str().expect("UTF-8 path"), + ], + ); + + // Export the shadow-passed mutation as a genome. + let bundle_path = directory.path().join("mutation.genome.json"); + let exported = run_json( + &origin_db, + [ + "genome", + "export", + &mutation_id, + "--out", + bundle_path.to_str().expect("UTF-8 path"), + ], + ); + assert_eq!(exported["command"], "genome_export"); + assert!( + exported["result"]["event_count"].as_u64().expect("events") >= 1, + "genome must carry evidence events" + ); + assert_eq!(exported["result"]["attestation_count"], 2); + + // The bundle is a schema-shaped file: correct envelope, both attestations, + // and lifecycle state that does NOT travel. + let bundle: Value = + serde_json::from_slice(&fs::read(&bundle_path).expect("read bundle")).expect("bundle JSON"); + assert_eq!(bundle["spec_version"], "genome/0.1"); + assert!( + bundle["genome_id"] + .as_str() + .expect("genome id") + .starts_with("gen_") + ); + let kinds: Vec<&str> = bundle["attestations"] + .as_array() + .expect("attestations") + .iter() + .map(|attestation| attestation["kind"].as_str().expect("kind")) + .collect(); + assert!(kinds.contains(&"replay") && kinds.contains(&"shadow")); + + // Re-export without --force refuses to clobber the existing file. + let refused = command(&origin_db) + .args([ + "genome", + "export", + &mutation_id, + "--out", + bundle_path.to_str().expect("UTF-8 path"), + ]) + .output() + .expect("genome export no force"); + assert!( + !refused.status.success(), + "genome export must not overwrite without --force" + ); + + // Import into a fresh, empty database. A dry run writes nothing. + let receiver_db = directory.path().join("receiver.db"); + let dry = run_json( + &receiver_db, + [ + "genome", + "import", + bundle_path.to_str().expect("UTF-8 path"), + "--dry-run", + ], + ); + assert_eq!(dry["command"], "genome_import"); + assert_eq!(dry["result"]["dry_run"], true); + assert_eq!(dry["result"]["mutation_outcome"], "registered"); + // A dry run must not create the mutation. + let empty = run_json(&receiver_db, ["mutations", "list"]); + assert!(empty["result"].as_array().expect("empty list").is_empty()); + + // The real import registers a fresh candidate and stores both attestations. + let imported = run_json( + &receiver_db, + [ + "genome", + "import", + bundle_path.to_str().expect("UTF-8 path"), + ], + ); + assert_eq!(imported["result"]["mutation_outcome"], "registered"); + assert_eq!(imported["result"]["mutation_id"], mutation_id.as_str()); + assert!( + imported["result"]["events_inserted"] + .as_u64() + .expect("inserted") + >= 1 + ); + assert_eq!( + imported["result"]["attestations"] + .as_array() + .expect("attestations") + .len(), + 2 + ); + assert!( + imported["result"]["attestations"] + .as_array() + .expect("attestations") + .iter() + .all( + |attestation| attestation["hash_verified"] == true && attestation["stored"] == true + ) + ); + + // The candidate lands as `candidate` — lifecycle state never travels — and + // its show output carries the origin-claimed attestations honestly. + let shown = run_json(&receiver_db, ["mutations", "show", &mutation_id]); + assert_eq!(shown["result"]["mutation"]["state"], "candidate"); + assert_eq!( + shown["result"]["attestations"] + .as_array() + .expect("attestations") + .len(), + 2 + ); + + // The text `show` labels attestations as origin-claimed, not verified. + let shown_text = command(&receiver_db) + .args(["mutations", "show", &mutation_id]) + .output() + .expect("show text"); + assert!(shown_text.status.success()); + let shown_text = String::from_utf8_lossy(&shown_text.stdout); + assert!( + shown_text.contains("origin-claimed, not locally verified"), + "show must label attestations honestly: {shown_text}" + ); + + // `mutations list` marks the imported candidate. + let listed = command(&receiver_db) + .args(["mutations", "list"]) + .output() + .expect("list text"); + assert!(String::from_utf8_lossy(&listed.stdout).contains("(imported)")); + + // Re-importing the same bundle is idempotent: the mutation is a duplicate + // and the attestations are already present. + let reimported = run_json( + &receiver_db, + [ + "genome", + "import", + bundle_path.to_str().expect("UTF-8 path"), + ], + ); + assert_eq!(reimported["result"]["mutation_outcome"], "duplicate"); + assert!( + reimported["result"]["attestations"] + .as_array() + .expect("attestations") + .iter() + .all(|attestation| attestation["stored"] == false) + ); +} diff --git a/crates/autophagy-genome/Cargo.toml b/crates/autophagy-genome/Cargo.toml new file mode 100644 index 0000000..3e31856 --- /dev/null +++ b/crates/autophagy-genome/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "autophagy-genome" +description = "Portable, redaction-gated mutation genome bundle format for Autophagy" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +autophagy-events = { path = "../autophagy-events" } +autophagy-mutations = { path = "../autophagy-mutations" } +autophagy-redaction = { path = "../autophagy-redaction" } +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true + +[dev-dependencies] +jsonschema = { version = "0.47", default-features = false } + +[lints] +workspace = true diff --git a/crates/autophagy-genome/src/lib.rs b/crates/autophagy-genome/src/lib.rs new file mode 100644 index 0000000..a2f74d7 --- /dev/null +++ b/crates/autophagy-genome/src/lib.rs @@ -0,0 +1,253 @@ +//! Portable, redaction-gated mutation genome bundle format (`genome/0.1`). +//! +//! A genome is a single self-contained JSON file that carries one verified +//! mutation candidate from the machine that produced it to another. This crate +//! owns the wire format only: it builds a bundle from local materials (applying +//! the redaction gate), and parses and integrity-checks a bundle on the way in. +//! It performs no storage or network I/O — the CLI orchestrates gathering from +//! and ingesting into the store. See ADR 0016 and `docs/specs/genome/0.1`. +//! +//! Two invariants make the bundle safe to share and safe to trust: +//! +//! - **Redaction gate.** Every event runs through [`autophagy_redaction`]'s +//! policy at export; a path-excluded event ABORTS the export (dropping it +//! silently would break the content-hash-locked reports that cite it). The +//! mutation's reviewable text is scrubbed with the same secret rules. +//! - **Trust is not transplanted.** The bundle carries verification reports as +//! display-only attestations, never lifecycle state. The receiver re-verifies +//! locally to advance the candidate. + +mod model; + +use autophagy_events::Event; +use autophagy_mutations::MutationPackage; +use autophagy_redaction::{PrivacyError, PrivacyPolicy}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +pub use model::{ + AttestationKind, GENOME_SPEC_VERSION, GenomeAttestation, GenomeBundle, GenomeOrigin, + GenomeRedaction, GenomeTransition, POLICY_DESCRIPTION, +}; + +/// Local materials for one genome, before redaction and assembly. +pub struct GenomeSource { + /// Stable identity of the exporting instance. + pub origin_instance_key: String, + /// The exporting binary's version string. + pub autophagy_version: String, + /// RFC 3339 export timestamp. + pub exported_at: String, + /// The mutation package to export (reviewable text is scrubbed by `build`). + pub package: MutationPackage, + /// Every event cited by the hypothesis and the attestations, loaded from the + /// store. `build` re-runs the redaction policy over each one. + pub events: Vec, + /// The origin's verification reports to carry as attestations. + pub attestations: Vec, + /// The origin lifecycle transition history, for display context. + pub transitions: Vec, +} + +/// One verification report to carry as an attestation. `build` fingerprints the +/// report bytes; the caller supplies the report and its metadata. +pub struct AttestationInput { + /// Evaluation family. + pub kind: AttestationKind, + /// Stable report identity from the origin. + pub id: String, + /// Stable scenario/observation set hash. + pub set_hash: String, + /// The complete versioned report. + pub report: Value, + /// Whether the origin claimed the evaluation passed. + pub passed: bool, + /// RFC 3339 timestamp the origin recorded the report. + pub created_at: String, +} + +/// Failure building a genome bundle. +#[derive(Debug, thiserror::Error)] +pub enum GenomeBuildError { + /// An event was excluded by path policy. Exporting it redacted would drop it + /// silently and break every report that cites it, so the export aborts and + /// names the event instead. + #[error( + "event '{event_id}' is excluded by the current path policy; \ + it anchors evidence this genome must carry, so export cannot continue \ + (adjust import.exclude_paths or export a mutation with different evidence)" + )] + PathExcludedEvent { + /// The excluded event's identity. + event_id: String, + }, + /// The path policy could not be compiled from the configured exclusions. + #[error(transparent)] + Policy(#[from] PrivacyError), + /// The scrubbed mutation package could not be re-materialized. + #[error("scrubbed mutation package is no longer a valid package: {0}")] + PackageReserialize(serde_json::Error), + /// Canonical bundle serialization failed. + #[error("could not serialize genome bundle: {0}")] + Serialization(#[from] serde_json::Error), +} + +/// Failure parsing or integrity-checking a genome bundle. +#[derive(Debug, thiserror::Error)] +pub enum GenomeParseError { + /// The bytes were not a valid genome bundle. + #[error("could not parse genome bundle: {0}")] + Json(#[from] serde_json::Error), + /// The bundle declared an unsupported contract version. + #[error("unsupported genome spec version '{found}', expected '{expected}'")] + UnsupportedSpecVersion { + /// The version the bundle declared. + found: String, + /// The version this binary understands. + expected: &'static str, + }, + /// The declared `genome_id` did not match the recomputed content id, so the + /// bundle was altered after export. + #[error("genome content id mismatch: declared '{declared}', computed '{computed}'")] + GenomeIdMismatch { + /// The id the bundle declared. + declared: String, + /// The id recomputed from the bundle content. + computed: String, + }, +} + +/// Build a redacted genome bundle from local materials. +/// +/// Applies the redaction gate to every event (aborting on a path-excluded +/// event), scrubs the mutation's reviewable text with the same secret rules, +/// fingerprints each attestation report, and stamps the content-derived +/// `genome_id`. +/// +/// # Errors +/// Returns [`GenomeBuildError`] when an event is path-excluded, the policy is +/// invalid, the scrubbed package cannot be re-materialized, or serialization +/// fails. +pub fn build( + source: GenomeSource, + policy: &PrivacyPolicy, +) -> Result { + let mut redacted_fields = 0u64; + let mut evidence_events: Vec = Vec::with_capacity(source.events.len()); + for event in &source.events { + let outcome = policy.apply(event); + match outcome.event { + Some(sanitized) => { + redacted_fields = redacted_fields.saturating_add(outcome.redacted_fields); + evidence_events.push(sanitized); + } + None => { + return Err(GenomeBuildError::PathExcludedEvent { + event_id: event.event_id.as_str().to_owned(), + }); + } + } + } + // Stable order so the content id is reproducible regardless of how the + // caller gathered the events. + evidence_events.sort_by(|left, right| left.event_id.as_str().cmp(right.event_id.as_str())); + + // Scrub reviewable free text (title, hypothesis, intervention instruction, + // exclusions, failure cases) so no credential travels inside prose. + let mut package_value = serde_json::to_value(&source.package)?; + redacted_fields = redacted_fields.saturating_add(policy.scrub_value(&mut package_value)); + let mutation: MutationPackage = + serde_json::from_value(package_value).map_err(GenomeBuildError::PackageReserialize)?; + + let attestations = source + .attestations + .into_iter() + .map(|input| GenomeAttestation { + kind: input.kind, + id: input.id, + set_hash: input.set_hash, + content_hash: report_content_hash_hex(&input.report), + report_json: input.report, + passed: input.passed, + created_at: input.created_at, + }) + .collect(); + + let mut bundle = GenomeBundle { + spec_version: GENOME_SPEC_VERSION.to_owned(), + genome_id: String::new(), + exported_at: source.exported_at, + origin: GenomeOrigin { + instance_key: source.origin_instance_key, + autophagy_version: source.autophagy_version, + }, + mutation, + evidence_events, + attestations, + transitions: source.transitions, + redaction: GenomeRedaction { + redacted_fields, + policy: POLICY_DESCRIPTION.to_owned(), + }, + }; + bundle.genome_id = compute_genome_id(&bundle)?; + Ok(bundle) +} + +/// Parse a genome bundle and verify its structural contract and content id. +/// +/// On success the bundle is well-formed, declares the supported spec version, +/// and its `genome_id` matches the recomputed content id. Per-attestation +/// transit-integrity is checked separately via [`GenomeAttestation::hash_matches`], +/// which is a display concern rather than a parse failure. +/// +/// # Errors +/// Returns [`GenomeParseError`] for malformed bytes, an unsupported spec +/// version, or a content-id mismatch (the bundle was altered after export). +pub fn parse(bytes: &[u8]) -> Result { + let bundle: GenomeBundle = serde_json::from_slice(bytes)?; + if bundle.spec_version != GENOME_SPEC_VERSION { + return Err(GenomeParseError::UnsupportedSpecVersion { + found: bundle.spec_version, + expected: GENOME_SPEC_VERSION, + }); + } + let computed = compute_genome_id(&bundle)?; + if computed != bundle.genome_id { + return Err(GenomeParseError::GenomeIdMismatch { + declared: bundle.genome_id, + computed, + }); + } + Ok(bundle) +} + +/// The content-derived id of a bundle: `gen_` + SHA-256 hex of the bundle's +/// canonical JSON with the `genome_id` field removed. Object keys serialize in +/// sorted order, so the digest is independent of field ordering. +fn compute_genome_id(bundle: &GenomeBundle) -> Result { + let mut value = serde_json::to_value(bundle)?; + if let Some(object) = value.as_object_mut() { + object.remove("genome_id"); + } + let bytes = serde_json::to_vec(&value)?; + Ok(format!("gen_{}", hex_digest(&bytes))) +} + +/// SHA-256 hex of a report value's canonical serialization. Used for the +/// attestation transit-integrity fingerprint at both build and verify time. +#[must_use] +pub fn report_content_hash_hex(report: &Value) -> String { + let bytes = serde_json::to_vec(report).unwrap_or_default(); + hex_digest(&bytes) +} + +fn hex_digest(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} diff --git a/crates/autophagy-genome/src/model.rs b/crates/autophagy-genome/src/model.rs new file mode 100644 index 0000000..154c21f --- /dev/null +++ b/crates/autophagy-genome/src/model.rs @@ -0,0 +1,139 @@ +use autophagy_events::Event; +use autophagy_mutations::MutationPackage; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// The genome bundle contract version this crate builds and parses. +pub const GENOME_SPEC_VERSION: &str = "genome/0.1"; + +/// Human-readable description of the redaction policy applied at export. The +/// exact path exclusions are the receiver-visible `import.exclude_paths`; the +/// secret rules are the built-in conservative set in `autophagy-redaction`. +pub const POLICY_DESCRIPTION: &str = "built-in secret rules + import.exclude_paths"; + +/// A self-contained, redaction-gated mutation genome. +/// +/// One developer exports a verified candidate as this bundle; another imports it +/// as a fresh review-only candidate. The bundle carries the immutable mutation +/// package, the exact (redacted) AEP events its hypothesis and verification +/// reports cite — so the evidence foreign-key wall and content-hash-locked +/// reports stay valid on the receiver — the origin's verification reports as +/// display-only attestations, and the lifecycle transition history for context. +/// The candidate's lifecycle STATE never travels (see ADR 0016). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GenomeBundle { + /// Bundle contract version (`genome/0.1`). + pub spec_version: String, + /// Stable content identity: `gen_` + SHA-256 hex of the canonical bundle + /// content with this field removed. Recomputed and checked on import. + pub genome_id: String, + /// RFC 3339 export timestamp. + pub exported_at: String, + /// Where this genome came from. + pub origin: GenomeOrigin, + /// The complete immutable mutation package, with reviewable text scrubbed. + pub mutation: MutationPackage, + /// The redacted AEP events cited by the hypothesis and the attestations, in + /// stable event-id order. + pub evidence_events: Vec, + /// Origin-claimed, display-only verification reports. + pub attestations: Vec, + /// Origin lifecycle transition history, for context only. + pub transitions: Vec, + /// What redaction did to this bundle. + pub redaction: GenomeRedaction, +} + +/// Stable provenance for an exported genome. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GenomeOrigin { + /// Stable identity of the exporting Autophagy instance. + pub instance_key: String, + /// The exporting binary's version string. + pub autophagy_version: String, +} + +/// A verification evaluation family that can be attested. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AttestationKind { + /// A deterministic replay evaluation. + Replay, + /// An observation-only shadow evaluation. + Shadow, +} + +impl AttestationKind { + /// Stable wire and database representation. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Replay => "replay", + Self::Shadow => "shadow", + } + } +} + +/// One origin-claimed verification report carried by the bundle. +/// +/// An attestation is a museum label. It records that the origin ran a replay or +/// shadow evaluation and what it claimed, so a reviewer can see the provenance; +/// it does NOT let the receiver skip local re-verification. The `content_hash` +/// is a transit-integrity fingerprint over `report_json`, checked on import. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GenomeAttestation { + /// Evaluation family. + pub kind: AttestationKind, + /// Stable report identity from the origin (`rep_…`/`shd_…`). + pub id: String, + /// Stable scenario/observation set hash the origin evaluated. + pub set_hash: String, + /// The complete versioned report exactly as the origin stored it. + pub report_json: Value, + /// SHA-256 hex of the report bytes, for the transit-integrity check. + pub content_hash: String, + /// Whether the origin claimed the evaluation passed. + pub passed: bool, + /// RFC 3339 timestamp the origin recorded the report. + pub created_at: String, +} + +impl GenomeAttestation { + /// Whether `report_json` still hashes to the carried `content_hash`. + /// + /// This is a transit-integrity check only: a match proves the report bytes + /// were not altered after export, never that the receiver reproduced the + /// origin's result. + #[must_use] + pub fn hash_matches(&self) -> bool { + crate::report_content_hash_hex(&self.report_json) == self.content_hash + } +} + +/// One origin lifecycle transition, carried for display only. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GenomeTransition { + /// Previous state; absent for initial generation. + pub from_state: Option, + /// New lifecycle state. + pub to_state: String, + /// Human-readable transition reason. + pub reason: String, + /// RFC 3339 transition timestamp. + pub occurred_at: String, +} + +/// A summary of what redaction did to the bundle at export. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GenomeRedaction { + /// Number of string fields the secret rules changed across events and the + /// package text. + pub redacted_fields: u64, + /// Human-readable description of the policy applied. + pub policy: String, +} diff --git a/crates/autophagy-genome/tests/genome.rs b/crates/autophagy-genome/tests/genome.rs new file mode 100644 index 0000000..a00cd42 --- /dev/null +++ b/crates/autophagy-genome/tests/genome.rs @@ -0,0 +1,217 @@ +//! Bundle build/parse behavior and `genome/0.1` schema conformance. + +use autophagy_events::Event; +use autophagy_genome::{ + AttestationInput, AttestationKind, GENOME_SPEC_VERSION, GenomeBuildError, GenomeParseError, + GenomeSource, GenomeTransition, build, parse, +}; +use autophagy_mutations::MutationPackage; +use autophagy_redaction::PrivacyPolicy; +use serde_json::{Value, json}; + +const BUNDLE_SCHEMA: &str = include_str!("../../../docs/specs/genome/0.1/bundle.schema.json"); +const VALID: &[&str] = &[ + include_str!("../../../docs/specs/genome/0.1/valid/full.json"), + include_str!("../../../docs/specs/genome/0.1/valid/minimal_no_attestations.json"), +]; +const INVALID: &[&str] = &[ + include_str!("../../../docs/specs/genome/0.1/invalid/bad_spec_version.json"), + include_str!("../../../docs/specs/genome/0.1/invalid/bad_genome_id.json"), + include_str!("../../../docs/specs/genome/0.1/invalid/unknown_field.json"), + include_str!("../../../docs/specs/genome/0.1/invalid/missing_redaction.json"), + include_str!("../../../docs/specs/genome/0.1/invalid/bad_attestation_kind.json"), + include_str!("../../../docs/specs/genome/0.1/invalid/bad_content_hash.json"), +]; + +fn package() -> MutationPackage { + let value = json!({ + "spec_version": "mutation/0.1", + "mutation_id": "mut_a87b7c83aa9bf78169c01c2ad73b02c1ad7705f976fc2c94194f397a3091ce9d", + "version": "0.1.0", + "state": "candidate", + "generated_by": "deterministic_template_v1", + "source_finding_id": "find_examplefinding", + "source_detector": "repeated_command_failure", + "title": "Prevent repeated command failure: shell: go build ./...", + "hypothesis": { + "statement": "The recurring go build failure is caused by missing preconditions.", + "expected_result": "Matching go build attempts avoid repeated exit-code-1 failures.", + "supporting_event_ids": ["evt_support_one", "evt_support_two"], + "counterexample_event_ids": [], + "failure_cases": ["The command can fail transiently even when preconditions hold."] + }, + "triggers": [ + { "type": "tool_call", "selector": "failure/v2|shell|go build ./...|exit:1" } + ], + "exclusions": ["Do not block execution; this instruction is advisory."], + "intervention": { + "type": "agent_instruction", + "instruction": "Before running `go build ./...`, verify its required preconditions." + }, + "permissions": { + "filesystem_read": [], "filesystem_write": [], "commands": [], + "environment": [], "network": false + }, + "promotion": { + "minimum_replays": 5, + "minimum_success_rate_bps": 8000, + "maximum_false_positive_rate_bps": 1000 + } + }); + serde_json::from_value(value).expect("package") +} + +fn event(event_id: &str, project: &str) -> Event { + let value = json!({ + "spec_version": "aep/0.1", + "event_id": event_id, + "session_id": "ses_alpha", + "timestamp": "2026-06-01T09:00:00Z", + "source": "claude-code", + "type": "tool.failed", + "project": project, + "tool": { "name": "shell", "input": { "command": "go build ./..." }, "exit_code": 1 } + }); + serde_json::from_value(value).expect("event") +} + +fn source() -> GenomeSource { + GenomeSource { + origin_instance_key: "laptop-abc".to_owned(), + autophagy_version: "0.1.0".to_owned(), + exported_at: "2026-07-19T12:00:00Z".to_owned(), + package: package(), + events: vec![ + event("evt_support_one", "/repo/public"), + event("evt_support_two", "/repo/public"), + ], + attestations: vec![AttestationInput { + kind: AttestationKind::Replay, + id: "rep_examplereplay".to_owned(), + set_hash: "scenarioset0001".to_owned(), + report: json!({ + "spec_version": "replay/0.1", + "replay_id": "rep_examplereplay", + "passed": true + }), + passed: true, + created_at: "2026-07-10T10:00:00Z".to_owned(), + }], + transitions: vec![GenomeTransition { + from_state: None, + to_state: "candidate".to_owned(), + reason: "generated from evidence".to_owned(), + occurred_at: "2026-06-03T09:00:00Z".to_owned(), + }], + } +} + +fn policy(exclude: &[&str]) -> PrivacyPolicy { + let patterns = exclude.iter().map(|p| (*p).to_owned()).collect::>(); + PrivacyPolicy::new(&patterns).expect("policy") +} + +#[test] +fn a_built_bundle_conforms_to_its_own_schema() { + let bundle = build(source(), &policy(&[])).expect("build"); + assert_eq!(bundle.spec_version, GENOME_SPEC_VERSION); + assert!(bundle.genome_id.starts_with("gen_")); + let schema: Value = serde_json::from_str(BUNDLE_SCHEMA).expect("schema JSON"); + let validator = jsonschema::validator_for(&schema).expect("compile schema"); + let instance = serde_json::to_value(&bundle).expect("bundle value"); + assert!( + validator.is_valid(&instance), + "schema rejected a freshly built bundle" + ); +} + +#[test] +fn a_built_bundle_round_trips_through_parse() { + let bundle = build(source(), &policy(&[])).expect("build"); + let bytes = serde_json::to_vec(&bundle).expect("serialize"); + let parsed = parse(&bytes).expect("parse"); + assert_eq!(parsed, bundle); + assert_eq!(parsed.evidence_events.len(), 2); + assert!(parsed.attestations[0].hash_matches()); +} + +#[test] +fn tampering_with_content_is_detected_by_the_genome_id() { + let bundle = build(source(), &policy(&[])).expect("build"); + let mut value = serde_json::to_value(&bundle).expect("value"); + // Alter the mutation title without touching genome_id. + value["mutation"]["title"] = json!("A different, tampered title"); + let bytes = serde_json::to_vec(&value).expect("serialize"); + assert!(matches!( + parse(&bytes), + Err(GenomeParseError::GenomeIdMismatch { .. }) + )); +} + +#[test] +fn tampering_with_an_attestation_report_is_detected_by_its_hash() { + let mut bundle = build(source(), &policy(&[])).expect("build"); + assert!(bundle.attestations[0].hash_matches()); + bundle.attestations[0].report_json["passed"] = json!(false); + assert!( + !bundle.attestations[0].hash_matches(), + "a mutated report must not match its carried content hash" + ); +} + +#[test] +fn a_path_excluded_event_aborts_the_build() { + let mut source = source(); + source.events[1] = event("evt_support_two", "/repo/private/client"); + let error = build(source, &policy(&["**/private/**"])).expect_err("must abort"); + assert!(matches!( + error, + GenomeBuildError::PathExcludedEvent { event_id } if event_id == "evt_support_two" + )); +} + +#[test] +fn secrets_in_package_text_are_scrubbed_and_counted() { + let mut source = source(); + source.package.intervention.instruction = + "Use token sk-abcdefghijklmnop to authenticate before building.".to_owned(); + let bundle = build(source, &policy(&[])).expect("build"); + assert!( + !bundle + .mutation + .intervention + .instruction + .contains("sk-abcdefghijklmnop") + ); + assert!(bundle.redaction.redacted_fields >= 1); +} + +#[test] +fn unsupported_spec_version_is_rejected() { + let bundle = build(source(), &policy(&[])).expect("build"); + let mut value = serde_json::to_value(&bundle).expect("value"); + value["spec_version"] = json!("genome/9.9"); + let bytes = serde_json::to_vec(&value).expect("serialize"); + assert!(matches!( + parse(&bytes), + Err(GenomeParseError::UnsupportedSpecVersion { .. }) + )); +} + +#[test] +fn fixtures_round_trip_through_the_schema() { + let schema: Value = serde_json::from_str(BUNDLE_SCHEMA).expect("schema JSON"); + assert_eq!( + schema["properties"]["spec_version"]["const"], + GENOME_SPEC_VERSION + ); + let validator = jsonschema::validator_for(&schema).expect("compile schema"); + for fixture in VALID { + let instance: Value = serde_json::from_str(fixture).expect("valid fixture JSON"); + assert!(validator.is_valid(&instance), "schema rejected {fixture}"); + } + for fixture in INVALID { + let instance: Value = serde_json::from_str(fixture).expect("invalid fixture JSON"); + assert!(!validator.is_valid(&instance), "schema accepted {fixture}"); + } +} diff --git a/crates/autophagy-redaction/src/lib.rs b/crates/autophagy-redaction/src/lib.rs index 2b5aab6..9dc9972 100644 --- a/crates/autophagy-redaction/src/lib.rs +++ b/crates/autophagy-redaction/src/lib.rs @@ -101,6 +101,37 @@ impl PrivacyPolicy { redacted_fields, } } + + /// Scrub recognized secret material from an arbitrary JSON value in place. + /// + /// Applies the same conservative secret rules as [`apply`](Self::apply), but + /// over any [`serde_json::Value`] rather than a normalized event, and without + /// consulting path policy (a bare value carries no project or artifact path + /// to exclude). Every string reachable in the value — object values, array + /// elements, and nested combinations — is scrubbed. Returns the number of + /// string fields the rules changed, so a caller can report exactly how much + /// was redacted. + /// + /// This is the projection used to scrub the reviewable free text carried by + /// a mutation package (title, hypothesis, intervention instruction) before it + /// leaves the machine in an exported genome, so no credential accidentally + /// travels inside prose the secret rules would otherwise catch in event + /// payloads. + pub fn scrub_value(&self, value: &mut Value) -> u64 { + let mut redacted_fields = 0; + redact_value(value, &self.rules, &mut redacted_fields); + redacted_fields + } + + /// Scrub recognized secret material from a single string in place. + /// + /// The string-level counterpart to [`scrub_value`](Self::scrub_value). + /// Returns `1` when the rules changed the string and `0` otherwise. + pub fn scrub_string(&self, value: &mut String) -> u64 { + let mut redacted_fields = 0; + redact_string(value, &self.rules, &mut redacted_fields); + redacted_fields + } } fn default_rules() -> Vec { @@ -210,6 +241,47 @@ mod tests { assert!(policy.apply(&fixture_event("/repo/public")).event.is_some()); } + #[test] + fn scrub_value_redacts_nested_secrets_and_counts_changes() { + let policy = PrivacyPolicy::new(&[]).expect("policy"); + let mut value = json!({ + "title": "Prevent repeated failure of curl -H 'Authorization: Bearer abcdef0123456789abcdef'", + "steps": ["export API_KEY=abcdefgh12345678", "run sk-abcdefghijklmnop now"], + "count": 3, + "nested": { "token": "ghp_abcdefghijklmnopqrstuvwxyz" } + }); + let redacted = policy.scrub_value(&mut value); + assert_eq!(redacted, 4); + let encoded = serde_json::to_string(&value).expect("JSON"); + assert!(!encoded.contains("sk-abcdefghijklmnop")); + assert!(!encoded.contains("ghp_abcdefghijklmnopqrstuvwxyz")); + assert!(!encoded.contains("Bearer abcdef0123456789abcdef")); + assert!(encoded.contains(REDACTED)); + // Non-string leaves are untouched. + assert_eq!(value["count"], json!(3)); + } + + #[test] + fn scrub_value_leaves_clean_text_and_returns_zero() { + let policy = PrivacyPolicy::new(&[]).expect("policy"); + let mut value = json!({ "title": "Prevent repeated command failure: cargo test" }); + assert_eq!(policy.scrub_value(&mut value), 0); + assert_eq!( + value["title"], + json!("Prevent repeated command failure: cargo test") + ); + } + + #[test] + fn scrub_string_reports_whether_it_changed() { + let policy = PrivacyPolicy::new(&[]).expect("policy"); + let mut clean = "cargo build --release".to_owned(); + assert_eq!(policy.scrub_string(&mut clean), 0); + let mut secret = "token=sk-abcdefghijklmnop".to_owned(); + assert_eq!(policy.scrub_string(&mut secret), 1); + assert!(!secret.contains("sk-abcdefghijklmnop")); + } + fn fixture_event(project: &str) -> Event { Event { spec_version: SpecVersion::V0_1, diff --git a/crates/autophagy-store/migrations/0004_mutation_attestations.sql b/crates/autophagy-store/migrations/0004_mutation_attestations.sql new file mode 100644 index 0000000..3041879 --- /dev/null +++ b/crates/autophagy-store/migrations/0004_mutation_attestations.sql @@ -0,0 +1,37 @@ +-- Origin-claimed verification attestations carried by an imported genome. +-- +-- A team genome (see ADR 0016) is a portable, redaction-gated bundle: one +-- developer exports a verified mutation candidate and another imports it. Trust +-- is deliberately NOT transplanted. The candidate always lands locally as a +-- fresh `candidate` (register_mutation), and the replay/shadow verification +-- reports the origin ran travel alongside it as DISPLAY-ONLY attestations. +-- +-- This table stores those attestations. It is a museum label, not a lifecycle +-- gate: no state-advancing read path (register_replay, register_shadow, install, +-- efficacy) may ever consult it. The receiver must re-run challenge -> replay -> +-- shadow against its own local evidence to advance the candidate. The stored +-- `passed` flag records what the origin claimed; `hash_verified` records only +-- that the bundled report bytes still hash to their carried content hash (a +-- transit-integrity check), never that the receiver reproduced the result. +-- +-- Ordered after post-install efficacy (0003) and, like every migration from the +-- first release onward, immutable — add new migrations, never edit this one. + +CREATE TABLE mutation_attestations ( + attestation_id TEXT PRIMARY KEY CHECK (attestation_id LIKE 'att_%'), + mutation_id TEXT NOT NULL REFERENCES mutation_candidates(mutation_id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('replay', 'shadow')), + origin_instance TEXT NOT NULL, + set_hash TEXT NOT NULL, + report_json TEXT NOT NULL CHECK (json_valid(report_json)), + content_hash BLOB NOT NULL CHECK (length(content_hash) = 32), + passed INTEGER NOT NULL CHECK (passed IN (0, 1)), + hash_verified INTEGER NOT NULL CHECK (hash_verified IN (0, 1)), + imported_at TEXT NOT NULL, + -- One attestation per (mutation, kind, evaluated set): re-importing the same + -- genome is an idempotent no-op rather than a duplicate row. + UNIQUE (mutation_id, kind, set_hash) +) STRICT; + +CREATE INDEX mutation_attestations_candidate + ON mutation_attestations(mutation_id, kind, set_hash); diff --git a/crates/autophagy-store/src/error.rs b/crates/autophagy-store/src/error.rs index e95cddc..cfc0bf5 100644 --- a/crates/autophagy-store/src/error.rs +++ b/crates/autophagy-store/src/error.rs @@ -179,6 +179,12 @@ pub enum StoreError { /// Conflicting efficacy identity. efficacy_id: String, }, + /// An attestation carried an unsupported evaluation kind. + #[error("attestation kind '{kind}' is not one of 'replay' or 'shadow'")] + InvalidAttestationKind { + /// Rejected attestation kind. + kind: String, + }, /// Installation registration violated the supported target contract. #[error("installation registration is invalid")] InvalidInstallationRegistration, diff --git a/crates/autophagy-store/src/lib.rs b/crates/autophagy-store/src/lib.rs index fd06392..2fea563 100644 --- a/crates/autophagy-store/src/lib.rs +++ b/crates/autophagy-store/src/lib.rs @@ -12,13 +12,14 @@ mod util; pub use error::StoreError; pub use model::{ - AdapterActivity, DeleteAllSummary, DeleteSummary, DetectionFingerprint, EfficacyOccurrence, - EfficacyOccurrences, EfficacyRegisterOutcome, EfficacyRegistration, EfficacyStatusSummary, - InsertOutcome, InstallationRegistration, InstallationTransitionOutcome, MutationDetails, - MutationEfficacyRecord, MutationInstallationRecord, MutationRecord, MutationRegisterOutcome, - MutationRegistration, MutationReplayRecord, MutationShadowRecord, MutationTransition, - MutationTransitionOutcome, PruneSummary, RankingExplanation, RankingSignal, RankingSignalKind, - RebuildSummary, ReplayRegisterOutcome, ReplayRegistration, RetrievalFilter, + AdapterActivity, AttestationRegisterOutcome, AttestationRegistration, DeleteAllSummary, + DeleteSummary, DetectionFingerprint, EfficacyOccurrence, EfficacyOccurrences, + EfficacyRegisterOutcome, EfficacyRegistration, EfficacyStatusSummary, InsertOutcome, + InstallationRegistration, InstallationTransitionOutcome, MutationAttestationRecord, + MutationDetails, MutationEfficacyRecord, MutationInstallationRecord, MutationRecord, + MutationRegisterOutcome, MutationRegistration, MutationReplayRecord, MutationShadowRecord, + MutationTransition, MutationTransitionOutcome, PruneSummary, RankingExplanation, RankingSignal, + RankingSignalKind, RebuildSummary, ReplayRegisterOutcome, ReplayRegistration, RetrievalFilter, RetrievalFilterField, RetrievalHit, RetrievalMatchKind, RetrievalOutcome, RetrievalQuery, SearchHit, SearchProjection, SessionSummary, ShadowRegisterOutcome, ShadowRegistration, SourceCursor, SourceIdentity, StoreStats, diff --git a/crates/autophagy-store/src/migration.rs b/crates/autophagy-store/src/migration.rs index 82a312e..b6ccb77 100644 --- a/crates/autophagy-store/src/migration.rs +++ b/crates/autophagy-store/src/migration.rs @@ -42,6 +42,11 @@ const MIGRATIONS: &[Migration] = &[ description: "post-install mutation efficacy tracking", sql: include_str!("../migrations/0003_mutation_efficacy.sql"), }, + Migration { + version: 4, + description: "origin-claimed mutation attestations", + sql: include_str!("../migrations/0004_mutation_attestations.sql"), + }, ]; /// SHA-256 checksums of the eight pre-release development migrations, in order diff --git a/crates/autophagy-store/src/model.rs b/crates/autophagy-store/src/model.rs index e94deb4..c40f509 100644 --- a/crates/autophagy-store/src/model.rs +++ b/crates/autophagy-store/src/model.rs @@ -536,6 +536,76 @@ pub struct MutationDetails { pub installations: Vec, /// Post-install efficacy reports in creation order (oldest first). pub efficacies: Vec, + /// Origin-claimed, display-only verification attestations carried in from an + /// imported genome. These NEVER advance the lifecycle; the receiver must + /// re-verify locally. Empty for locally-generated candidates. + pub attestations: Vec, +} + +/// Owned input for one origin-claimed attestation carried by an imported genome. +/// +/// An attestation is a museum label: it records that the genome's origin ran a +/// replay or shadow evaluation and what that evaluation claimed, so a reviewer +/// can see the provenance. It never advances the local lifecycle — the receiver +/// must re-run the gate against local evidence. See ADR 0016. +#[derive(Clone, Debug, PartialEq)] +pub struct AttestationRegistration { + /// Mutation the attestation is claimed for. Must already exist locally. + pub mutation_id: String, + /// Evaluation family: `replay` or `shadow`. + pub kind: String, + /// Stable identity of the genome's origin (`origin.instance_key`). + pub origin_instance: String, + /// Stable scenario/observation set hash the origin evaluated. + pub set_hash: String, + /// Complete versioned report exactly as carried in the bundle. + pub report: Value, + /// SHA-256 of the carried report bytes, for the transit-integrity check. + pub content_hash: [u8; 32], + /// Whether the origin claimed the evaluation passed. + pub passed: bool, + /// Whether the carried report bytes still hash to `content_hash`. + pub hash_verified: bool, +} + +/// One persisted origin-claimed attestation. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct MutationAttestationRecord { + /// Stable attestation identity (`att_%`). + pub attestation_id: String, + /// Attested mutation identity. + pub mutation_id: String, + /// Evaluation family: `replay` or `shadow`. + pub kind: String, + /// Stable identity of the genome's origin. + pub origin_instance: String, + /// Stable scenario/observation set hash the origin evaluated. + pub set_hash: String, + /// Complete versioned report exactly as imported. + pub report: Value, + /// Whether the origin claimed the evaluation passed. + pub passed: bool, + /// Whether the carried report bytes hashed to their carried content hash on + /// import — a transit-integrity check, NOT a local re-verification. + pub hash_verified: bool, + /// Canonical import timestamp. + pub imported_at: String, +} + +/// Idempotent attestation persistence result. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum AttestationRegisterOutcome { + /// A new attestation row was stored. + Inserted { + /// Stable attestation identity. + attestation_id: String, + }, + /// An attestation for this mutation, kind, and set already existed. + Duplicate { + /// Existing attestation identity. + attestation_id: String, + }, } /// Idempotent lifecycle transition result. diff --git a/crates/autophagy-store/src/store.rs b/crates/autophagy-store/src/store.rs index 3f39089..15a729e 100644 --- a/crates/autophagy-store/src/store.rs +++ b/crates/autophagy-store/src/store.rs @@ -1,4 +1,6 @@ -use std::{collections::BTreeMap, collections::BTreeSet, path::Path, time::Duration}; +use std::{ + collections::BTreeMap, collections::BTreeSet, fmt::Write as _, path::Path, time::Duration, +}; use autophagy_events::{Event, EventKind}; use rusqlite::{ @@ -8,13 +10,14 @@ use rusqlite::{ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ - AdapterActivity, DeleteAllSummary, DeleteSummary, DetectionFingerprint, EfficacyOccurrence, - EfficacyOccurrences, EfficacyRegisterOutcome, EfficacyRegistration, EfficacyStatusSummary, - InsertOutcome, InstallationRegistration, InstallationTransitionOutcome, MutationDetails, - MutationEfficacyRecord, MutationInstallationRecord, MutationRecord, MutationRegisterOutcome, - MutationRegistration, MutationReplayRecord, MutationShadowRecord, MutationTransition, - MutationTransitionOutcome, PruneSummary, RankingExplanation, RankingSignal, RankingSignalKind, - RebuildSummary, ReplayRegisterOutcome, ReplayRegistration, RetrievalFilter, + AdapterActivity, AttestationRegisterOutcome, AttestationRegistration, DeleteAllSummary, + DeleteSummary, DetectionFingerprint, EfficacyOccurrence, EfficacyOccurrences, + EfficacyRegisterOutcome, EfficacyRegistration, EfficacyStatusSummary, InsertOutcome, + InstallationRegistration, InstallationTransitionOutcome, MutationAttestationRecord, + MutationDetails, MutationEfficacyRecord, MutationInstallationRecord, MutationRecord, + MutationRegisterOutcome, MutationRegistration, MutationReplayRecord, MutationShadowRecord, + MutationTransition, MutationTransitionOutcome, PruneSummary, RankingExplanation, RankingSignal, + RankingSignalKind, RebuildSummary, ReplayRegisterOutcome, ReplayRegistration, RetrievalFilter, RetrievalFilterField, RetrievalHit, RetrievalMatchKind, RetrievalQuery, SearchHit, SearchProjection, SessionSummary, ShadowRegisterOutcome, ShadowRegistration, SourceCursor, SourceIdentity, StoreError, StoreStats, migration, util, @@ -1109,6 +1112,21 @@ impl EventStore { rows.map(|row| mutation_record(row?)).collect() } + /// The set of mutation ids carrying at least one origin-claimed attestation. + /// + /// Cheap enough to call alongside [`list_mutations`](Self::list_mutations) so + /// a listing can mark imported candidates without loading each one's audit. + /// + /// # Errors + /// Returns [`StoreError`] for a database failure. + pub fn mutations_with_attestations(&self) -> Result, StoreError> { + let mut statement = self + .connection + .prepare("SELECT DISTINCT mutation_id FROM mutation_attestations")?; + let rows = statement.query_map([], |row| row.get::<_, String>(0))?; + Ok(rows.collect::, _>>()?) + } + /// Return one candidate and its complete lifecycle audit. /// /// # Errors @@ -1237,6 +1255,7 @@ impl EventStore { .map(|row| installation_record(row?)) .collect::, StoreError>>()?; let efficacies = self.efficacy_records(mutation_id)?; + let attestations = self.attestation_records(mutation_id)?; Ok(MutationDetails { mutation, transitions, @@ -1244,9 +1263,132 @@ impl EventStore { shadows, installations, efficacies, + attestations, }) } + /// Every origin-claimed attestation for one mutation, in stable order. + fn attestation_records( + &self, + mutation_id: &str, + ) -> Result, StoreError> { + let mut statement = self.connection.prepare( + "SELECT attestation_id, mutation_id, kind, origin_instance, set_hash, + report_json, passed, hash_verified, imported_at + FROM mutation_attestations WHERE mutation_id = ?1 + ORDER BY kind, set_hash, attestation_id", + )?; + let rows = statement.query_map([mutation_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, bool>(6)?, + row.get::<_, bool>(7)?, + row.get::<_, String>(8)?, + )) + })?; + rows.map(|row| { + let ( + attestation_id, + mutation_id, + kind, + origin_instance, + set_hash, + report, + passed, + hash_verified, + imported_at, + ) = row?; + Ok(MutationAttestationRecord { + attestation_id, + mutation_id, + kind, + origin_instance, + set_hash, + report: serde_json::from_str(&report)?, + passed, + hash_verified, + imported_at, + }) + }) + .collect::, StoreError>>() + } + + /// Persist one origin-claimed, display-only attestation carried by a genome. + /// + /// Attestations never advance the lifecycle (see ADR 0016): this method + /// inserts a museum-label row and performs no state transition. The + /// `UNIQUE(mutation_id, kind, set_hash)` constraint makes re-importing the + /// same genome idempotent — a matching attestation is a no-op + /// [`AttestationRegisterOutcome::Duplicate`]. The mutation must already exist + /// locally; the foreign key rejects an orphan attestation. + /// + /// # Errors + /// Returns [`StoreError`] for an invalid attestation kind, a missing mutation, + /// serialization failure, or a database failure. + pub fn register_attestation( + &mut self, + registration: &AttestationRegistration, + ) -> Result { + if registration.kind != "replay" && registration.kind != "shadow" { + return Err(StoreError::InvalidAttestationKind { + kind: registration.kind.clone(), + }); + } + let attestation_id = attestation_id( + ®istration.mutation_id, + ®istration.kind, + ®istration.set_hash, + ); + let report_json = serde_json::to_string(®istration.report)?; + let now = util::now_timestamp()?; + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + if transaction + .query_row( + "SELECT 1 FROM mutation_candidates WHERE mutation_id = ?1", + [®istration.mutation_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .is_none() + { + return Err(StoreError::MutationNotFound { + mutation_id: registration.mutation_id.clone(), + }); + } + let inserted = transaction.execute( + "INSERT INTO mutation_attestations( + attestation_id, mutation_id, kind, origin_instance, set_hash, + report_json, content_hash, passed, hash_verified, imported_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(mutation_id, kind, set_hash) DO NOTHING", + params![ + attestation_id, + registration.mutation_id, + registration.kind, + registration.origin_instance, + registration.set_hash, + report_json, + registration.content_hash.as_slice(), + registration.passed, + registration.hash_verified, + now, + ], + )?; + transaction.commit()?; + if inserted == 0 { + Ok(AttestationRegisterOutcome::Duplicate { attestation_id }) + } else { + Ok(AttestationRegisterOutcome::Inserted { attestation_id }) + } + } + /// Every efficacy report for one mutation, oldest first. fn efficacy_records( &self, @@ -2343,6 +2485,20 @@ fn insert_mutation_evidence( Ok(()) } +/// Deterministic attestation identity from its natural key. Making the id a +/// pure function of `(mutation_id, kind, set_hash)` means re-importing the same +/// genome derives the same id, so the insert is an idempotent no-op. +fn attestation_id(mutation_id: &str, kind: &str, set_hash: &str) -> String { + let material = format!("attestation/v1\0{mutation_id}\0{kind}\0{set_hash}"); + let digest = util::sha256(material.as_bytes()); + let mut encoded = String::with_capacity(digest.len() * 2 + 4); + encoded.push_str("att_"); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + fn count_mutations(transaction: &Transaction<'_>) -> Result { transaction.query_row("SELECT count(*) FROM mutation_candidates", [], |row| { row.get(0) diff --git a/crates/autophagy-store/tests/legacy_adoption.rs b/crates/autophagy-store/tests/legacy_adoption.rs index 18e7cd4..7064301 100644 --- a/crates/autophagy-store/tests/legacy_adoption.rs +++ b/crates/autophagy-store/tests/legacy_adoption.rs @@ -101,15 +101,15 @@ fn known_legacy_v8_database_is_adopted_and_data_preserved() { // First open through the store adopts the baseline, collapsing the eight // legacy rows to the v1 baseline, then applies the post-baseline chain - // (v2, v3) on top — landing on the current released schema. + // (v2, v3, v4) on top — landing on the current released schema. { let store = EventStore::open(&path).expect("open adopts legacy database"); - assert_eq!(store.schema_version().expect("schema version"), 3); + assert_eq!(store.schema_version().expect("schema version"), 4); } assert_eq!( ledger_state(&path), - (3, 3, 3), + (4, 4, 4), "legacy chain replaced by the released baseline chain and user_version reset" ); assert_eq!( @@ -121,9 +121,9 @@ fn known_legacy_v8_database_is_adopted_and_data_preserved() { // Second open is a pure no-op: same schema, same ledger, data intact. { let store = EventStore::open(&path).expect("second open is a no-op"); - assert_eq!(store.schema_version().expect("schema version"), 3); + assert_eq!(store.schema_version().expect("schema version"), 4); } - assert_eq!(ledger_state(&path), (3, 3, 3)); + assert_eq!(ledger_state(&path), (4, 4, 4)); assert_eq!(data_counts(&path), before); } diff --git a/crates/autophagy-store/tests/store.rs b/crates/autophagy-store/tests/store.rs index edf8585..9b76d10 100644 --- a/crates/autophagy-store/tests/store.rs +++ b/crates/autophagy-store/tests/store.rs @@ -6,12 +6,12 @@ use autophagy_events::{ Artifact, ArtifactKind, Event, EventId, EventKind, SessionId, SpecVersion, ToolCall, }; use autophagy_store::{ - DeleteAllSummary, DeleteSummary, EfficacyRegisterOutcome, EfficacyRegistration, EventStore, - InsertOutcome, InstallationRegistration, InstallationTransitionOutcome, - MutationRegisterOutcome, MutationRegistration, PruneSummary, RebuildSummary, - ReplayRegisterOutcome, ReplayRegistration, RetrievalMatchKind, RetrievalOutcome, - RetrievalQuery, SearchProjection, ShadowRegisterOutcome, ShadowRegistration, SourceCursor, - SourceIdentity, StoreError, StoreStats, + AttestationRegisterOutcome, AttestationRegistration, DeleteAllSummary, DeleteSummary, + EfficacyRegisterOutcome, EfficacyRegistration, EventStore, InsertOutcome, + InstallationRegistration, InstallationTransitionOutcome, MutationRegisterOutcome, + MutationRegistration, PruneSummary, RebuildSummary, ReplayRegisterOutcome, ReplayRegistration, + RetrievalMatchKind, RetrievalOutcome, RetrievalQuery, SearchProjection, ShadowRegisterOutcome, + ShadowRegistration, SourceCursor, SourceIdentity, StoreError, StoreStats, }; use serde_json::{Value, json}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -31,7 +31,7 @@ fn migrations_persist_and_reopen_cleanly() { { let mut store = EventStore::open(&database).expect("store should open"); - assert_eq!(store.schema_version().expect("schema version"), 3); + assert_eq!(store.schema_version().expect("schema version"), 4); assert!(matches!( store .insert_event(&source, &event, &SearchProjection::default()) @@ -41,7 +41,7 @@ fn migrations_persist_and_reopen_cleanly() { } let reopened = EventStore::open(&database).expect("store should reopen"); - assert_eq!(reopened.schema_version().expect("schema version"), 3); + assert_eq!(reopened.schema_version().expect("schema version"), 4); assert_eq!( reopened .get_event(event.event_id.as_str()) @@ -1070,6 +1070,89 @@ fn findings_cache_round_trips_and_collects_stale_generations() { ); } +#[test] +fn attestations_are_display_only_and_idempotent() { + let mut store = EventStore::open_in_memory().expect("store"); + let source = source("instance-attest"); + for (event_id, session_id, timestamp) in [ + ("evt_mutation-support-a", "ses_a", "2026-07-16T06:00:00Z"), + ("evt_mutation-support-b", "ses_b", "2026-07-16T06:01:00Z"), + ("evt_mutation-counter", "ses_c", "2026-07-16T06:02:00Z"), + ] { + store + .insert_event( + &source, + &session_event( + event_id, + session_id, + EventKind::DecisionRecorded, + timestamp, + 0, + ), + &SearchProjection::default(), + ) + .expect("evidence event"); + } + store + .register_mutation(&mutation_registration( + "mut_attest", + "fnd_attest", + "eqv_attest", + )) + .expect("register"); + + let registration = AttestationRegistration { + mutation_id: "mut_attest".to_owned(), + kind: "replay".to_owned(), + origin_instance: "laptop-origin".to_owned(), + set_hash: "scenarioset0001".to_owned(), + report: json!({"replay_id": "rep_x", "passed": true}), + content_hash: [7_u8; 32], + passed: true, + hash_verified: true, + }; + let first = store + .register_attestation(®istration) + .expect("first attestation"); + let attestation_id = match &first { + AttestationRegisterOutcome::Inserted { attestation_id } => attestation_id.clone(), + AttestationRegisterOutcome::Duplicate { .. } => panic!("first insert must be Inserted"), + }; + assert!(attestation_id.starts_with("att_")); + + // Re-registering the same (mutation, kind, set) is an idempotent no-op. + assert_eq!( + store + .register_attestation(®istration) + .expect("re-register"), + AttestationRegisterOutcome::Duplicate { attestation_id } + ); + + // The attestation surfaces on the mutation but never advances its state. + let details = store.get_mutation("mut_attest").expect("details"); + assert_eq!(details.mutation.state, "candidate"); + assert_eq!(details.attestations.len(), 1); + assert_eq!(details.attestations[0].kind, "replay"); + assert_eq!(details.attestations[0].origin_instance, "laptop-origin"); + assert!(details.attestations[0].passed); + assert!(details.attestations[0].hash_verified); + + // An unsupported kind is rejected, and an orphan attestation is refused. + let mut bad_kind = registration.clone(); + bad_kind.kind = "efficacy".to_owned(); + bad_kind.set_hash = "other".to_owned(); + assert!(matches!( + store.register_attestation(&bad_kind), + Err(StoreError::InvalidAttestationKind { .. }) + )); + let mut orphan = registration.clone(); + orphan.mutation_id = "mut_missing".to_owned(); + assert!(matches!( + store.register_attestation(&orphan), + Err(StoreError::MutationNotFound { .. }) + )); +} + fn source(instance_key: &str) -> SourceIdentity { SourceIdentity::new("codex", instance_key).with_display_name("Codex") } diff --git a/docs/decisions/0016-team-genome-export-import.md b/docs/decisions/0016-team-genome-export-import.md new file mode 100644 index 0000000..9ab67a7 --- /dev/null +++ b/docs/decisions/0016-team-genome-export-import.md @@ -0,0 +1,123 @@ +# ADR 0016: Export and import mutation genomes between machines + +- Status: accepted +- Date: 2026-07-19 + +## Context + +Every mutation Autophagy produces is verified locally: it is challenged, then +replayed against annotated counterfactuals, then shadow-measured for trigger +precision, all against one machine's own evidence. That verification is +expensive and human-reviewed, and today it cannot leave the machine that did it. +A team wants the payoff of that work to be shareable — one developer takes a +mutation to `shadow_passed`, and a teammate should be able to pick it up — without +either of them shipping raw session content anywhere or a network appearing in +the default path. + +Three facts of the local store shape what "shareable" can mean: + +- **The evidence foreign-key wall.** `register_mutation` fails unless every + supporting and counterexample `event_id` already exists in `events`, and + deleting a cited event cascade-kills the candidate. A package alone is not + registrable on another machine. +- **Content-hash-locked reports.** Replay and shadow reports embed their exact + `source_event_ids`, and the store checks that set precisely + (`*_report_matches_registration`). Rewriting event ids to anonymize them would + break the hashes. +- **No register-as-verified path.** Lifecycle state advances only through the + gated `register_replay` / `register_shadow`, each of which re-derives from + local evidence. There is deliberately no way to assert a state directly. + +The first two force any portable bundle to carry the **original** events +(redacted). The third means a receiver cannot — and must not — inherit a +lifecycle state; they have to re-verify. + +## Decision + +Add a file-based, offline **team genome** (`genome/0.1`): a single self-contained +JSON bundle (`.genome.json`) that one developer exports and another imports. + +**Bundle format.** A genome carries `spec_version`, a content-derived `genome_id` +(`gen_` + SHA-256 hex of the canonical bundle with the id field removed — +recomputed and checked on import), `exported_at`, an `origin` +(`instance_key`, `autophagy_version`), the full immutable mutation package, the +redacted `evidence_events` its hypothesis and reports cite (in stable id order), +`attestations`, the origin `transitions` for context, and a `redaction` +summary. The normative contract and fixtures live at `docs/specs/genome/0.1`. The +bundle logic lives in a new `autophagy-genome` crate, which depends only on +`events`, `mutations`, and `redaction` and performs no storage or network I/O; +the CLI orchestrates gathering from and ingesting into the store. + +**Trust is never transplanted.** The candidate always lands on the receiver as a +fresh `candidate` through the ordinary `register_mutation` path. Lifecycle +**state does not travel**. The origin's replay and shadow reports travel as +**display-only attestations**, stored in a new `mutation_attestations` table +(migration 0004, STRICT). No lifecycle read path — `register_replay`, +`register_shadow`, install, or efficacy gating — ever consults that table. Each +attestation records the origin's claim (`passed`) and a `hash_verified` flag that +reports only whether the carried report bytes still hash to their carried +`content_hash` — a transit-integrity check, never a local re-verification. The +receiver must re-run challenge → replay → shadow against local evidence to +advance the candidate; `mutations show` and `mutations list` say so in plain +language ("origin-claimed, not locally verified"). + +**Redaction gate at both ends.** At export, every event runs through +`PrivacyPolicy` (the built-in secret rules plus the configured +`import.exclude_paths`). A path-excluded event **aborts** the export, naming the +event: silently dropping it would break the content-hash-locked reports that cite +it, so the honest move is to refuse. The mutation's reviewable free text (title, +hypothesis, intervention instruction, exclusions, failure cases) is scrubbed with +the same secret rules through a new public `PrivacyPolicy::scrub_value` / +`scrub_string` API. At import, the receiver re-runs its own policy over every +event before storing it — it never trusts that the sender redacted correctly — +and a path-excluded event aborts the import. + +**Import respects existing store semantics.** Events are ingested through the +normal path, so validation, idempotency, and quarantine are preserved. A +conflicting reuse of an event id (the store would quarantine) aborts the import +cleanly, because evidence integrity cannot be satisfied. The mutation +registration maps the store's verdicts directly: `Duplicate` is a friendly +no-op, `EquivalentExisting` reports which local mutation is equivalent and writes +nothing, and a content conflict errors with nothing written. `--dry-run` prints +the full plan and writes nothing. + +## Real-data verification + +The design was exercised against a copy of the author's real database (schema +v2, upgraded in place to v4 on open). A real `repeated_command_failure` +candidate was exported under an isolated `AUTOPHAGY_CONFIG_DIR`; the bundle was +inspected to confirm the evidence events carried only redaction-approved content +and the reviewable text was scrubbed; and it was imported into a fresh empty +database, landing as a `candidate`. The user's real database and configuration +were never touched. + +## Privacy + +A genome contains **redacted event content** — the exact AEP events the mutation +cites, run through the redaction policy at export and again at import. Exporting +is an explicit, user-initiated act that writes a local file the user then chooses +whether and how to share; Autophagy performs no network I/O at any point. The +attestation reports are deterministic, model-free evaluation summaries (event +ids, normalized selectors, integer counts, and outcome enums) and carry no raw +payloads. Path-excluded evidence aborts rather than leaks, at both ends. + +## Consequences + +- A new ordered, immutable migration (0004) and the `autophagy-genome` crate. +- `MutationDetails` gains an `attestations` field (additive); `mutations list` + gains a text-only `(imported)` marker while its JSON surface is unchanged. +- Because state never travels, an imported mutation cannot be installed until the + receiver re-verifies it locally. This is intended: verification is a property + of local evidence, not a transferable certificate. + +## Alternatives considered + +- **Ship lifecycle state / a register-as-verified path.** Rejected: it would let + an unverified claim install a behavior change, defeating the point of the + gates. +- **Anonymize event ids instead of carrying events.** Rejected: it breaks the + content-hash-locked reports and the evidence foreign-key wall. +- **A `[genome]` config section for default export policy overrides.** Deferred; + v0.1 reuses `import.exclude_paths` so there is one redaction policy to reason + about. A future revision can add per-genome policy without changing the wire + format. diff --git a/docs/specs/genome/0.1/README.md b/docs/specs/genome/0.1/README.md new file mode 100644 index 0000000..3737af5 --- /dev/null +++ b/docs/specs/genome/0.1/README.md @@ -0,0 +1,93 @@ +# Team Genome v0.1 + +A **genome** is a single self-contained JSON file (`.genome.json`) that carries +one verified mutation candidate from the machine that produced it to another. +One developer exports a mutation they have taken through challenge, replay, and +shadow; a teammate imports it as a fresh review-only candidate. It is +file-based, local-first, and offline: exporting writes a file, importing reads +one, and no network I/O ever occurs. The normative contract is +[`bundle.schema.json`](bundle.schema.json); `valid/` and `invalid/` hold +fixtures the parser and builder are tested against. See ADR 0016 for the design. + +## Why a bundle, not just the package + +A mutation package alone is not portable. Two facts of the local store force the +genome to carry more: + +- **The evidence foreign-key wall.** `register_mutation` fails unless every + supporting and counterexample `event_id` already exists locally, and deleting + a cited event cascade-kills the candidate. A genome therefore carries the + exact AEP events its hypothesis cites so registration can succeed on the + receiver. +- **Content-hash-locked reports.** Replay and shadow reports embed their exact + `source_event_ids`, and the store checks that set precisely. Rewriting event + ids would break the hashes, so the bundle carries the **original** events + (redacted), keeping their ids valid. + +## Contents + +- `spec_version` — always `genome/0.1`. +- `genome_id` — `gen_` + SHA-256 hex of the canonical bundle content with the + `genome_id` field itself removed. Object keys serialize in sorted order, so + the id is independent of field ordering. The importer recomputes it and + rejects the bundle on mismatch (it was altered after export). +- `exported_at` — RFC 3339 export time. +- `origin` — `instance_key` and `autophagy_version` of the exporter. +- `mutation` — the complete immutable mutation package, governed by the + `mutation/*` contract, with its reviewable free text scrubbed (below). +- `evidence_events` — the redacted AEP events, in stable `event_id` order, each + governed by the `aep/*` contract. +- `attestations` — origin-claimed verification reports (below). +- `transitions` — the origin's lifecycle history, display-only. +- `redaction` — `redacted_fields` (count of string fields the secret rules + changed) and a `policy` description. + +## Redaction gate + +Redaction runs at **both** ends, so the receiver never has to trust that the +sender redacted correctly: + +- **At export**, every event runs through the redaction policy (built-in secret + rules plus the configured `import.exclude_paths`). A path-excluded event + **aborts** the export, naming the event — silently dropping it would break the + reports that cite it. The mutation's reviewable text (title, hypothesis, + intervention instruction, exclusions, failure cases) is scrubbed with the same + secret rules. +- **At import**, the receiver re-runs its own policy over every event before it + is stored, and a path-excluded event aborts the import. + +## Attestations are origin-claimed, not verified + +Trust is never transplanted. The candidate always lands on the receiver as a +fresh `candidate`; **lifecycle state does not travel**. The origin's replay and +shadow reports travel as **display-only attestations**: + +- `kind` — `replay` or `shadow`. +- `id`, `set_hash` — the origin's report identity and evaluated-set hash. +- `report_json` — the complete versioned report, exactly as the origin stored it + (a deterministic, model-free evaluation summary: event ids, selectors, integer + counts, and outcome enums — never raw payloads). +- `content_hash` — SHA-256 hex of `report_json`, a **transit-integrity** + fingerprint. On import the receiver recomputes it; a match proves the report + bytes were not altered after export, **not** that the receiver reproduced the + result. +- `passed` — what the origin claimed. + +The receiver must re-run challenge → replay → shadow against its own local +evidence to advance the candidate. No lifecycle read path (replay, shadow, +install, efficacy gating) ever consults an attestation. + +## Import outcomes + +Import ingests the events through the normal path (validation, redaction, +idempotency, quarantine), then registers the mutation: + +- **Duplicate** — the same package already exists locally: a friendly no-op. +- **Equivalent existing** — an equivalent trigger/intervention already exists + under another id: reported, nothing written. +- **Content conflict** — the same id exists with different content: an error, + nothing written. +- A conflicting reuse of an evidence `event_id` (the store would quarantine) + aborts the import cleanly, because evidence integrity cannot be satisfied. + +`--dry-run` prints the full plan and writes nothing. diff --git a/docs/specs/genome/0.1/bundle.schema.json b/docs/specs/genome/0.1/bundle.schema.json new file mode 100644 index 0000000..01c4cfc --- /dev/null +++ b/docs/specs/genome/0.1/bundle.schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://autophagy.sh/specs/genome/0.1/bundle.schema.json", + "title": "Autophagy Team Genome Bundle v0.1", + "description": "A self-contained, redaction-gated export of one mutation candidate, its redacted evidence events, origin-claimed verification attestations, and lifecycle history. The mutation package and evidence events are governed by their own contracts (mutation/*, aep/*); this schema governs the genome envelope.", + "type": "object", + "additionalProperties": false, + "required": [ + "spec_version", + "genome_id", + "exported_at", + "origin", + "mutation", + "evidence_events", + "attestations", + "transitions", + "redaction" + ], + "properties": { + "spec_version": { "const": "genome/0.1" }, + "genome_id": { "type": "string", "pattern": "^gen_[a-f0-9]{64}$" }, + "exported_at": { "type": "string", "format": "date-time" }, + "origin": { + "type": "object", + "additionalProperties": false, + "required": ["instance_key", "autophagy_version"], + "properties": { + "instance_key": { "type": "string", "minLength": 1 }, + "autophagy_version": { "type": "string", "minLength": 1 } + } + }, + "mutation": { + "type": "object", + "description": "A full mutation package (mutation/0.1 or mutation/0.2).", + "required": [ + "spec_version", + "mutation_id", + "title", + "hypothesis", + "triggers", + "intervention", + "permissions", + "promotion" + ], + "properties": { + "mutation_id": { "type": "string", "pattern": "^mut_[A-Za-z0-9._:-]+$" } + } + }, + "evidence_events": { + "type": "array", + "description": "Redacted AEP events cited by the hypothesis and attestations, in stable event-id order.", + "items": { + "type": "object", + "required": ["spec_version", "event_id", "session_id", "type"], + "properties": { + "event_id": { "type": "string", "pattern": "^evt_[A-Za-z0-9._:-]+$" } + } + } + }, + "attestations": { + "type": "array", + "items": { "$ref": "#/$defs/attestation" } + }, + "transitions": { + "type": "array", + "items": { "$ref": "#/$defs/transition" } + }, + "redaction": { + "type": "object", + "additionalProperties": false, + "required": ["redacted_fields", "policy"], + "properties": { + "redacted_fields": { "type": "integer", "minimum": 0 }, + "policy": { "type": "string", "minLength": 1 } + } + } + }, + "$defs": { + "attestation": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "set_hash", + "report_json", + "content_hash", + "passed", + "created_at" + ], + "properties": { + "kind": { "enum": ["replay", "shadow"] }, + "id": { "type": "string", "minLength": 1 }, + "set_hash": { "type": "string", "minLength": 1 }, + "report_json": { "type": "object" }, + "content_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "passed": { "type": "boolean" }, + "created_at": { "type": "string", "format": "date-time" } + } + }, + "transition": { + "type": "object", + "additionalProperties": false, + "required": ["from_state", "to_state", "reason", "occurred_at"], + "properties": { + "from_state": { "type": ["string", "null"] }, + "to_state": { "type": "string", "minLength": 1 }, + "reason": { "type": "string" }, + "occurred_at": { "type": "string", "format": "date-time" } + } + } + } +} diff --git a/docs/specs/genome/0.1/invalid/bad_attestation_kind.json b/docs/specs/genome/0.1/invalid/bad_attestation_kind.json new file mode 100644 index 0000000..c910c13 --- /dev/null +++ b/docs/specs/genome/0.1/invalid/bad_attestation_kind.json @@ -0,0 +1,107 @@ +{ + "spec_version": "genome/0.1", + "genome_id": "gen_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "exported_at": "2026-07-19T12:00:00Z", + "origin": { + "instance_key": "laptop-abc", + "autophagy_version": "0.1.0" + }, + "mutation": { + "spec_version": "mutation/0.1", + "mutation_id": "mut_a87b7c83aa9bf78169c01c2ad73b02c1ad7705f976fc2c94194f397a3091ce9d", + "version": "0.1.0", + "state": "candidate", + "generated_by": "deterministic_template_v1", + "source_finding_id": "find_examplefinding", + "source_detector": "repeated_command_failure", + "title": "Prevent repeated command failure: shell: go build ./...", + "hypothesis": { + "statement": "The recurring go build failure is caused by missing preconditions.", + "expected_result": "Matching go build attempts avoid repeated exit-code-1 failures.", + "supporting_event_ids": [ + "evt_support_one", + "evt_support_two" + ], + "counterexample_event_ids": [], + "failure_cases": [ + "The command can fail transiently even when preconditions hold." + ] + }, + "triggers": [ + { + "type": "tool_call", + "selector": "failure/v2|shell|go build ./...|exit:1" + } + ], + "exclusions": [ + "Do not block execution; this instruction is advisory." + ], + "intervention": { + "type": "agent_instruction", + "instruction": "Before running `go build ./...`, verify its required preconditions." + }, + "permissions": { + "filesystem_read": [], + "filesystem_write": [], + "commands": [], + "environment": [], + "network": false + }, + "promotion": { + "minimum_replays": 5, + "minimum_success_rate_bps": 8000, + "maximum_false_positive_rate_bps": 1000 + } + }, + "evidence_events": [ + { + "spec_version": "aep/0.1", + "event_id": "evt_support_one", + "session_id": "ses_alpha", + "timestamp": "2026-06-01T09:00:00Z", + "source": "claude-code", + "type": "tool.failed" + }, + { + "spec_version": "aep/0.1", + "event_id": "evt_support_two", + "session_id": "ses_beta", + "timestamp": "2026-06-02T09:00:00Z", + "source": "claude-code", + "type": "tool.failed" + } + ], + "attestations": [ + { + "kind": "efficacy", + "id": "rep_examplereplay", + "set_hash": "scenarioset0001", + "report_json": { + "spec_version": "replay/0.1", + "replay_id": "rep_examplereplay", + "passed": true + }, + "content_hash": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "passed": true, + "created_at": "2026-07-10T10:00:00Z" + } + ], + "transitions": [ + { + "from_state": null, + "to_state": "candidate", + "reason": "generated from evidence", + "occurred_at": "2026-06-03T09:00:00Z" + }, + { + "from_state": "candidate", + "to_state": "challenged", + "reason": "challenge checklist completed", + "occurred_at": "2026-06-04T09:00:00Z" + } + ], + "redaction": { + "redacted_fields": 0, + "policy": "built-in secret rules + import.exclude_paths" + } +} diff --git a/docs/specs/genome/0.1/invalid/bad_content_hash.json b/docs/specs/genome/0.1/invalid/bad_content_hash.json new file mode 100644 index 0000000..eae7ea4 --- /dev/null +++ b/docs/specs/genome/0.1/invalid/bad_content_hash.json @@ -0,0 +1,107 @@ +{ + "spec_version": "genome/0.1", + "genome_id": "gen_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "exported_at": "2026-07-19T12:00:00Z", + "origin": { + "instance_key": "laptop-abc", + "autophagy_version": "0.1.0" + }, + "mutation": { + "spec_version": "mutation/0.1", + "mutation_id": "mut_a87b7c83aa9bf78169c01c2ad73b02c1ad7705f976fc2c94194f397a3091ce9d", + "version": "0.1.0", + "state": "candidate", + "generated_by": "deterministic_template_v1", + "source_finding_id": "find_examplefinding", + "source_detector": "repeated_command_failure", + "title": "Prevent repeated command failure: shell: go build ./...", + "hypothesis": { + "statement": "The recurring go build failure is caused by missing preconditions.", + "expected_result": "Matching go build attempts avoid repeated exit-code-1 failures.", + "supporting_event_ids": [ + "evt_support_one", + "evt_support_two" + ], + "counterexample_event_ids": [], + "failure_cases": [ + "The command can fail transiently even when preconditions hold." + ] + }, + "triggers": [ + { + "type": "tool_call", + "selector": "failure/v2|shell|go build ./...|exit:1" + } + ], + "exclusions": [ + "Do not block execution; this instruction is advisory." + ], + "intervention": { + "type": "agent_instruction", + "instruction": "Before running `go build ./...`, verify its required preconditions." + }, + "permissions": { + "filesystem_read": [], + "filesystem_write": [], + "commands": [], + "environment": [], + "network": false + }, + "promotion": { + "minimum_replays": 5, + "minimum_success_rate_bps": 8000, + "maximum_false_positive_rate_bps": 1000 + } + }, + "evidence_events": [ + { + "spec_version": "aep/0.1", + "event_id": "evt_support_one", + "session_id": "ses_alpha", + "timestamp": "2026-06-01T09:00:00Z", + "source": "claude-code", + "type": "tool.failed" + }, + { + "spec_version": "aep/0.1", + "event_id": "evt_support_two", + "session_id": "ses_beta", + "timestamp": "2026-06-02T09:00:00Z", + "source": "claude-code", + "type": "tool.failed" + } + ], + "attestations": [ + { + "kind": "replay", + "id": "rep_examplereplay", + "set_hash": "scenarioset0001", + "report_json": { + "spec_version": "replay/0.1", + "replay_id": "rep_examplereplay", + "passed": true + }, + "content_hash": "tooshort", + "passed": true, + "created_at": "2026-07-10T10:00:00Z" + } + ], + "transitions": [ + { + "from_state": null, + "to_state": "candidate", + "reason": "generated from evidence", + "occurred_at": "2026-06-03T09:00:00Z" + }, + { + "from_state": "candidate", + "to_state": "challenged", + "reason": "challenge checklist completed", + "occurred_at": "2026-06-04T09:00:00Z" + } + ], + "redaction": { + "redacted_fields": 0, + "policy": "built-in secret rules + import.exclude_paths" + } +} diff --git a/docs/specs/genome/0.1/invalid/bad_genome_id.json b/docs/specs/genome/0.1/invalid/bad_genome_id.json new file mode 100644 index 0000000..40d4ccb --- /dev/null +++ b/docs/specs/genome/0.1/invalid/bad_genome_id.json @@ -0,0 +1,87 @@ +{ + "spec_version": "genome/0.1", + "genome_id": "not-a-valid-genome-id", + "exported_at": "2026-07-19T12:00:00Z", + "origin": { + "instance_key": "laptop-xyz", + "autophagy_version": "0.1.0" + }, + "mutation": { + "spec_version": "mutation/0.1", + "mutation_id": "mut_b13f28789cdae90dd6837572975bca5bf73e6b45f6e36730f7ff4d37cefa8e41", + "version": "0.1.0", + "state": "candidate", + "generated_by": "deterministic_template_v1", + "source_finding_id": "find_secondfinding", + "source_detector": "repeated_command_failure", + "title": "Prevent repeated command failure: shell: cargo test", + "hypothesis": { + "statement": "The recurring cargo test failure is caused by unchanged retries.", + "expected_result": "Matching cargo test attempts avoid repeated exit-code-101 failures.", + "supporting_event_ids": [ + "evt_only_one", + "evt_only_two" + ], + "counterexample_event_ids": [], + "failure_cases": [ + "The same command text can represent different intent." + ] + }, + "triggers": [ + { + "type": "tool_call", + "selector": "failure/v2|shell|cargo test|exit:101" + } + ], + "exclusions": [ + "Do not block execution; this instruction is advisory." + ], + "intervention": { + "type": "agent_instruction", + "instruction": "Before running `cargo test`, verify its required preconditions." + }, + "permissions": { + "filesystem_read": [], + "filesystem_write": [], + "commands": [], + "environment": [], + "network": false + }, + "promotion": { + "minimum_replays": 5, + "minimum_success_rate_bps": 8000, + "maximum_false_positive_rate_bps": 1000 + } + }, + "evidence_events": [ + { + "spec_version": "aep/0.1", + "event_id": "evt_only_one", + "session_id": "ses_gamma", + "timestamp": "2026-06-01T09:00:00Z", + "source": "codex", + "type": "tool.failed" + }, + { + "spec_version": "aep/0.1", + "event_id": "evt_only_two", + "session_id": "ses_delta", + "timestamp": "2026-06-02T09:00:00Z", + "source": "codex", + "type": "tool.failed" + } + ], + "attestations": [], + "transitions": [ + { + "from_state": null, + "to_state": "candidate", + "reason": "generated from evidence", + "occurred_at": "2026-06-03T09:00:00Z" + } + ], + "redaction": { + "redacted_fields": 2, + "policy": "built-in secret rules + import.exclude_paths" + } +} diff --git a/docs/specs/genome/0.1/invalid/bad_spec_version.json b/docs/specs/genome/0.1/invalid/bad_spec_version.json new file mode 100644 index 0000000..0ca99c1 --- /dev/null +++ b/docs/specs/genome/0.1/invalid/bad_spec_version.json @@ -0,0 +1,87 @@ +{ + "spec_version": "genome/0.2", + "genome_id": "gen_fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + "exported_at": "2026-07-19T12:00:00Z", + "origin": { + "instance_key": "laptop-xyz", + "autophagy_version": "0.1.0" + }, + "mutation": { + "spec_version": "mutation/0.1", + "mutation_id": "mut_b13f28789cdae90dd6837572975bca5bf73e6b45f6e36730f7ff4d37cefa8e41", + "version": "0.1.0", + "state": "candidate", + "generated_by": "deterministic_template_v1", + "source_finding_id": "find_secondfinding", + "source_detector": "repeated_command_failure", + "title": "Prevent repeated command failure: shell: cargo test", + "hypothesis": { + "statement": "The recurring cargo test failure is caused by unchanged retries.", + "expected_result": "Matching cargo test attempts avoid repeated exit-code-101 failures.", + "supporting_event_ids": [ + "evt_only_one", + "evt_only_two" + ], + "counterexample_event_ids": [], + "failure_cases": [ + "The same command text can represent different intent." + ] + }, + "triggers": [ + { + "type": "tool_call", + "selector": "failure/v2|shell|cargo test|exit:101" + } + ], + "exclusions": [ + "Do not block execution; this instruction is advisory." + ], + "intervention": { + "type": "agent_instruction", + "instruction": "Before running `cargo test`, verify its required preconditions." + }, + "permissions": { + "filesystem_read": [], + "filesystem_write": [], + "commands": [], + "environment": [], + "network": false + }, + "promotion": { + "minimum_replays": 5, + "minimum_success_rate_bps": 8000, + "maximum_false_positive_rate_bps": 1000 + } + }, + "evidence_events": [ + { + "spec_version": "aep/0.1", + "event_id": "evt_only_one", + "session_id": "ses_gamma", + "timestamp": "2026-06-01T09:00:00Z", + "source": "codex", + "type": "tool.failed" + }, + { + "spec_version": "aep/0.1", + "event_id": "evt_only_two", + "session_id": "ses_delta", + "timestamp": "2026-06-02T09:00:00Z", + "source": "codex", + "type": "tool.failed" + } + ], + "attestations": [], + "transitions": [ + { + "from_state": null, + "to_state": "candidate", + "reason": "generated from evidence", + "occurred_at": "2026-06-03T09:00:00Z" + } + ], + "redaction": { + "redacted_fields": 2, + "policy": "built-in secret rules + import.exclude_paths" + } +} diff --git a/docs/specs/genome/0.1/invalid/missing_redaction.json b/docs/specs/genome/0.1/invalid/missing_redaction.json new file mode 100644 index 0000000..feba7da --- /dev/null +++ b/docs/specs/genome/0.1/invalid/missing_redaction.json @@ -0,0 +1,83 @@ +{ + "spec_version": "genome/0.1", + "genome_id": "gen_fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + "exported_at": "2026-07-19T12:00:00Z", + "origin": { + "instance_key": "laptop-xyz", + "autophagy_version": "0.1.0" + }, + "mutation": { + "spec_version": "mutation/0.1", + "mutation_id": "mut_b13f28789cdae90dd6837572975bca5bf73e6b45f6e36730f7ff4d37cefa8e41", + "version": "0.1.0", + "state": "candidate", + "generated_by": "deterministic_template_v1", + "source_finding_id": "find_secondfinding", + "source_detector": "repeated_command_failure", + "title": "Prevent repeated command failure: shell: cargo test", + "hypothesis": { + "statement": "The recurring cargo test failure is caused by unchanged retries.", + "expected_result": "Matching cargo test attempts avoid repeated exit-code-101 failures.", + "supporting_event_ids": [ + "evt_only_one", + "evt_only_two" + ], + "counterexample_event_ids": [], + "failure_cases": [ + "The same command text can represent different intent." + ] + }, + "triggers": [ + { + "type": "tool_call", + "selector": "failure/v2|shell|cargo test|exit:101" + } + ], + "exclusions": [ + "Do not block execution; this instruction is advisory." + ], + "intervention": { + "type": "agent_instruction", + "instruction": "Before running `cargo test`, verify its required preconditions." + }, + "permissions": { + "filesystem_read": [], + "filesystem_write": [], + "commands": [], + "environment": [], + "network": false + }, + "promotion": { + "minimum_replays": 5, + "minimum_success_rate_bps": 8000, + "maximum_false_positive_rate_bps": 1000 + } + }, + "evidence_events": [ + { + "spec_version": "aep/0.1", + "event_id": "evt_only_one", + "session_id": "ses_gamma", + "timestamp": "2026-06-01T09:00:00Z", + "source": "codex", + "type": "tool.failed" + }, + { + "spec_version": "aep/0.1", + "event_id": "evt_only_two", + "session_id": "ses_delta", + "timestamp": "2026-06-02T09:00:00Z", + "source": "codex", + "type": "tool.failed" + } + ], + "attestations": [], + "transitions": [ + { + "from_state": null, + "to_state": "candidate", + "reason": "generated from evidence", + "occurred_at": "2026-06-03T09:00:00Z" + } + ] +} diff --git a/docs/specs/genome/0.1/invalid/unknown_field.json b/docs/specs/genome/0.1/invalid/unknown_field.json new file mode 100644 index 0000000..ac21f29 --- /dev/null +++ b/docs/specs/genome/0.1/invalid/unknown_field.json @@ -0,0 +1,88 @@ +{ + "spec_version": "genome/0.1", + "genome_id": "gen_fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + "exported_at": "2026-07-19T12:00:00Z", + "origin": { + "instance_key": "laptop-xyz", + "autophagy_version": "0.1.0" + }, + "mutation": { + "spec_version": "mutation/0.1", + "mutation_id": "mut_b13f28789cdae90dd6837572975bca5bf73e6b45f6e36730f7ff4d37cefa8e41", + "version": "0.1.0", + "state": "candidate", + "generated_by": "deterministic_template_v1", + "source_finding_id": "find_secondfinding", + "source_detector": "repeated_command_failure", + "title": "Prevent repeated command failure: shell: cargo test", + "hypothesis": { + "statement": "The recurring cargo test failure is caused by unchanged retries.", + "expected_result": "Matching cargo test attempts avoid repeated exit-code-101 failures.", + "supporting_event_ids": [ + "evt_only_one", + "evt_only_two" + ], + "counterexample_event_ids": [], + "failure_cases": [ + "The same command text can represent different intent." + ] + }, + "triggers": [ + { + "type": "tool_call", + "selector": "failure/v2|shell|cargo test|exit:101" + } + ], + "exclusions": [ + "Do not block execution; this instruction is advisory." + ], + "intervention": { + "type": "agent_instruction", + "instruction": "Before running `cargo test`, verify its required preconditions." + }, + "permissions": { + "filesystem_read": [], + "filesystem_write": [], + "commands": [], + "environment": [], + "network": false + }, + "promotion": { + "minimum_replays": 5, + "minimum_success_rate_bps": 8000, + "maximum_false_positive_rate_bps": 1000 + } + }, + "evidence_events": [ + { + "spec_version": "aep/0.1", + "event_id": "evt_only_one", + "session_id": "ses_gamma", + "timestamp": "2026-06-01T09:00:00Z", + "source": "codex", + "type": "tool.failed" + }, + { + "spec_version": "aep/0.1", + "event_id": "evt_only_two", + "session_id": "ses_delta", + "timestamp": "2026-06-02T09:00:00Z", + "source": "codex", + "type": "tool.failed" + } + ], + "attestations": [], + "transitions": [ + { + "from_state": null, + "to_state": "candidate", + "reason": "generated from evidence", + "occurred_at": "2026-06-03T09:00:00Z" + } + ], + "redaction": { + "redacted_fields": 2, + "policy": "built-in secret rules + import.exclude_paths" + }, + "extra_field": true +} diff --git a/docs/specs/genome/0.1/valid/full.json b/docs/specs/genome/0.1/valid/full.json new file mode 100644 index 0000000..9728787 --- /dev/null +++ b/docs/specs/genome/0.1/valid/full.json @@ -0,0 +1,97 @@ +{ + "spec_version": "genome/0.1", + "genome_id": "gen_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "exported_at": "2026-07-19T12:00:00Z", + "origin": { + "instance_key": "laptop-abc", + "autophagy_version": "0.1.0" + }, + "mutation": { + "spec_version": "mutation/0.1", + "mutation_id": "mut_a87b7c83aa9bf78169c01c2ad73b02c1ad7705f976fc2c94194f397a3091ce9d", + "version": "0.1.0", + "state": "candidate", + "generated_by": "deterministic_template_v1", + "source_finding_id": "find_examplefinding", + "source_detector": "repeated_command_failure", + "title": "Prevent repeated command failure: shell: go build ./...", + "hypothesis": { + "statement": "The recurring go build failure is caused by missing preconditions.", + "expected_result": "Matching go build attempts avoid repeated exit-code-1 failures.", + "supporting_event_ids": ["evt_support_one", "evt_support_two"], + "counterexample_event_ids": [], + "failure_cases": ["The command can fail transiently even when preconditions hold."] + }, + "triggers": [ + { "type": "tool_call", "selector": "failure/v2|shell|go build ./...|exit:1" } + ], + "exclusions": ["Do not block execution; this instruction is advisory."], + "intervention": { + "type": "agent_instruction", + "instruction": "Before running `go build ./...`, verify its required preconditions." + }, + "permissions": { + "filesystem_read": [], + "filesystem_write": [], + "commands": [], + "environment": [], + "network": false + }, + "promotion": { + "minimum_replays": 5, + "minimum_success_rate_bps": 8000, + "maximum_false_positive_rate_bps": 1000 + } + }, + "evidence_events": [ + { + "spec_version": "aep/0.1", + "event_id": "evt_support_one", + "session_id": "ses_alpha", + "timestamp": "2026-06-01T09:00:00Z", + "source": "claude-code", + "type": "tool.failed" + }, + { + "spec_version": "aep/0.1", + "event_id": "evt_support_two", + "session_id": "ses_beta", + "timestamp": "2026-06-02T09:00:00Z", + "source": "claude-code", + "type": "tool.failed" + } + ], + "attestations": [ + { + "kind": "replay", + "id": "rep_examplereplay", + "set_hash": "scenarioset0001", + "report_json": { + "spec_version": "replay/0.1", + "replay_id": "rep_examplereplay", + "passed": true + }, + "content_hash": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "passed": true, + "created_at": "2026-07-10T10:00:00Z" + } + ], + "transitions": [ + { + "from_state": null, + "to_state": "candidate", + "reason": "generated from evidence", + "occurred_at": "2026-06-03T09:00:00Z" + }, + { + "from_state": "candidate", + "to_state": "challenged", + "reason": "challenge checklist completed", + "occurred_at": "2026-06-04T09:00:00Z" + } + ], + "redaction": { + "redacted_fields": 0, + "policy": "built-in secret rules + import.exclude_paths" + } +} diff --git a/docs/specs/genome/0.1/valid/minimal_no_attestations.json b/docs/specs/genome/0.1/valid/minimal_no_attestations.json new file mode 100644 index 0000000..d9778c4 --- /dev/null +++ b/docs/specs/genome/0.1/valid/minimal_no_attestations.json @@ -0,0 +1,77 @@ +{ + "spec_version": "genome/0.1", + "genome_id": "gen_fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + "exported_at": "2026-07-19T12:00:00Z", + "origin": { + "instance_key": "laptop-xyz", + "autophagy_version": "0.1.0" + }, + "mutation": { + "spec_version": "mutation/0.1", + "mutation_id": "mut_b13f28789cdae90dd6837572975bca5bf73e6b45f6e36730f7ff4d37cefa8e41", + "version": "0.1.0", + "state": "candidate", + "generated_by": "deterministic_template_v1", + "source_finding_id": "find_secondfinding", + "source_detector": "repeated_command_failure", + "title": "Prevent repeated command failure: shell: cargo test", + "hypothesis": { + "statement": "The recurring cargo test failure is caused by unchanged retries.", + "expected_result": "Matching cargo test attempts avoid repeated exit-code-101 failures.", + "supporting_event_ids": ["evt_only_one", "evt_only_two"], + "counterexample_event_ids": [], + "failure_cases": ["The same command text can represent different intent."] + }, + "triggers": [ + { "type": "tool_call", "selector": "failure/v2|shell|cargo test|exit:101" } + ], + "exclusions": ["Do not block execution; this instruction is advisory."], + "intervention": { + "type": "agent_instruction", + "instruction": "Before running `cargo test`, verify its required preconditions." + }, + "permissions": { + "filesystem_read": [], + "filesystem_write": [], + "commands": [], + "environment": [], + "network": false + }, + "promotion": { + "minimum_replays": 5, + "minimum_success_rate_bps": 8000, + "maximum_false_positive_rate_bps": 1000 + } + }, + "evidence_events": [ + { + "spec_version": "aep/0.1", + "event_id": "evt_only_one", + "session_id": "ses_gamma", + "timestamp": "2026-06-01T09:00:00Z", + "source": "codex", + "type": "tool.failed" + }, + { + "spec_version": "aep/0.1", + "event_id": "evt_only_two", + "session_id": "ses_delta", + "timestamp": "2026-06-02T09:00:00Z", + "source": "codex", + "type": "tool.failed" + } + ], + "attestations": [], + "transitions": [ + { + "from_state": null, + "to_state": "candidate", + "reason": "generated from evidence", + "occurred_at": "2026-06-03T09:00:00Z" + } + ], + "redaction": { + "redacted_fields": 2, + "policy": "built-in secret rules + import.exclude_paths" + } +}