gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1561Shell 203Rust 109Markdown 993728 Definiciones

Fase 4 — provenance en `hammer export` (sidecar de receta en el store)

Hasta ahora `hammer export` emitía sólo `file_drop` con `content_b64`.
Eso reproducía byte-a-byte pero perdía la receta: el receptor obtenía
binarios opacos, sin manera de auditarlos ni de recompilarlos desde
fuente. Esta fase cierra el hueco para los artefactos producidos por
`hammer-build::build`.

Mecanismo:
- `Store` reserva `.hammer/recipe.toml` (`RECIPE_SIDECAR_REL`) dentro
  de cada artefacto. `hammer-build::build` lo escribe antes de sellar,
  así queda inmutable junto con el árbol.
- `Store::recipe_for_dir` / `recipe_for_hash` lo leen de vuelta. Si el
  artefacto es viejo y no trae sidecar, devuelven `None` sin fallar.
- `Recipe::to_toml` (nuevo) hace el roundtrip.

Lógica del export (función pura `build_export_mutations`):
1. Aplica semánticas de Delete: invalida estados previos del mismo path.
2. Particiona los eventos sobrevivientes en (a) los que tienen
   `artifact_hash` con receta sidecar y (b) el resto.
3. Por cada grupo de (a) emite UN `source_patch` con repo+commit+build
   de la receta y `expected_hash = artifact_hash`. El `target_bin` es
   el primer path alfabético del grupo; el receptor hidrata el árbol
   completo al aplicar.
4. Patches de la receta se concatenan inline en el `source_patch.patch`.
5. Los eventos del bucket (b) caen al fallback `file_drop` con
   `content_b64 + content_hash` (orden alfabético).

Limitaciones explícitas:
- `SourcePatch` sólo modela `Source::Git`. Recetas con `tarball` se
  reportan por stderr y caen a file_drop. Extender el SWM para
  tarballs es trabajo aparte.
- Si la receta tiene patches pero alguno no se puede leer, no se
  inlina; `expected_hash` sigue siendo el gate de integridad para
  detectar la divergencia.

CLI: el subcomando `Export` ahora recibe `--store` para resolver
artefactos. Defaults igual que antes (`/store`).

Tests (12 nuevos):
- hammer-core (5): `Recipe::to_toml` roundtrip; `Store::recipe_for_dir`
  sin/con sidecar; `Store::recipe_for_hash` por prefijo + hash inexistente.
- hammer-cli (7): export sin store; semánticas de Delete; hydrate con
  sidecar emite source_patch agrupando dos archivos; hydrate sin sidecar
  cae a file_drop contando `missing_recipe`; mix trazable + external;
  path ilegible sólo warning.

de036d366605e338a6f604890433c11356101fb8 · por Sergio · (hace 4 meses) · padre 598620053 · ver el árbol en este commit

Delta semántico · 24

crates/hammer-build/src/lib.rs

  • cambió por dentro fn build pub

crates/hammer-core/src/recipe.rs

5 archivos · +569 −56

diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs
index b9f6d8f5..ba171d57 100644
--- a/crates/hammer-build/src/lib.rs
+++ b/crates/hammer-build/src/lib.rs
@@ -91,6 +91,17 @@ pub fn build(
         ));
     }
 
