H4b: gate de compatibilidad de configs en la receta real + hammer install (SDD 15 §H4)

Sube el modelo de slots de wawa-memo (prototipo host) a hammer:
- Recipe + source_patch del .swm llevan bloque `slots` {claims, requires} (slot->b3:…),
  FUERA de hash_inputs (topología ≠ identidad, no mueve el artifact_hash). Viaja intacto
  por los dos sentidos del puente (Recipe->.swm->Recipe): test de round-trip.
- InstalledDb registra claims por paquete + system_state() -> slot->hash (el Estado del gate).
- hammer-core::compat::evaluar(estado, slots) -> Veredicto {Compatible, Colision, Incompatible}
  (el álgebra probada en wawa-memo, sobre tipos de hammer).
- Gate en `hammer install`: antes de tocar nada evalúa el paquete contra el estado instalado.
  Incompatible (requisito sin resolver, caso wayland) -> aborta; Colisión (caso logo) ->
  aborta pidiendo elección salvo --force-slots; Compatible -> procede y registra los claims.

Plumbing propagado por los 5 sitios de Mutation::SourcePatch (from_recipe, swm_bridge,
export, bus, orchestrator). Tests: compat (4) + slots-no-en-hash/round-trip (2) +
system_state (1) + puente receta<->swm (1). Workspace compila y verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:10:02 -04:00
co-authored by Claude Opus 4.8
parent 66241c31a4
commit 27066776b3
11 changed files with 433 additions and 13 deletions
+63
View File
@@ -241,6 +241,11 @@ enum Cmd {
/// (no basta con que el `.swm` reproduzca — exige autoría del catálogo verificada).
#[arg(long)]
require_signed: bool,
/// [H4b] Ante una COLISIÓN de superficie (el paquete reclama un slot que ya ocupa otra
/// config, p. ej. el logo), adopta la nueva en vez de abortar. No afecta a un requisito
/// insatisfecho (ese rechazo es duro: el paquete no encaja con lo que tenés).
#[arg(long)]
force_slots: bool,
},
/// [Etapa F] Desinstala un paquete: borra los ficheros que registró (refcount: respeta los
/// que otro paquete instalado también aporta) y lo quita de la DB de instalados.
@@ -852,6 +857,7 @@ fn main() -> anyhow::Result<()> {
state_root,
db,
require_signed,
force_slots,
} => {
run_install(
&cli.store,
@@ -864,6 +870,7 @@ fn main() -> anyhow::Result<()> {
state_root.as_deref(),
&db,
require_signed,
force_slots,
)?;
}
Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?,
@@ -1590,6 +1597,7 @@ fn run_install(
state_root: Option<&std::path::Path>,
db_path: &std::path::Path,
require_signed: bool,
force_slots: bool,
) -> anyhow::Result<()> {
let source = RepoSource::parse(repo);
let index = source.read_index()?;
@@ -1658,6 +1666,48 @@ fn run_install(
eprintln!("firma: no verificada (pasá --trust <dir> para validar la autoría)");
}
// Gate de compatibilidad (H4b — configs compatibles, SDD 15 §H4). Antes de reconstruir o tocar
// nada, evaluamos las superficies que el paquete declara (`slots`) contra el estado INSTALADO.
// Es el chequeo local y reproducible de "cuando instalo, ¿es compatible con lo que ya tengo?".
let entrant_slots = source_patch_slots(&load_swm(swm_path_str)?);
if !entrant_slots.is_empty() {
entrant_slots
.validate()
.map_err(|e| anyhow::anyhow!("slots del paquete inválidos: {e}"))?;
let idb = hammer_core::InstalledDb::load(db_path)?;
let estado = idb.system_state();
match hammer_core::compat::evaluar(&estado, &entrant_slots) {
hammer_core::CompatVeredicto::Compatible => {
eprintln!(
"compat: compatible ({} reclamo(s), {} requisito(s) resuelto(s))",
entrant_slots.claims.len(),
entrant_slots.requires.len()
);
}
// Caso wayland: una dependencia sobre una superficie que el usuario divergió. Rechazo duro.
hammer_core::CompatVeredicto::Incompatible { slot, requerido, presente } => {
anyhow::bail!(
"compat: INCOMPATIBLE — '{name}' requiere la superficie '{slot}' @ {requerido}, \
pero el sistema tiene {}. Divergiste esa superficie: no se instala sobre ella.",
presente.as_deref().unwrap_or("(ausente)")
);
}
// Caso logo: dos configs escriben la misma superficie. Es una ELECCIÓN, no un error.
hammer_core::CompatVeredicto::Colision { slot, instalado, entrante } => {
if force_slots {
eprintln!(
"compat: colisión en '{slot}' ({instalado}{entrante}); --force-slots ⇒ me quedo con la nueva"
);
} else {
anyhow::bail!(
"compat: COLISIÓN en la superficie '{slot}' — ya la ocupa {instalado} y '{name}' \
quiere {entrante}. Es una elección: pasá --force-slots para adoptar la nueva."
);
}
}
}
}
// Dependencias: resolvemos el cierre transitivo de build-deps desde el índice (deps antes
// que el dependiente) y poblamos el catálogo de recetas que el lab consultará al reproducir
// el `source_patch`. Sin esto, un paquete con build-deps (openssh→zlib,openssl) no podría
@@ -1729,6 +1779,9 @@ fn run_install(
version: pkg_version,
hash: pkg_hash,
files,
// Las superficies reclamadas quedan en la DB ⇒ pasan a formar el `system_state` que el
// gate de la PRÓXIMA instalación consultará (H4b). Así una colisión se detecta después.
claims: entrant_slots.claims.clone(),
});
idb.save(db_path)?;
eprintln!("registrado {pkg_name} ({nfiles} fichero(s)) en {}", db_path.display());
@@ -1949,6 +2002,15 @@ fn source_patch_of(swm: &hammer_core::Swm) -> Option<&hammer_core::Mutation> {
.find(|m| matches!(m, hammer_core::Mutation::SourcePatch { .. }))
}
/// Las superficies del sistema (H4b) que declara el `source_patch` de un `.swm`, o `Slots` vacío
/// si el paquete no declara topología. Lo consume el gate de compatibilidad de `install`.
fn source_patch_slots(swm: &hammer_core::Swm) -> hammer_core::Slots {
match source_patch_of(swm) {
Some(hammer_core::Mutation::SourcePatch { slots, .. }) => slots.clone(),
_ => hammer_core::Slots::default(),
}
}
/// Firma el índice de un repo (release). Re-firmá tras publicar.
fn run_repo_sign(
repo_dir: &std::path::Path,
@@ -2432,6 +2494,7 @@ fn build_export_mutations(
expected_hash: Some(art_hash),
deps: recipe.deps.clone(),
evidence: recipe.evidence.clone(),
slots: recipe.slots.clone(),
});
stats.source_patches += 1;
}