Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 96 additions & 38 deletions crates/archmaker-core/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -718,40 +718,93 @@ fn resolve_internal(
})
}

pub fn render_artifact_bytes(manifest: &Manifest, target: &TargetRef) -> Result<(Vec<u8>, String)> {
/// Bytes + media type + adapter payload for a sealed manifest (ADR-0003 / DEC-001).
#[derive(Debug, Clone, PartialEq)]
pub struct RenderedArtifact {
pub bytes: Vec<u8>,
pub media_type: String,
pub content: serde_json::Value,
}

fn ordered_effective_selections(manifest: &Manifest) -> Vec<ResolvedSelection> {
let mut rows = manifest.effective_selections.clone();
rows.sort_by(|a, b| (&a.step_id, &a.option_id).cmp(&(&b.step_id, &b.option_id)));
rows
}

fn render_archmaker_profile(manifest: &Manifest) -> Result<RenderedArtifact> {
let source = CoreOp::ExportArtifact.source();
match target.id.as_str() {
"archmaker-profile" | "archinstall-profile" => {
let value =
serde_json::to_value(manifest).map_err(|_| CoreError::doc_canonical(source))?;
Ok((
archmaker_canonicalization::canonicalize(&value),
"application/json".to_string(),
))
}
"report" => {
let mut text = String::from("# ArchMaker report\n\n");
text.push_str(&format!("domain: {MANIFEST_DOMAIN_LABEL}\n"));
text.push_str(&format!("target: {} {}\n", target.id, target.version));
text.push_str(&format!(
"contentDigest: sha256:{}\n\n",
manifest.content_digest.value
));
text.push_str("## Effective selections\n\n");
let mut rows = manifest.effective_selections.clone();
rows.sort_by(|a, b| (&a.step_id, &a.option_id).cmp(&(&b.step_id, &b.option_id)));
for selection in &rows {
let origin = match selection.origin {
SelectionOrigin::Manual => "manual",
SelectionOrigin::Derived => "derived",
};
text.push_str(&format!(
"- {} = {} ({})\n",
selection.step_id, selection.option_id, origin
));
let value = serde_json::to_value(manifest).map_err(|_| CoreError::doc_canonical(source))?;
Ok(RenderedArtifact {
bytes: archmaker_canonicalization::canonicalize(&value),
media_type: "application/json".to_string(),
content: value,
})
}

/// Experimental archinstall adapter: versioned envelope, never the raw manifest (ADR-0003).
fn render_archinstall_profile(manifest: &Manifest, target: &TargetRef) -> Result<RenderedArtifact> {
let source = CoreOp::ExportArtifact.source();
let selections = ordered_effective_selections(manifest);
let envelope = serde_json::json!({
"adapter": "archinstall-profile",
"version": target.version,
"manifestRef": {
"contentDigest": {
"algorithm": "sha256",
"value": manifest.content_digest.value,
"kind": "content",
}
},
"effectiveSelections": selections,
});
// Reject executable / shell-shaped keys if they ever appear in the envelope.
if let Some(obj) = envelope.as_object() {
for banned in ["cmd", "hooks", "pacstrap", "shell", "script", "commands"] {
if obj.contains_key(banned) {
return Err(CoreError::tgt_unsupported(source));
}
Ok((text.into_bytes(), "text/markdown".to_string()))
}
}
Ok(RenderedArtifact {
bytes: archmaker_canonicalization::canonicalize(&envelope),
media_type: "application/json".to_string(),
content: envelope,
})
}

fn render_report(manifest: &Manifest, target: &TargetRef) -> Result<RenderedArtifact> {
let mut text = String::from("# ArchMaker report\n\n");
text.push_str(&format!("domain: {MANIFEST_DOMAIN_LABEL}\n"));
text.push_str(&format!("target: {} {}\n", target.id, target.version));
text.push_str(&format!(
"contentDigest: sha256:{}\n\n",
manifest.content_digest.value
));
text.push_str("## Effective selections\n\n");
for selection in &ordered_effective_selections(manifest) {
let origin = match selection.origin {
SelectionOrigin::Manual => "manual",
SelectionOrigin::Derived => "derived",
};
text.push_str(&format!(
"- {} = {} ({})\n",
selection.step_id, selection.option_id, origin
));
}
Ok(RenderedArtifact {
bytes: text.as_bytes().to_vec(),
media_type: "text/markdown".to_string(),
content: serde_json::Value::String(text),
})
}

pub fn render_artifact_bytes(manifest: &Manifest, target: &TargetRef) -> Result<RenderedArtifact> {
let source = CoreOp::ExportArtifact.source();
match target.id.as_str() {
"archmaker-profile" => render_archmaker_profile(manifest),
"archinstall-profile" => render_archinstall_profile(manifest, target),
"report" => render_report(manifest, target),
_ => Err(CoreError::tgt_unsupported(source)),
}
}
Expand Down Expand Up @@ -999,10 +1052,10 @@ impl CorePort for RealCore {
if recomputed.value != input.manifest.content_digest.value {
return Err(CoreError::doc_canonical(source));
}
let (bytes, media_type) = render_artifact_bytes(&input.manifest, &input.target_ref)?;
let rendered = render_artifact_bytes(&input.manifest, &input.target_ref)?;
let binary = BinaryDigest {
algorithm: DigestAlgorithm::Sha256,
value: sha256_hex(&bytes),
value: sha256_hex(&rendered.bytes),
kind: Some(BinaryKind::Binary),
};
let dir = self
Expand All @@ -1012,27 +1065,32 @@ impl CorePort for RealCore {
atomic_write_bytes(
CoreOp::ExportArtifact,
&dir.join(input.destination.token()),
&bytes,
&rendered.bytes,
)?;
let experimental = if input.target_ref.id == "archinstall-profile" {
Some(true)
} else {
None
};
Ok(Artifact {
document_version: input.manifest.document_version.clone(),
schema_version: input.manifest.schema_version.clone(),
artifact_ref: ArtifactRef {
binary_digest: binary.clone(),
media_type: media_type.clone(),
media_type: rendered.media_type.clone(),
},
target_ref: input.target_ref,
producer: Producer {
id: PRODUCER_ID.to_string(),
version: PRODUCER_VERSION.to_string(),
},
media_type,
media_type: rendered.media_type,
binary_digest: binary,
manifest_ref: ManifestRef {
content_digest: input.manifest.content_digest.clone(),
},
experimental: Some(input.manifest.target_ref.id == "archinstall-profile"),
content: None,
experimental,
content: Some(rendered.content),
})
}

Expand Down
2 changes: 1 addition & 1 deletion crates/archmaker-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ pub use archmaker_domain::{
pub use archmaker_rules::Diagnostic as RuleDiagnostic;
pub use core::{
catalog_content_digest, draft_content_digest, is_uuid, manifest_content_digest, new_uuid_v4,
render_artifact_bytes, RealCore, EMBEDDED_CATALOG_JSON, PIPELINE_VERSION,
render_artifact_bytes, RealCore, RenderedArtifact, EMBEDDED_CATALOG_JSON, PIPELINE_VERSION,
};
pub use error::{CoreError, ErrorCategory, ErrorFamily, Result, Severity};
pub use inputs::{
Expand Down
121 changes: 119 additions & 2 deletions crates/archmaker-core/tests/core_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -542,11 +542,128 @@ fn export_artifact_byte_idempotent() {
.expect("exports again");
assert_eq!(first.binary_digest, second.binary_digest);
assert_eq!(first.manifest_ref.content_digest, manifest.content_digest);
assert!(first.content.is_some(), "adapter content filled");
let bytes_a = std::fs::read(dir.join("artifact-a.json")).expect("reads a");
let bytes_b = std::fs::read(dir.join("artifact-b.json")).expect("reads b");
assert_eq!(bytes_a, bytes_b);
let (rendered, _) = render_artifact_bytes(&manifest, &export_target()).expect("renders");
assert_eq!(rendered, bytes_a);
let rendered = render_artifact_bytes(&manifest, &export_target()).expect("renders");
assert_eq!(rendered.bytes, bytes_a);
assert_eq!(rendered.media_type, "application/json");
let _ = std::fs::remove_dir_all(&dir);
}

fn export_target_archinstall() -> TargetRef {
TargetRef {
id: "archinstall-profile".to_string(),
version: "0.1.0".to_string(),
}
}

fn export_target_report() -> TargetRef {
TargetRef {
id: "report".to_string(),
version: "0.1.0".to_string(),
}
}

#[test]
fn export_adapters_diverge_bytes_and_fill_content() {
let (core, dir) = core_with_store("export-adapters");
let draft = valid_draft(&core);
let manifest_archmaker = core
.build_manifest(BuildManifestInput {
draft: draft.clone(),
catalog_ref: catalog_ref(),
target_ref: export_target(),
expected_resolution_digest: None,
request_id: None,
})
.expect("builds archmaker");
let manifest_archinstall = core
.build_manifest(BuildManifestInput {
draft: draft.clone(),
catalog_ref: catalog_ref(),
target_ref: export_target_archinstall(),
expected_resolution_digest: None,
request_id: None,
})
.expect("builds archinstall");
let manifest_report = core
.build_manifest(BuildManifestInput {
draft,
catalog_ref: catalog_ref(),
target_ref: export_target_report(),
expected_resolution_digest: None,
request_id: None,
})
.expect("builds report");

let archmaker = core
.export_artifact(ExportArtifactInput {
manifest: manifest_archmaker.clone(),
target_ref: export_target(),
destination: DestinationHandle::new("a.json".to_string()).expect("handle"),
request_id: None,
})
.expect("exports archmaker");
let archinstall = core
.export_artifact(ExportArtifactInput {
manifest: manifest_archinstall.clone(),
target_ref: export_target_archinstall(),
destination: DestinationHandle::new("b.json".to_string()).expect("handle"),
request_id: None,
})
.expect("exports archinstall");
let report = core
.export_artifact(ExportArtifactInput {
manifest: manifest_report,
target_ref: export_target_report(),
destination: DestinationHandle::new("c.md".to_string()).expect("handle"),
request_id: None,
})
.expect("exports report");

assert_ne!(
archmaker.binary_digest.value,
archinstall.binary_digest.value
);
assert_ne!(archmaker.binary_digest.value, report.binary_digest.value);
assert_ne!(archinstall.binary_digest.value, report.binary_digest.value);
assert_eq!(
archmaker.manifest_ref.content_digest.value,
manifest_archmaker.content_digest.value
);
assert_eq!(
archinstall.manifest_ref.content_digest.value,
manifest_archinstall.content_digest.value
);
assert_eq!(archinstall.experimental, Some(true));
assert!(archmaker.experimental.is_none());
assert_eq!(report.media_type, "text/markdown");
assert!(archmaker.content.is_some());
assert!(report.content.as_ref().is_some_and(|v| v.is_string()));

let envelope = archinstall.content.as_ref().expect("archinstall content");
assert_eq!(envelope["adapter"], "archinstall-profile");
assert_eq!(envelope["version"], "0.1.0");
assert_eq!(
envelope["manifestRef"]["contentDigest"]["value"],
manifest_archinstall.content_digest.value
);
assert!(envelope["effectiveSelections"].is_array());
for banned in ["cmd", "hooks", "pacstrap", "shell", "script", "commands"] {
assert!(
envelope.get(banned).is_none(),
"envelope must not carry executable key {banned}"
);
}
let bytes_a = std::fs::read(dir.join("a.json")).expect("a");
let bytes_b = std::fs::read(dir.join("b.json")).expect("b");
assert_ne!(bytes_a, bytes_b);
// archinstall envelope must not be a raw re-serialization of the domain Manifest.
let parsed_b: serde_json::Value = serde_json::from_slice(&bytes_b).expect("json");
assert!(parsed_b.get("domainLabel").is_none());
assert_eq!(parsed_b["adapter"], "archinstall-profile");
let _ = std::fs::remove_dir_all(&dir);
}

Expand Down
18 changes: 11 additions & 7 deletions docs/00-governance/go-no-go.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ reviewers:
- Reevaluación slice MVP-2 (MVP-2-07): **CONDITIONAL-CLEAR — walking skeleton MVP-0 + slices MVP-1/MVP-2**
(reglas de catálogo, resolución de dominio, validación que bloquea manifiesto). Baseline emitida:
`implementation-baseline-mvp2` (tags previos intactos).
- Reevaluación slice MVP-3 (MVP-3-06): **CONDITIONAL-CLEAR — walking skeleton MVP-0 + slices MVP-1/MVP-2/MVP-3**
(adapters de exportación versionados; ADR-0003 parcial). Baseline emitida:
`implementation-baseline-mvp3` (tags previos intactos).
- Alcance autorizado: `docs/10-delivery/walking-skeleton.md` (DOC-DEL-WSK-001),
`docs/10-delivery/mvp1-scope.md` (DOC-DEL-MVP1-001) y `docs/10-delivery/mvp2-scope.md`
(DOC-DEL-MVP2-001). Bloqueados: runner real, root, discos, shell arbitraria, catálogos remotos,
firma criptográfica de catálogo en carga, Enterprise.
`docs/10-delivery/mvp1-scope.md` (DOC-DEL-MVP1-001), `docs/10-delivery/mvp2-scope.md`
(DOC-DEL-MVP2-001) y `docs/10-delivery/mvp3-scope.md` (DOC-DEL-MVP3-001). Bloqueados: runner real,
root, discos, shell arbitraria, catálogos remotos, firma criptográfica de catálogo en carga,
Enterprise.

## Motivo del resultado mecánico

Expand Down Expand Up @@ -94,10 +98,10 @@ Resultado derivado: **Clear** — todos los gates G0–G10 están `complete`.
## Consecuencia inmediata

Autorizado: el walking skeleton MVP-0 definido en `docs/10-delivery/walking-skeleton.md`,
el slice **MVP-1** (`mvp1-scope.md`) y el slice **MVP-2** (reglas de catálogo / resolución /
validación) según `docs/10-delivery/mvp2-scope.md`. **No** autorizado: runner real, root,
discos, shell arbitraria, catálogos remotos, firma criptográfica de catálogo en carga, ni
Enterprise.
el slice **MVP-1** (`mvp1-scope.md`), el slice **MVP-2** (reglas / resolución / validación)
según `docs/10-delivery/mvp2-scope.md` y el slice **MVP-3** (adapters de exportación) según
`docs/10-delivery/mvp3-scope.md`. **No** autorizado: runner real, root, discos, shell
arbitraria, catálogos remotos, firma criptográfica de catálogo en carga, ni Enterprise.

## Decisiones de alcance (C3 — G1-C03)

Expand Down
8 changes: 4 additions & 4 deletions docs/00-governance/plan-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ reviewers:
# Estado de planificación

- Estado: in-review
- Gate global: CONDITIONAL-CLEAR — walking skeleton MVP-0 + slices MVP-1/MVP-2 (sin runner)
- Baseline vigente: `implementation-baseline-mvp2` (predecesores mvp0.1 y mvp1 intactos)
- Gate global: CONDITIONAL-CLEAR — walking skeleton MVP-0 + slices MVP-1/MVP-2/MVP-3 (sin runner)
- Baseline vigente: `implementation-baseline-mvp3` (predecesores mvp0.1, mvp1 y mvp2 intactos)
- Fecha de corte: 2026-10-12

## Bloqueos P0
Expand All @@ -28,8 +28,8 @@ documento no los repite.

## Regla de avance

Se autorizan el walking skeleton MVP-0 y el alcance MVP-1 de
`docs/10-delivery/mvp1-scope.md` (dominio, catálogo local, migración). El runner v1 y los
Se autorizan el walking skeleton MVP-0 y los slices MVP-1 (`mvp1-scope.md`), MVP-2
(`mvp2-scope.md`) y MVP-3 (`mvp3-scope.md`, adapters de exportación). El runner v1 y los
privilegios se autorizan únicamente cuando G0–G10 tengan evidencia específica de esas fases,
no existan riesgos P0 sin tratamiento y todos los contratos P0 posean fixtures válidos e inválidos.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ phase: MVP
priority: P0
documentStatus: accepted
approvalStatus: approved
implementationStatus: not-started
verificationStatus: not-verified
implementationStatus: partial
verificationStatus: partial
releaseStatus: ineligible
owners:
- architecture
Expand Down
Loading
Loading