+    // Sidecar de provenance: escribimos la receta serializada DENTRO de out_dir antes del
+    // seal, para que quede congelada en el árbol read-only del store. `hammer export` la
+    // lee de vuelta para emitir `source_patch` con la receta original en vez de un
+    // `file_drop` opaco. Ver `docs/04-overlay.md` §provenance (Fase 4).
+    let sidecar_path = out_dir.join(hammer_core::store::RECIPE_SIDECAR_REL);
+    if let Some(parent) = sidecar_path.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+    let toml_text = recipe.to_toml()?;
+    std::fs::write(&sidecar_path, toml_text.as_bytes())?;
+
     let sealed = store.seal(&out_dir, &h, &recipe.name)?;
     tracing::info!(path = %sealed.display(), "sealed");
     Ok(h)
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 08cb216e..297edcec 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -364,7 +364,7 @@ fn main() -> anyhow::Result<()> {
             run_swm_verify(&file, base_ref.as_deref())?;
         }
         Cmd::Export { base_ref, journal, since } => {
-            run_export(base_ref.as_deref(), &journal, since.as_deref())?;
+            run_export(base_ref.as_deref(), &journal, since.as_deref(), &cli.store)?;
         }
         Cmd::Ai {
             intent,
@@ -576,24 +576,23 @@ fn run_apply(
     Ok(())
 }
 
-/// Exporta el diario actual como `.swm`. Estrategia honesta para Fase 4:
+/// Exporta el diario actual como `.swm`. Provenance-aware:
 ///
-/// - Cada evento se traduce a un `file_drop` con `content_b64` (lo que hay en disco *ahora*)
-///   y `content_hash`. Esto garantiza reproducción byte-a-byte sin depender de tener el
-///   recipe original. La pérdida es que perdemos provenance — el receptor obtiene el binario
-///   pero no la receta que lo produjo. Para `source_patch` real necesitaremos un mapa
-///   artefacto→receta que la Fase 4 no construye todavía.
-/// - Mutaciones opacas (`Source::External`) llevan un `note` indicando "origen externo"
-///   para que el receptor sepa que no hay garantía de provenance.
-/// - Mutaciones con `op = Delete` se omiten: un `.swm` describe el estado final por
-///   adición/edición, no por borrado. Es un compromiso consciente (un futuro `FileRemove`
-///   resolvería esto).
+/// - Eventos cuyo `Source` apunta a un `artifact` con receta sidecar en el store
+///   (`.hammer/recipe.toml`) se agrupan por `artifact_hash` y emiten **un único**
+///   `source_patch` por grupo, con repo/commit/build de la receta original. El receptor
+///   reconstruye el binario desde fuente — provenance real, no un binario opaco.
+/// - Eventos sin provenance trazable (`Source::External`, builds viejos sin sidecar,
+///   `HammerCommit` con `artifact: None`) caen al fallback `file_drop` con `content_b64`
+///   + hash, reproduciendo byte-a-byte pero perdiendo la receta. Se cuenta como warning.
+/// - Eventos `Delete` invalidan estados previos del mismo path y luego se ignoran (un
+///   `.swm` describe el estado final por adición/edición).
 fn run_export(
     base_ref_path: Option<&std::path::Path>,
     journal_dir: &std::path::Path,
     since: Option<&str>,
+    store_root: &str,
 ) -> anyhow::Result<()> {
-    use base64::{engine::general_purpose::STANDARD, Engine as _};
     use std::collections::BTreeMap;
 
     let j = hammer_journal::Journal::open(journal_dir)?;
@@ -617,25 +616,222 @@ fn run_export(
         }
     };
 
-    // Dedup por path: dentro de un export queremos un único estado final por archivo.
-    // Procesamos en orden y nos quedamos con la última ocurrencia.
-    let mut last_by_path: std::collections::BTreeMap<PathBuf, &hammer_journal::MutationEvent> =
-        std::collections::BTreeMap::new();
-    for ev in &filtered {
+    // El store es opcional: si el directorio no existe, seguimos en modo "todo file_drop"
+    // (igual que el comportamiento pre-provenance). El receptor no nota la diferencia.
+    let store_opt = match hammer_core::Store::open(store_root) {
+        Ok(s) => Some(s),
+        Err(e) => {
+            eprintln!(
+                "warning: store {store_root} no accesible ({e}); export sin provenance"
+            );
+            None
+        }
+    };
+
+    let (mutations, stats) = build_export_mutations(&filtered, store_opt.as_ref());
+
+    let swm = hammer_core::Swm {
+        swm_version: 1,
+        base,
+        mutations,
+        signature: None,
+    };
+    let yaml = swm.to_yaml()?;
+    print!("{yaml}");
+    eprintln!(
+        "export: {} source_patch(es), {} file_drop(s), {} warning(s){}",
+        stats.source_patches,
+        stats.file_drops,
+        stats.warnings,
+        if stats.missing_recipe > 0 {
+            format!(" ({} artefacto(s) sin sidecar de receta)", stats.missing_recipe)
+        } else {
+            String::new()
+        },
+    );
+    Ok(())
+}
+
+#[derive(Debug, Default)]
+struct ExportStats {
+    source_patches: usize,
+    file_drops: usize,
+    warnings: usize,
+    /// Cuántos artefactos referenciados por eventos no traían `.hammer/recipe.toml`
+    /// y por tanto cayeron a file_drop. Útil para que el humano sepa que parte del
+    /// export perdió provenance (típicamente builds anteriores a Fase 4).
+    missing_recipe: usize,
+}
+
+/// Construye la lista de mutaciones del `.swm` exportado. Función pura sobre `events` +
+/// el store: testeable sin abrir el journal real ni la CLI.
+///
+/// Estrategia:
+/// 1. Aplicamos las semánticas de Delete (invalida estados anteriores del mismo path) y
+///    dedupeamos por path quedándonos con el último evento.
+/// 2. Particionamos los eventos sobrevivientes en dos: los que tienen un `artifact_hash`
+///    cuyo store contiene la receta sidecar (→ candidates para `source_patch` agrupado),
+///    y el resto (→ `file_drop`).
+/// 3. Por cada grupo `artifact_hash` con receta: emitimos UN `source_patch`. El
+///    `target_bin` es el primer path del grupo en orden alfabético; el resto de paths
+///    queda implícito (el receptor hidrata el artefacto completo).
+/// 4. Por cada evento de fallback: leemos el contenido actual del disco, codificamos
+///    base64 y emitimos `file_drop`. Si la lectura falla, contamos warning y omitimos.
+fn build_export_mutations(
+    events: &[hammer_journal::MutationEvent],
+    store: Option<&hammer_core::Store>,
+) -> (Vec<hammer_core::Mutation>, ExportStats) {
+    use base64::{engine::general_purpose::STANDARD, Engine as _};
+    use std::collections::BTreeMap;
+
+    // Paso 1: dedup respetando Delete.
+    let mut last_by_path: BTreeMap<PathBuf, &hammer_journal::MutationEvent> = BTreeMap::new();
+    for ev in events {
         if matches!(ev.op, hammer_journal::MutationOp::Delete) {
-            last_by_path.remove(&ev.path); // borrado anula cualquier estado previo
+            last_by_path.remove(&ev.path);
             continue;
         }
         last_by_path.insert(ev.path.clone(), ev);
     }
 
-    let mut mutations = Vec::new();
-    let mut warnings = 0usize;
+    let mut stats = ExportStats::default();
+
+    // Paso 2: particionamos. Para que la asignación a `source_patch` requiera la receta,
+    // resolvemos el sidecar acá; si no está, el evento cae al fallback.
+    //
+    // Estructura: artifact_hash → (Recipe, Vec<path>). Los paths se ordenarán antes de
+    // emitir, para que el target_bin sea determinista (importante para tests).
+    let mut groups: BTreeMap<String, (hammer_core::Recipe, Vec<PathBuf>)> = BTreeMap::new();
+    let mut fallback: Vec<(PathBuf, &hammer_journal::MutationEvent)> = Vec::new();
+
+    let mut artifacts_seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
+    let mut artifacts_without_recipe: std::collections::BTreeSet<String> =
+        std::collections::BTreeSet::new();
+
     for (path, ev) in &last_by_path {
-        let bytes = match std::fs::read(path) {
+        let artifact = match &ev.by.source {
+            hammer_journal::Source::HammerHydrate { artifact } => Some(artifact.clone()),
+            hammer_journal::Source::HammerCommit { artifact: Some(a), .. } => Some(a.clone()),
+            _ => None,
+        };
+        let Some(art) = artifact else {
+            fallback.push((path.clone(), *ev));
+            continue;
+        };
+        artifacts_seen.insert(art.clone());
+
+        // Si ya conseguimos la receta para este artifact_hash en una pasada anterior,
+        // reusamos sin tocar disco otra vez.
+        if let Some((_, paths)) = groups.get_mut(&art) {
+            paths.push(path.clone());
+            continue;
+        }
+        if artifacts_without_recipe.contains(&art) {
+            fallback.push((path.clone(), *ev));
+            continue;
+        }
+
+        let recipe_opt = store.and_then(|s| s.recipe_for_hash(&art).ok().flatten());
+        match recipe_opt {
+            Some(r) => {
+                groups.insert(art, (r, vec![path.clone()]));
+            }
+            None => {
+                artifacts_without_recipe.insert(art);
+                fallback.push((path.clone(), *ev));
+            }
+        }
+    }
+
+    stats.missing_recipe = artifacts_without_recipe.len();
+
+    let mut mutations: Vec<hammer_core::Mutation> = Vec::new();
+
+    // Paso 3: source_patches (ordenados por artifact_hash para estabilidad del output).
+    for (art_hash, (recipe, mut paths)) in groups {
+        paths.sort();
+        let target_bin = paths
+            .first()
+            .map(|p| p.display().to_string())
+            .unwrap_or_else(|| "/".into());
+
+        // Convertimos la Recipe a los campos que SourcePatch necesita.
+        let (repo, commit) = match recipe.source.kind() {
+            Ok(hammer_core::SourceKind::Git { repo, commit }) => {
+                (repo.to_string(), commit.to_string())
+            }
+            Ok(hammer_core::SourceKind::Tarball { url, sha256 }) => {
+                // SourcePatch sólo modela el modo git. Para tarballs caemos al
+                // fallback file_drop por evento — no perdemos completeness, sólo
+                // provenance fina. El humano lo ve por stderr.
+                eprintln!(
+                    "warning: artefacto {art_hash} proviene de tarball ({url} sha256={sha256}); \
+                     `source_patch` aún no modela tarballs, hago file_drop"
+                );
+                for p in &paths {
+                    if let Some(ev) = last_by_path.get(p) {
+                        fallback.push((p.clone(), *ev));
+                    }
+                }
+                continue;
+            }
+            Err(_) => {
+                stats.warnings += 1;
+                eprintln!(
+                    "warning: receta de {art_hash} tiene source inválido; cayendo a file_drop"
+                );
+                for p in &paths {
+                    if let Some(ev) = last_by_path.get(p) {
+                        fallback.push((p.clone(), *ev));
+                    }
+                }
+                continue;
+            }
+        };
+
+        // Patches inline: si la receta tenía patches, los leemos y concatenamos. El
+        // hash del artefacto incluye su contenido (ver `Recipe::hash_inputs`), así que
+        // el receptor reconstruye byte-a-byte si y sólo si el patch coincide.
+        let patch_inline = collect_patches_inline(&recipe);
+
+        if paths.len() > 1 {
+            eprintln!(
+                "  source_patch {} cubre {} archivo(s); target_bin={} (resto se hidrata al aplicar)",
+                art_hash,
+                paths.len(),
+                target_bin
+            );
+        }
+
+        mutations.push(hammer_core::Mutation::SourcePatch {
+            repo,
+            commit,
+            patch: patch_inline,
+            patch_url: None,
+            build: hammer_core::SwmBuild {
+                compiler: recipe.build.compiler.as_str().to_string(),
+                target: recipe.build.target.clone(),
+                link: recipe.build.link.as_str().to_string(),
+                flags: recipe.build.flags.clone(),
+            },
+            target_bin,
+            expected_hash: Some(art_hash),
+        });
+        stats.source_patches += 1;
+    }
+
+    // Paso 4: file_drops. Dedupeamos por path porque algunos paths pueden haber rebotado
+    // del grupo de tarball-fallback al fallback general además del path original.
+    let mut seen_paths: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
+    fallback.sort_by(|(a, _), (b, _)| a.cmp(b));
+    for (path, ev) in fallback {
+        if !seen_paths.insert(path.clone()) {
+            continue;
+        }
+        let bytes = match std::fs::read(&path) {
             Ok(b) => b,
             Err(e) => {
-                warnings += 1;
+                stats.warnings += 1;
                 eprintln!(
                     "warning: no pude leer {} para exportar (ev ts={}): {e}",
                     path.display(),
@@ -646,44 +842,46 @@ fn run_export(
         };
         let h = hammer_core::ArtifactHash::of_inputs(&[bytes.as_slice()]);
         let b64 = STANDARD.encode(&bytes);
-        let note = match &ev.by.source {
-            hammer_journal::Source::External => Some(format!(
-                "origen externo (pid={:?}, uid={:?}) — sin provenance",
-                ev.by.pid, ev.by.uid
-            )),
-            hammer_journal::Source::HammerHydrate { artifact } => {
-                Some(format!("hydrate {artifact}"))
-            }
-            hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
-                Some(a) => Some(format!("commit {overlay} ({a})")),
-                None => Some(format!("commit {overlay}")),
-            },
-        };
-        // El note vive en el journal, no en el schema del file_drop — lo omitimos en el
-        // YAML emitido por ahora; está en stderr (warnings) si era external.
-        let _ = note;
         mutations.push(hammer_core::Mutation::FileDrop {
             path: path.display().to_string(),
             content_hash: h.as_str().to_string(),
             content_b64: Some(b64),
             content_url: None,
         });
+        stats.file_drops += 1;
     }
 
-    let swm = hammer_core::Swm {
-        swm_version: 1,
-        base,
-        mutations,
-        signature: None,
-    };
-    let yaml = swm.to_yaml()?;
-    print!("{yaml}");
-    eprintln!(
-        "export: {} mutación(es), {} warning(s)",
-        swm.mutations.len(),
-        warnings
-    );
-    Ok(())
+    (mutations, stats)
+}
+
+/// Concatena los contenidos de todos los patches declarados por la receta en un único
+/// blob (separado por una línea con `--- /dev/null` falsa). Si la receta no tiene
+/// patches, devuelve `None`. Si algún patch no se puede leer, devuelve `None` y deja un
+/// warning en stderr — el receptor reconstruirá sin patches y verificará con
+/// `expected_hash` que el binario coincide, así que el error es detectable.
+fn collect_patches_inline(recipe: &hammer_core::Recipe) -> Option<String> {
+    if recipe.source.patches.is_empty() {
+        return None;
+    }
+    let mut all = String::new();
+    for p in &recipe.source.patches {
+        let path = if std::path::Path::new(p).is_absolute() {
+            std::path::PathBuf::from(p)
+        } else {
+            recipe.base_dir.join(p)
+        };
+        match std::fs::read_to_string(&path) {
+            Ok(text) => all.push_str(&text),
+            Err(e) => {
+                eprintln!(
+                    "warning: no pude leer patch {} para inline: {e}",
+                    path.display()
+                );
+                return None;
+            }
+        }
+    }
+    Some(all)
 }
 
 /// Ejecuta el bucle agéntico de Fase 6 vía `hammer-agent`. Acepta una intención NL +
@@ -948,3 +1146,190 @@ fn run_ctl(line: &str, fifo: &std::path::Path) -> anyhow::Result<()> {
     }
     Ok(())
 }
+
+#[cfg(test)]
+mod export_tests {
+    use super::*;
+    use hammer_journal::{Actor, MutationEvent, MutationOp, Source};
+
+    fn write_recipe_sidecar(art_dir: &std::path::Path, name: &str, commit: &str) {
+        std::fs::create_dir_all(art_dir.join(".hammer")).unwrap();
+        let toml = format!(
+            r#"
+name = "{name}"
+version = "0.1"
+[source]
+repo   = "git://example/{name}.git"
+commit = "{commit}"
+[build]
+compiler = "zig-cc"
+target   = "x86_64-linux-musl"
+link     = "static"
+flags    = ["--enable-foo"]
+"#
+        );
+        std::fs::write(art_dir.join(hammer_core::store::RECIPE_SIDECAR_REL), toml).unwrap();
+    }
+
+    fn ev_hydrate(path: &str, artifact: &str) -> MutationEvent {
+        MutationEvent {
+            ts: "2026-06-10T00:00:00Z".into(),
+            op: MutationOp::Create,
+            path: PathBuf::from(path),
+            by: Actor {
+                source: Source::HammerHydrate { artifact: artifact.into() },
+                pid: None,
+                uid: None,
+            },
+            content_hash: None,
+            note: None,
+        }
+    }
+
+    fn ev_external(path: &str) -> MutationEvent {
+        MutationEvent {
+            ts: "2026-06-10T00:00:01Z".into(),
+            op: MutationOp::Edit,
+            path: PathBuf::from(path),
+            by: Actor { source: Source::External, pid: Some(42), uid: Some(1000) },
+            content_hash: None,
+            note: None,
+        }
+    }
+
+    #[test]
+    fn export_without_store_falls_back_to_file_drops() {
+        let d = tempfile::tempdir().unwrap();
+        let target = d.path().join("etc/hosts");
+        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
+        std::fs::write(&target, b"127.0.0.1 lo\n").unwrap();
+
+        let events = vec![ev_external(target.to_str().unwrap())];
+        let (mutations, stats) = build_export_mutations(&events, None);
+        assert_eq!(stats.source_patches, 0);
+        assert_eq!(stats.file_drops, 1);
+        assert!(matches!(mutations[0], hammer_core::Mutation::FileDrop { .. }));
+    }
+
+    #[test]
+    fn delete_event_invalidates_previous_state() {
+        let d = tempfile::tempdir().unwrap();
+        let target = d.path().join("a");
+        std::fs::write(&target, b"x").unwrap();
+
+        let mut create = ev_external(target.to_str().unwrap());
+        create.op = MutationOp::Create;
+        let mut delete = ev_external(target.to_str().unwrap());
+        delete.op = MutationOp::Delete;
+        let events = vec![create, delete];
+        let (mutations, stats) = build_export_mutations(&events, None);
+        assert!(mutations.is_empty());
+        assert_eq!(stats.file_drops, 0);
+    }
+
+    #[test]
+    fn hydrate_with_recipe_sidecar_emits_source_patch() {
+        let d = tempfile::tempdir().unwrap();
+        let store_root = d.path().join("store");
+        let store = hammer_core::Store::open(&store_root).unwrap();
+        let art_dir = store_root.join("cafe1234-grep");
+        write_recipe_sidecar(&art_dir, "grep", "deadbeef");
+
+        // Dos archivos del MISMO artifact_hash ⇒ deben colapsar en UN source_patch.
+        let file_a = d.path().join("usr/bin/grep");
+        let file_b = d.path().join("usr/lib/grep-helper.so");
+        std::fs::create_dir_all(file_a.parent().unwrap()).unwrap();
+        std::fs::create_dir_all(file_b.parent().unwrap()).unwrap();
+        std::fs::write(&file_a, b"x").unwrap();
+        std::fs::write(&file_b, b"y").unwrap();
+        let events = vec![
+            ev_hydrate(file_a.to_str().unwrap(), "b3:cafe1234"),
+            ev_hydrate(file_b.to_str().unwrap(), "b3:cafe1234"),
+        ];
+
+        let (mutations, stats) = build_export_mutations(&events, Some(&store));
+        assert_eq!(stats.source_patches, 1, "{:?}", stats);
+        assert_eq!(stats.file_drops, 0);
+        assert_eq!(stats.missing_recipe, 0);
+        match &mutations[0] {
+            hammer_core::Mutation::SourcePatch {
+                repo,
+                commit,
+                build,
+                target_bin,
+                expected_hash,
+                ..
+            } => {
+                assert_eq!(repo, "git://example/grep.git");
+                assert_eq!(commit, "deadbeef");
+                assert_eq!(build.compiler, "zig-cc");
+                assert_eq!(build.link, "static");
+                assert_eq!(build.flags, vec!["--enable-foo".to_string()]);
+                // target_bin es el primer path alfabético del grupo.
+                let tb = std::path::PathBuf::from(target_bin);
+                assert!(tb.ends_with("usr/bin/grep"), "{target_bin}");
+                assert_eq!(expected_hash.as_deref(), Some("b3:cafe1234"));
+            }
+            other => panic!("esperaba SourcePatch, llegó {other:?}"),
+        }
+    }
+
+    #[test]
+    fn hydrate_without_recipe_falls_back_to_file_drop() {
+        let d = tempfile::tempdir().unwrap();
+        let store_root = d.path().join("store");
+        let store = hammer_core::Store::open(&store_root).unwrap();
+        // Creamos el artefacto SIN sidecar.
+        std::fs::create_dir_all(store_root.join("badbeef-grep")).unwrap();
+
+        let target = d.path().join("usr/bin/grep");
+        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
+        std::fs::write(&target, b"binary").unwrap();
+        let events = vec![ev_hydrate(target.to_str().unwrap(), "b3:badbeef")];
+
+        let (mutations, stats) = build_export_mutations(&events, Some(&store));
+        assert_eq!(stats.source_patches, 0);
+        assert_eq!(stats.file_drops, 1);
+        assert_eq!(stats.missing_recipe, 1, "debe contar el artefacto sin sidecar");
+        assert!(matches!(mutations[0], hammer_core::Mutation::FileDrop { .. }));
+    }
+
+    #[test]
+    fn mixed_traceable_and_external_emit_both_kinds() {
+        let d = tempfile::tempdir().unwrap();
+        let store_root = d.path().join("store");
+        let store = hammer_core::Store::open(&store_root).unwrap();
+        let art_dir = store_root.join("aaaa1111-grep");
+        write_recipe_sidecar(&art_dir, "grep", "feedface");
+
+        let traced = d.path().join("usr/bin/grep");
+        std::fs::create_dir_all(traced.parent().unwrap()).unwrap();
+        std::fs::write(&traced, b"x").unwrap();
+        let edited = d.path().join("etc/grep.conf");
+        std::fs::create_dir_all(edited.parent().unwrap()).unwrap();
+        std::fs::write(&edited, b"OPTS=\n").unwrap();
+
+        let events = vec![
+            ev_hydrate(traced.to_str().unwrap(), "b3:aaaa1111"),
+            ev_external(edited.to_str().unwrap()),
+        ];
+        let (mutations, stats) = build_export_mutations(&events, Some(&store));
+        assert_eq!(stats.source_patches, 1);
+        assert_eq!(stats.file_drops, 1);
+        // Orden: source_patches primero (BTreeMap por hash), luego file_drops.
+        assert!(matches!(mutations[0], hammer_core::Mutation::SourcePatch { .. }));
+        assert!(matches!(mutations[1], hammer_core::Mutation::FileDrop { .. }));
+    }
+
+    #[test]
+    fn unreadable_path_in_fallback_only_warns() {
+        // ev_external apuntando a un path inexistente: el evento se cuenta como warning
+        // y queda fuera del .swm. La función no falla.
+        let events = vec![ev_external("/no/existe/seguramente/x")];
+        let (mutations, stats) = build_export_mutations(&events, None);
+        assert_eq!(stats.warnings, 1);
+        assert_eq!(stats.file_drops, 0);
+        assert!(mutations.is_empty());
+    }
+
+}
diff --git a/crates/hammer-core/src/recipe.rs b/crates/hammer-core/src/recipe.rs
index cced4da2..9cf6c7c5 100644
--- a/crates/hammer-core/src/recipe.rs
+++ b/crates/hammer-core/src/recipe.rs
@@ -171,6 +171,12 @@ impl Recipe {
         toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string()))
     }
 
+    /// Serializa la receta a TOML. Útil para el sidecar de provenance que `hammer-build`
+    /// escribe junto al artefacto en el store. `base_dir` no se serializa (es transient).
+    pub fn to_toml(&self) -> crate::Result<String> {
+        toml::to_string(self).map_err(|e| crate::Error::Recipe(e.to_string()))
+    }
+
     /// Carga una receta desde el disco. `base_dir` se fija al directorio que contiene el
     /// archivo, de modo que `source.patches` resuelve relativo a ahí (estilo Cargo).
     pub fn load_from_path(path: impl AsRef<Path>) -> crate::Result<Recipe> {
@@ -266,6 +272,26 @@ build = ["pcre2"]
         assert_eq!(r.deps.build, vec!["pcre2".to_string()]);
     }
 
+    #[test]
+    fn to_toml_roundtrips_via_from_toml() {
+        let r1 = Recipe::from_toml(SAMPLE).unwrap();
+        let serialized = r1.to_toml().expect("to_toml");
+        let r2 = Recipe::from_toml(&serialized).expect("from_toml(roundtrip)");
+        assert_eq!(r2.name, r1.name);
+        assert_eq!(r2.version, r1.version);
+        assert_eq!(r2.build.compiler, r1.build.compiler);
+        assert_eq!(r2.build.link, r1.build.link);
+        assert_eq!(r2.build.flags, r1.build.flags);
+        assert_eq!(r2.deps.build, r1.deps.build);
+        // El kind del source también roundtripea.
+        match (r1.source.kind().unwrap(), r2.source.kind().unwrap()) {
+            (SourceKind::Git { commit: c1, .. }, SourceKind::Git { commit: c2, .. }) => {
+                assert_eq!(c1, c2);
+            }
+            _ => panic!("source kind cambió en el roundtrip"),
+        }
+    }
+
     #[test]
     fn parse_tarball_source() {
         let s = r#"
diff --git a/crates/hammer-core/src/store.rs b/crates/hammer-core/src/store.rs
index 629b25e9..71908511 100644
--- a/crates/hammer-core/src/store.rs
+++ b/crates/hammer-core/src/store.rs
@@ -6,6 +6,12 @@
 use std::path::{Path, PathBuf};
 
 use crate::hash::ArtifactHash;
+use crate::recipe::Recipe;
+
+/// Ruta relativa, dentro de cada artefacto sellado, donde el lab guarda la receta original
+/// como sidecar de provenance. Estable: el receptor de un `.swm` exportado puede asumir
+/// este path para reconstruir la receta de un artefacto cualquiera.
+pub const RECIPE_SIDECAR_REL: &str = ".hammer/recipe.toml";
 
 pub struct Store {
     root: PathBuf,
@@ -71,6 +77,28 @@ impl Store {
         }
     }
 
+    /// Lee la receta sidecar (`.hammer/recipe.toml`) dentro de un artefacto sellado, si
+    /// existe. Devuelve `None` para artefactos viejos sin sidecar (builds anteriores a la
+    /// introducción del provenance) o cuando el sidecar fue eliminado a mano. Es
+    /// best-effort: nunca falla por ausencia, sólo por sidecar corrupto.
+    pub fn recipe_for_dir(&self, artifact_dir: &Path) -> crate::Result<Option<Recipe>> {
+        let p = artifact_dir.join(RECIPE_SIDECAR_REL);
+        if !p.is_file() {
+            return Ok(None);
+        }
+        let text = std::fs::read_to_string(&p)?;
+        Recipe::from_toml(&text).map(Some)
+    }
+
+    /// Como `recipe_for_dir` pero resuelve el artefacto por hash (con o sin `b3:`).
+    pub fn recipe_for_hash(&self, hex_or_prefixed: &str) -> crate::Result<Option<Recipe>> {
+        let dir = match self.find_by_hash(hex_or_prefixed) {
+            Ok(d) => d,
+            Err(_) => return Ok(None),
+        };
+        self.recipe_for_dir(&dir)
+    }
+
     /// Sella el árbol de salida de un build en el store bajo su hash.
     ///
     /// - Si el destino ya existe (caché), devuelve la ruta sin tocar `out_dir`.
@@ -193,6 +221,67 @@ mod tests {
         assert!(err.contains("aaa111-foo") && err.contains("aaa222-bar"), "{err}");
     }
 
+    #[test]
+    fn recipe_for_dir_returns_none_without_sidecar() {
+        let store_dir = tempfile::tempdir().unwrap();
+        let store = Store::open(store_dir.path()).unwrap();
+        let art = store_dir.path().join("aaa-foo");
+        std::fs::create_dir_all(&art).unwrap();
+        let r = store.recipe_for_dir(&art).unwrap();
+        assert!(r.is_none());
+    }
+
+    #[test]
+    fn recipe_for_dir_reads_sidecar() {
+        let store_dir = tempfile::tempdir().unwrap();
+        let store = Store::open(store_dir.path()).unwrap();
+        let art = store_dir.path().join("aaa-foo");
+        std::fs::create_dir_all(art.join(".hammer")).unwrap();
+        std::fs::write(
+            art.join(RECIPE_SIDECAR_REL),
+            r#"
+name = "grep"
+version = "3.12"
+[source]
+repo   = "git://example/grep.git"
+commit = "deadbeef"
+[build]
+compiler = "zig-cc"
+target   = "x86_64-linux-musl"
+link     = "static"
+flags    = []
+"#,
+        )
+        .unwrap();
+        let r = store.recipe_for_dir(&art).unwrap().expect("Some");
+        assert_eq!(r.name, "grep");
+        assert_eq!(r.version, "3.12");
+    }
+
+    #[test]
+    fn recipe_for_hash_resolves_by_prefix_then_reads() {
+        let store_dir = tempfile::tempdir().unwrap();
+        let store = Store::open(store_dir.path()).unwrap();
+        let art = store.root().join("cafe1234-demo");
+        std::fs::create_dir_all(art.join(".hammer")).unwrap();
+        std::fs::write(
+            art.join(RECIPE_SIDECAR_REL),
+            r#"
+name = "demo"
+version = "0.1"
+[source]
+tarball = "https://x/y.tar.gz"
+sha256  = "abc123"
+[build]
+"#,
+        )
+        .unwrap();
+        let r = store.recipe_for_hash("cafe").unwrap().expect("Some");
+        assert_eq!(r.name, "demo");
+        // Hash inexistente: None (no error).
+        assert!(store.recipe_for_hash("ffffff").unwrap().is_none());
+    }
+
     #[test]
     fn seal_is_idempotent_when_dst_exists() {
         let store_dir = tempfile::tempdir().unwrap();
diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md
index fbeb5a9e..876877ec 100644
--- a/docs/10-roadmap.md
+++ b/docs/10-roadmap.md
@@ -67,9 +67,11 @@ pre-requisito de validación.
 - [x] CLI: `hammer apply [--prefix DIR] [--base-ref base.json]`,
       `hammer swm-verify`, `hammer export --journal DIR > out.swm`.
 - [ ] `patch_url` / `content_url` remotos (hoy sólo inline).
-- [ ] Provenance en `export`: mapa artefacto→receta para emitir `source_patch` en vez de
-      `file_drop`. Hoy se emiten file_drops con `content_b64`, lo que reproduce byte-a-byte
-      pero pierde la receta original.
+- [x] Provenance en `export`: mapa artefacto→receta vía sidecar `.hammer/recipe.toml` que
+      `hammer-build::build` escribe dentro del artefacto antes de sellar. `hammer export`
+      agrupa eventos por `artifact_hash` y emite UN `source_patch` por grupo cuya receta
+      sea recuperable; lo demás cae al fallback `file_drop`. Source `git` modelado;
+      `tarball` cae a file_drop con warning (pendiente extender SourcePatch).
 - [ ] Firma `signature` (ed25519) y `TrustStore` local.
 - **Hecho cuando:** exportas un cambio, lo aplicas en otra máquina y reproduce idéntico.
       ✅ Demostrado en `crates/hammer-cli/tests/swm_roundtrip.rs` para