use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
sync::Arc,
time::Instant,
};
use crate::receipt_chain_seam::{
epoch::{
read_receipt_epoch, AdmissionDecision, AdmissionItem, AdmissionLedger, AndonLevel,
CeilingLevel, ComponentLevels, EquivalenceMap, EquivalenceStatus, ObservedOutcome,
ReceiptEpochV2, ReceiptEpochV2Builder, SCHEMA_V2,
},
Andon, ChainStanding, ChainVerification, ReceiptRecord, CHAIN_RULE_V2_FOLD,
RECEIPT_RECORD_VERSION,
};
use serde::Serialize;
use tera::Value;
use crate::{
config::GgenConfig,
error::{AppError, Result},
generation_rules::duration_ms,
graph::{
DeterministicGraph, EngineQueryResults, EngineValue, GraphEngine, GraphLawStore,
TurtleDocument,
},
template::{build_tera_with_packs, sparql_to_value, Frontmatter, MatchSpec, Template},
write::{
plan_write, preflight_checksum_slot, preflight_structured_matchers, validate_match_specs,
WriteOutcome, MAX_OUTPUT_BYTES,
},
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum EngineKind {
#[default]
GraphLaw,
Oxigraph,
}
pub fn new_graph_engine(kind: EngineKind) -> Result<Arc<dyn GraphEngine>> {
Ok(match kind {
EngineKind::GraphLaw => Arc::new(GraphLawStore::new()?),
EngineKind::Oxigraph => Arc::new(DeterministicGraph::new()?),
})
}
pub fn read_ontology_file(root: &Path, path: &Path) -> Result<(String, String)> {
let content = std::fs::read_to_string(path).map_err(|e| {
AppError::fm_config(
3,
format!(
"ontology `{}` unreadable: {e}. Remediation: fix [ontology].source or \
[ontology].imports.",
path.display()
),
)
})?;
Ok((rel_display(root, path), content))
}
fn absorb_manifest_rules(
root: &Path, manifest: &mut ggen_config::manifest::GgenManifest,
) -> Result<()> {
let Some(rules) = manifest.rules.take() else {
return Ok(());
};
if rules.n3.is_empty() && rules.datalog.is_empty() {
return Ok(());
}
let as_strings = |paths: &[std::path::PathBuf]| -> Vec<String> {
paths
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect()
};
if let Err(e) = crate::generation_rules::resolve_rule_sources(root, &as_strings(&rules.n3)) {
return Err(rule_source_refusal(&e));
}
if let Err(e) = crate::generation_rules::resolve_rule_sources(root, &as_strings(&rules.datalog))
{
return Err(rule_source_refusal(&e));
}
manifest.law.rules.extend(rules.n3);
Ok(())
}
fn rule_source_refusal(e: &crate::generation_rules::RuleSourceError) -> AppError {
match e {
crate::generation_rules::RuleSourceError::DatalogUnsupported { path } => AppError::fm_law(
19,
format!(
"UNSUPPORTED: [rules].datalog entry `{}` refused — ggen-engine has no \
Datalog text parser this release (graphlaw exposes N3 only). \
Remediation: translate the rules to N3 and declare them under \
[rules].n3.",
path.display()
),
),
crate::generation_rules::RuleSourceError::Unreadable { path, reason } => AppError::fm_law(
19,
format!(
"rule file `{}` unreadable: {reason}. Remediation: fix [rules].n3 in \
ggen.toml.",
path.display()
),
),
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SyncOptions {
pub dry_run: bool,
pub engine: EngineKind,
pub receipt_origin: Option<&'static str>,
pub consumer_mode: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct SyncReport {
pub written: Vec<PathBuf>,
pub skipped: Vec<(PathBuf, String)>,
pub graph_hash_hex: String,
pub decisions: BTreeMap<String, String>,
pub packs: BTreeMap<String, String>,
pub closure: BTreeMap<String, String>,
}
pub const RECEIPT_REL_PATH: &str = ".ggen-v2/receipt.json";
pub const MAX_QUERY_RESULT_ROWS: usize = 4_096;
pub const MAX_PENDING_OUTPUTS: usize = 8_192;
pub const MAX_TOTAL_RENDERED_BYTES: usize = 64 * 1024 * 1024;
struct PendingWrite {
to: String,
body: String,
frontmatter: Frontmatter,
}
#[derive(Debug, Clone, Copy)]
enum ProjectionMode {
Whole,
Row(usize),
Aggregate,
}
pub const RECEIPT_LOG_REL_PATH: &str = ".ggen-v2/receipt-log.jsonl";
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct SyncReceipt {
pub record: ReceiptRecord,
pub payload: ReceiptPayload,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReceiptPayload {
pub graph_hash: String,
pub outputs: BTreeMap<String, String>,
#[serde(default)]
pub packs: BTreeMap<String, String>,
#[serde(default)]
pub decisions: BTreeMap<String, String>,
#[serde(default)]
pub closure: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build_provenance: Option<String>,
}
#[allow(clippy::too_many_lines)]
pub fn sync(root: &Path, opts: SyncOptions) -> Result<SyncReport> {
let parsed = crate::schema_dispatch::load(root)?;
let config = match parsed {
crate::schema_dispatch::ParsedGgenToml::DeclarativeRules(mut manifest) => {
absorb_manifest_rules(root, &mut manifest)?;
return crate::generation_rules::run(root, &manifest, opts);
}
crate::schema_dispatch::ParsedGgenToml::Frontmatter(config) => *config,
};
let load_start = Instant::now();
let load_span = tracing::info_span!(
"pipeline.load",
"operation.name" = "pipeline.load",
"operation.type" = "pipeline",
"pipeline.stage" = "load",
"pipeline.duration_ms" = tracing::field::Empty,
);
let load_guard = load_span.enter();
let ontology_path = root.join(&config.ontology.source);
let (ontology_label, ttl) = read_ontology_file(root, &ontology_path)?;
let graph: Arc<dyn GraphEngine> = new_graph_engine(opts.engine)?;
let packs = if opts.dry_run {
crate::pack::resolve_read_only(&config, root)?
} else {
crate::pack::resolve(&config, root)?
};
let lock_entries = crate::pack::lock_entries(&config, &packs)?;
crate::pack::check_lock(root, &lock_entries)?;
let mut ontology_sources = Vec::with_capacity(
2 + packs
.iter()
.map(|pack| 1 + pack.extra_ontology_paths.len())
.sum::<usize>(),
);
ontology_sources.push((ontology_label, ttl));
for import in &config.ontology.imports {
ontology_sources.push(read_ontology_file(root, &root.join(import))?);
}
for pack in &packs {
let pack_ttl = std::fs::read_to_string(&pack.ontology_path).map_err(|e| {
AppError::fm_pack(
4,
format!(
"pack `{}`: ontology `{}` unreadable: {e}",
pack.name,
pack.ontology_path.display()
),
)
})?;
ontology_sources.push((rel_display(root, &pack.ontology_path), pack_ttl));
for (declared, extra_path) in &pack.extra_ontology_paths {
let extra_ttl = std::fs::read_to_string(extra_path).map_err(|e| {
AppError::fm_pack(
4,
format!(
"pack `{}`: extra ontology `{declared}` unreadable at `{}`: {e}",
pack.name,
extra_path.display()
),
)
})?;
ontology_sources.push((declared.clone(), extra_ttl));
}
}
let pack_topology_ttl = crate::pack_scope::topology_turtle(&packs);
if !pack_topology_ttl.is_empty() {
ontology_sources.push(("urn:ggen:pack-topology".to_string(), pack_topology_ttl));
}
let ontology_documents: Vec<TurtleDocument<'_>> = ontology_sources
.iter()
.map(|(label, content)| TurtleDocument::new(label, content))
.collect();
let ontology_receipt = graph.insert_turtle_documents(&ontology_documents)?;
tracing::debug!(
ontology.documents = ontology_receipt.documents,
ontology.parsed_quads = ontology_receipt.parsed_quads,
ontology.inserted_quads = ontology_receipt.inserted_quads,
"ontology batch admitted"
);
let templates = discover_templates(root, &config, &packs)?;
let mut closure: BTreeMap<String, String> = BTreeMap::new();
closure.insert(
"actuator".to_string(),
concat!("ggen@", env!("CARGO_PKG_VERSION")).to_string(),
);
closure.insert(
"policy:frontmatter-projection-limits".to_string(),
format!(
"max_query_rows={MAX_QUERY_RESULT_ROWS};max_outputs={MAX_PENDING_OUTPUTS}; max_total_rendered_bytes={MAX_TOTAL_RENDERED_BYTES}; max_output_bytes={MAX_OUTPUT_BYTES}"
),
);
closure.insert("project".to_string(), config.project.name.clone());
let manifest_path = root.join("ggen.toml");
closure.insert(
rel_display(root, &manifest_path),
hash_file_or_missing(&manifest_path),
);
closure.insert(
rel_display(root, &ontology_path),
hash_file_or_missing(&ontology_path),
);
for pack in &packs {
closure.insert(
rel_display(root, &pack.ontology_path),
hash_file_or_missing(&pack.ontology_path),
);
for (declared, extra_path) in &pack.extra_ontology_paths {
closure.insert(declared.clone(), hash_file_or_missing(extra_path));
}
}
for (tpl_path, tpl) in &templates {
closure.insert(rel_display(root, tpl_path), hash_file_or_missing(tpl_path));
let template_dir = tpl_path.parent().unwrap_or_else(|| Path::new("."));
if let Some(from) = tpl.frontmatter.from.as_deref() {
if let Ok(from_path) = crate::write::resolve_target(template_dir, from) {
closure.insert(
rel_display(root, &from_path),
hash_file_or_missing(&from_path),
);
}
}
for rdf_file in &tpl.frontmatter.rdf {
if let Ok(rdf_path) = crate::write::resolve_target(template_dir, rdf_file) {
closure.insert(
rel_display(root, &rdf_path),
hash_file_or_missing(&rdf_path),
);
}
}
}
if config.law.reflexive {
let log_path = root.join(RECEIPT_LOG_REL_PATH);
if let Ok(raw) = std::fs::read_to_string(&log_path) {
let mut malformed_count: usize = 0;
for (idx, line) in raw.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
if let Ok(receipt) = serde_json::from_str::<SyncReceipt>(line) {
let andon_label = format!("{:?}", receipt.record.andon);
let ttl = format!(
"@prefix ggenr: <http://seanchatmangpt.github.io/ggen/receipt#> .\n\
ggenr:sync-{chain} a ggenr:Sync ;\n\
\x20 ggenr:index {idx} ;\n\
\x20 ggenr:chainHash \"{chain_esc}\" ;\n\
\x20 ggenr:prevChainHash \"{prev_esc}\" ;\n\
\x20 ggenr:graphHash \"{graph_esc}\" ;\n\
\x20 ggenr:andon \"{andon_esc}\" ;\n\
\x20 ggenr:outputCount {outputs} .\n",
chain = receipt.record.chain_hash_hex,
idx = idx,
chain_esc = turtle_escape(&receipt.record.chain_hash_hex),
prev_esc = turtle_escape(&receipt.record.prev_chain_hash_hex),
graph_esc = turtle_escape(&receipt.payload.graph_hash),
andon_esc = turtle_escape(&andon_label),
outputs = receipt.payload.outputs.len(),
);
graph.insert_turtle(&ttl)?;
} else {
closure.insert(
format!("receipt-log:MALFORMED-{malformed_count}"),
"MALFORMED".to_string(),
);
malformed_count += 1;
}
}
}
}
drop(load_guard);
load_span.record("pipeline.duration_ms", duration_ms(load_start.elapsed()));
let extract_start = Instant::now();
let extract_span = tracing::info_span!(
"pipeline.extract",
"operation.name" = "pipeline.extract",
"operation.type" = "pipeline",
"pipeline.stage" = "extract",
"pipeline.duration_ms" = tracing::field::Empty,
);
let extract_guard = extract_span.enter();
for (_, tpl) in &templates {
if declares_rdf_overlay(&tpl.frontmatter) {
continue;
}
if let Some(construct) = tpl.frontmatter.construct.as_deref() {
insert_construct(graph.as_ref(), construct)?;
}
}
drop(extract_guard);
extract_span.record("pipeline.duration_ms", duration_ms(extract_start.elapsed()));
let validate_start = Instant::now();
let validate_span = tracing::info_span!(
"pipeline.validate",
"operation.name" = "pipeline.validate",
"operation.type" = "pipeline",
"pipeline.stage" = "validate",
"pipeline.duration_ms" = tracing::field::Empty,
);
let validate_guard = validate_span.enter();
for rel in &config.law.rules {
let rule_path = root.join(rel);
let src = std::fs::read_to_string(&rule_path).map_err(|e| {
AppError::fm_law(
10,
format!(
"rule file `{}` unreadable: {e}. Remediation: fix [law].rules.",
rule_path.display()
),
)
})?;
closure.insert(
rel_display(root, &rule_path),
hash_file_or_missing(&rule_path),
);
graph.load_rules(&src)?;
}
let mut any_hook_loaded = false;
for pack in &packs {
let hook_path = pack.root.join("hook.ttl");
if !hook_path.is_file() {
continue;
}
let hook_ttl = std::fs::read_to_string(&hook_path).map_err(|e| {
AppError::fm_pack(
14,
format!(
"pack `{}`: hook.ttl at `{}` exists but is unreadable: {e}. \
Remediation: fix the file's permissions/encoding or remove it.",
pack.name,
hook_path.display()
),
)
})?;
closure.insert(
rel_display(root, &hook_path),
hash_file_or_missing(&hook_path),
);
graph.load_hook_pack(&hook_ttl)?;
any_hook_loaded = true;
}
if !config.law.rules.is_empty() || any_hook_loaded {
graph.materialize()?;
let denials = graph.check_denials()?;
if !denials.is_empty() {
return Err(AppError::fm_law(
11,
format!(
"{} denial rule(s) violated after materialization: {}. \
Remediation: fix the facts or the denial rules in [law].rules \
(or a pack's hook.ttl).",
denials.len(),
denials.join("; ")
),
));
}
}
if !config.law.shapes.is_empty() {
return Err(AppError::fm_law(
12,
format!(
"[law].shapes ({} SHACL shapes file(s) declared) is no longer \
supported at sync time; SHACL shape gates were replaced by \
engine-independent SPARQL gate queries. Remediation: migrate to \
[law].gates = [\"…/*.rq\"] — each file holds one ASK (true = \
violation) or SELECT (any row = violation) query, optionally \
preceded by `# MESSAGE: <text>` comment lines.",
config.law.shapes.len()
),
));
}
let mut gates_attempted: Vec<String> = Vec::new();
let resolved_gates = resolve_gate_entries(root, &config.law.gates)?;
for (rel_disp, gate_path) in &resolved_gates {
let src = std::fs::read_to_string(gate_path).map_err(|e| {
AppError::fm_law(
12,
format!(
"SPARQL gate file `{}` unreadable: {e}. Remediation: fix [law].gates.",
gate_path.display()
),
)
})?;
closure.insert(rel_disp.clone(), hash_file_or_missing(gate_path));
gates_attempted.push(rel_disp.clone());
let gate = parse_gate_source(&src);
match evaluate_gate(graph.as_ref(), &gate.query)? {
GateOutcome::Pass => {}
GateOutcome::NotAGate => {
return Err(refuse_with_portable_envelope(
root,
&packs,
graph.as_ref(),
&gates_attempted,
"GATE_INVALID",
opts.dry_run,
AppError::fm_law(
12,
format!(
"SPARQL gate `{rel_disp}` is not a gate query: it must be an ASK \
(true = violation) or a SELECT (any row = violation), not \
a CONSTRUCT/DESCRIBE. Remediation: fix [law].gates."
),
),
));
}
GateOutcome::Violation(detail) => {
return Err(refuse_with_portable_envelope(
root,
&packs,
graph.as_ref(),
&gates_attempted,
"GATE_VIOLATION",
opts.dry_run,
AppError::fm_law(
13,
format!(
"SPARQL gate `{}` refused the sync: {}{detail}. \
Remediation: fix the offending facts or the gate query.",
rel_disp,
gate.message_prefix(),
),
),
));
}
}
}
for pack in &packs {
let legacy_shapes = pack.root.join("shapes.ttl");
if legacy_shapes.exists() {
return Err(AppError::fm_pack(
12,
format!(
"pack `{}`: shapes.ttl at `{}` is no longer supported; pack \
SHACL shape gates were replaced by engine-independent SPARQL \
gate queries. Remediation: migrate to gates/*.rq — each file \
holds one ASK (true = violation) or SELECT (any row = \
violation) query, optionally preceded by `# MESSAGE: <text>` \
comment lines — then delete shapes.ttl.",
pack.name,
legacy_shapes.display()
),
));
}
for gate_path in list_gate_files(&pack.root.join("gates"), &pack.name)? {
let src = std::fs::read_to_string(&gate_path).map_err(|e| {
AppError::fm_pack(
12,
format!(
"pack `{}`: gate `{}` exists but is unreadable: {e}. \
Remediation: fix the file's permissions/encoding or remove it.",
pack.name,
gate_path.display()
),
)
})?;
closure.insert(
rel_display(root, &gate_path),
hash_file_or_missing(&gate_path),
);
gates_attempted.push(rel_display(root, &gate_path));
let gate = parse_gate_source(&src);
match evaluate_gate(graph.as_ref(), &gate.query)? {
GateOutcome::Pass => {}
GateOutcome::NotAGate => {
return Err(refuse_with_portable_envelope(
root,
&packs,
graph.as_ref(),
&gates_attempted,
"GATE_INVALID",
opts.dry_run,
AppError::fm_pack(
12,
format!(
"pack `{}`: gate `{}` is not a gate query: it must be an \
ASK (true = violation) or a SELECT (any row = \
violation), not a CONSTRUCT/DESCRIBE. Remediation: fix \
or remove the gate file.",
pack.name,
gate_path.display()
),
),
));
}
GateOutcome::Violation(detail) => {
return Err(refuse_with_portable_envelope(
root,
&packs,
graph.as_ref(),
&gates_attempted,
"GATE_VIOLATION",
opts.dry_run,
AppError::fm_pack(
13,
format!(
"pack `{}` gate `{}` refused the sync against the union \
graph: {}{detail}. Remediation: fix the offending facts \
(in this pack, another pack, or the project ontology — \
gates run against the UNION, so a violation may come \
from any graph source), or fix the gate query.",
pack.name,
gate_path
.file_name()
.unwrap_or(gate_path.as_os_str())
.to_string_lossy(),
gate.message_prefix(),
),
),
));
}
}
}
}
drop(validate_guard);
validate_span.record(
"pipeline.duration_ms",
duration_ms(validate_start.elapsed()),
);
{
let mut pack_manifests = Vec::new();
for pack in &packs {
let manifest_path = pack.root.join("pack.toml");
if manifest_path.is_file() {
if let Ok(content) = std::fs::read_to_string(&manifest_path) {
if let Ok(pm) = ggen_abb_sbb::depgraph::extract_pack_manifest(&content) {
pack_manifests.push(pm);
}
}
}
}
if !pack_manifests.is_empty() {
let consumer_edges = if manifest_path.is_file() {
std::fs::read_to_string(&manifest_path).ok().and_then(|c| {
ggen_abb_sbb::depgraph::extract_consumer_edges(&c)
.ok()
.flatten()
})
} else {
None
};
ggen_abb_sbb::depgraph::resolve_sync_order(&pack_manifests, consumer_edges.as_ref())
.map_err(|refusal| {
AppError::fm_pack(
15,
format!("Cross-pack dependency graph admission refused: {refusal}"),
)
})?;
}
let ea_graph_path = root.join("ea.graph.json");
if ea_graph_path.is_file() {
let content = std::fs::read_to_string(&ea_graph_path).map_err(|e| {
AppError::fm_law(
15,
format!(
"ea.graph.json at `{}` unreadable: {e}",
ea_graph_path.display()
),
)
})?;
let ea_graph = ggen_abb_sbb::parse_graph(&content).map_err(|refusal| {
AppError::fm_law(
15,
format!(
"EA graph at `{}` malformed: {refusal}",
ea_graph_path.display()
),
)
})?;
ea_graph.validate().map_err(|refusal| {
AppError::fm_law(
15,
format!(
"EA graph at `{}` failed validation: {refusal}",
ea_graph_path.display()
),
)
})?;
for sbb in &ea_graph.candidate_sbbs {
let req = ggen_abb_sbb::Request {
abb: sbb.abb.clone(),
sbb: sbb.id.clone(),
requested_authority: ggen_abb_sbb::Authority::Construct,
expected_graph_digest: None,
};
ggen_abb_sbb::admit(&ea_graph, &req).map_err(|refusal| {
AppError::fm_law(
15,
format!("EA SBB admission refused for `{}`: {refusal}", sbb.id),
)
})?;
}
}
}
let graph_hash_hex = hex32(&graph.state_hash()?);
let generate_start = Instant::now();
let generate_span = tracing::info_span!(
"pipeline.generate",
"operation.name" = "pipeline.generate",
"operation.type" = "pipeline",
"pipeline.stage" = "generate",
"pipeline.duration_ms" = tracing::field::Empty,
);
let generate_guard = generate_span.enter();
let mut tera = build_tera_with_packs(Arc::clone(&graph), &packs)?;
let consumer_fixture_subjects: Option<std::collections::BTreeSet<String>> =
if opts.consumer_mode || config.templates.consumer_mode {
Some(fixture_only_subjects(graph.as_ref())?)
} else {
None
};
let mut skipped: Vec<(PathBuf, String)> = Vec::new();
let mut decisions: BTreeMap<String, String> = BTreeMap::new();
let mut pending: Vec<PendingWrite> = Vec::new();
let mut independent_recheck_base: Option<Arc<dyn GraphEngine>> = None;
for (tpl_path, tpl) in &templates {
let overlay = build_rdf_overlay(&graph, tpl_path, &tpl.frontmatter)?;
if let Some(overlay_graph) = &overlay {
if let Some(construct) = tpl.frontmatter.construct.as_deref() {
insert_construct(overlay_graph.as_ref(), construct)?;
}
}
let mut overlay_tera_slot;
let (active_graph, active_tera): (&Arc<dyn GraphEngine>, &mut tera::Tera) = match &overlay {
Some(og) => {
overlay_tera_slot = build_tera_with_packs(Arc::clone(og), &packs)?;
(og, &mut overlay_tera_slot)
}
None => (&graph, &mut tera),
};
let determinism_recheck: Option<ExtractedRows> = if tpl.frontmatter.determinism
== Some(true)
{
let fresh_base = if let Some(existing) = &independent_recheck_base {
Arc::clone(existing)
} else {
let built =
build_independent_recheck_base_graph(root, &config, opts, &packs, &templates)?;
independent_recheck_base = Some(Arc::clone(&built));
built
};
let recheck_graph: Arc<dyn GraphEngine> =
match build_rdf_overlay(&fresh_base, tpl_path, &tpl.frontmatter)? {
Some(fresh_overlay) => fresh_overlay,
None => fresh_base,
};
Some(extract_query_results(
recheck_graph.as_ref(),
tpl_path,
&tpl.frontmatter,
"determinism_recheck",
)?)
} else {
None
};
let Some((named, results)) =
extract_query_results(active_graph.as_ref(), tpl_path, &tpl.frontmatter, "primary")?
else {
check_determinism_guard_agrees_false(tpl_path, determinism_recheck.as_ref())?;
let reason = format!("when guard false ({})", tpl_path.display());
let evidence_key =
if tpl.frontmatter.to.contains("{{") || tpl.frontmatter.to.contains("{%") {
format!("@template/{}", rel_display(root, tpl_path))
} else {
crate::write::resolve_target(root, &tpl.frontmatter.to)?;
tpl.frontmatter.to.clone()
};
decisions.insert(evidence_key.clone(), format!("skipped: {reason}"));
skipped.push((PathBuf::from(evidence_key), reason));
continue;
};
let fan_out = tpl.frontmatter.to.contains("{{");
let explicit_driver = tpl.frontmatter.for_each.is_some();
if explicit_driver && !fan_out {
let Some((body, frontmatter)) = render_aggregate_projection(
active_tera,
&tpl.body,
tpl_path,
&tpl.frontmatter,
&named,
&results,
)?
else {
check_determinism_rows_agree_empty(
tpl_path,
determinism_recheck.as_ref(),
"named `for_each:` driver",
)?;
let driver_name = tpl.frontmatter.for_each.as_deref().unwrap_or("?");
let reason = format!(
"for_each `{driver_name}` produced 0 rows ({})",
tpl_path.display()
);
crate::write::resolve_target(root, &tpl.frontmatter.to)?;
let evidence_key = tpl.frontmatter.to.clone();
decisions.insert(evidence_key.clone(), format!("skipped: {reason}"));
skipped.push((PathBuf::from(evidence_key), reason));
continue;
};
admit_shape_files(
root,
tpl_path,
&frontmatter.shape,
active_graph.as_ref(),
&mut closure,
)?;
check_determinism(
active_tera,
&tpl.body,
tpl_path,
&tpl.frontmatter,
determinism_recheck.as_ref(),
ProjectionMode::Aggregate,
&body,
&frontmatter,
)?;
if let Some(subjects) = &consumer_fixture_subjects {
if let Some(iri) = named
.values()
.find_map(|v| fixture_reference_in(v, subjects))
{
let reason = format!(
"consumer-mode: aggregate projection references fixtureOnly-marked \
spec <{iri}> — installer emission suppressed"
);
decisions.insert(tpl.frontmatter.to.clone(), format!("skipped: {reason}"));
skipped.push((PathBuf::from(tpl.frontmatter.to.clone()), reason));
continue;
}
}
pending.push(PendingWrite {
to: tpl.frontmatter.to.clone(),
body,
frontmatter,
});
} else if fan_out {
if results.is_empty() {
check_determinism_rows_agree_empty(
tpl_path,
determinism_recheck.as_ref(),
"implicit row fan-out",
)?;
let reason = format!(
"for_each (implicit row fan-out) produced 0 rows ({})",
tpl_path.display()
);
let evidence_key = format!("@template/{}", rel_display(root, tpl_path));
decisions.insert(evidence_key.clone(), format!("skipped: {reason}"));
skipped.push((PathBuf::from(evidence_key), reason));
}
for (row_index, row) in results.iter().enumerate() {
let ctx = row_context(&named, &results, row);
let to = render_str(active_tera, &tpl.frontmatter.to, &ctx, tpl_path)?;
let body = render_str(active_tera, &tpl.body, &ctx, tpl_path)?;
let frontmatter =
render_output_frontmatter(active_tera, &tpl.frontmatter, &ctx, tpl_path, &to)?;
admit_shape_files(
root,
tpl_path,
&frontmatter.shape,
active_graph.as_ref(),
&mut closure,
)?;
check_determinism(
active_tera,
&tpl.body,
tpl_path,
&tpl.frontmatter,
determinism_recheck.as_ref(),
ProjectionMode::Row(row_index),
&body,
&frontmatter,
)?;
if let Some(subjects) = &consumer_fixture_subjects {
if let Some(iri) = fixture_reference_in(&results[row_index], subjects) {
let reason = format!(
"consumer-mode: row references fixtureOnly-marked spec \
<{iri}> — installer emission suppressed"
);
decisions.insert(to.clone(), format!("skipped: {reason}"));
skipped.push((PathBuf::from(to.clone()), reason));
continue;
}
}
pending.push(PendingWrite {
to,
body,
frontmatter,
});
}
} else {
let ctx = base_context(&named, &results);
let to = tpl.frontmatter.to.clone();
let body = render_str(active_tera, &tpl.body, &ctx, tpl_path)?;
let frontmatter =
render_output_frontmatter(active_tera, &tpl.frontmatter, &ctx, tpl_path, &to)?;
admit_shape_files(
root,
tpl_path,
&frontmatter.shape,
active_graph.as_ref(),
&mut closure,
)?;
check_determinism(
active_tera,
&tpl.body,
tpl_path,
&tpl.frontmatter,
determinism_recheck.as_ref(),
ProjectionMode::Whole,
&body,
&frontmatter,
)?;
if let Some(subjects) = &consumer_fixture_subjects {
if let Some(iri) = named
.values()
.find_map(|v| fixture_reference_in(v, subjects))
{
let reason = format!(
"consumer-mode: projection references fixtureOnly-marked spec \
<{iri}> — installer emission suppressed"
);
decisions.insert(to.clone(), format!("skipped: {reason}"));
skipped.push((PathBuf::from(to.clone()), reason));
continue;
}
}
pending.push(PendingWrite {
to,
body,
frontmatter,
});
}
}
if config.templates.aggregate_modules {
const AGGREGATOR_REL_PATH: &str = "src/ggen_pack_mods.rs";
let mut mounts: Vec<(String, String)> = pending
.iter()
.filter(|pw| {
pw.to.starts_with("src/")
&& Path::new(&pw.to)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("rs"))
&& pw.to != AGGREGATOR_REL_PATH
})
.map(|pw| {
let rel_to_src = pw.to.trim_start_matches("src/").to_string();
let mod_name = rel_to_src
.trim_end_matches(".rs")
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect::<String>();
(mod_name, rel_to_src)
})
.collect();
mounts.sort();
mounts.dedup();
if !mounts.is_empty() {
let mut body = String::from(
"// GENERATED by `ggen sync` (`[templates] aggregate_modules = true`).\n\
// One mount per generated `src/*.rs` output this run produced. Do not\n\
// edit by hand — regenerated every sync. Consumer wiring: exactly one\n\
// permanent line in your own lib.rs:\n\
// include!(\"ggen_pack_mods.rs\");\n\n",
);
for (mod_name, rel_to_src) in &mounts {
use std::fmt::Write as _;
let _ = writeln!(body, "#[path = \"{rel_to_src}\"]\npub mod {mod_name};");
}
let frontmatter = Frontmatter {
to: AGGREGATOR_REL_PATH.to_string(),
sparql: BTreeMap::new(),
for_each: None,
construct: None,
inject: false,
before: None,
after: None,
at_line: None,
skip_if: None,
unless_exists: false,
unattended_write_eligible: false,
force: true,
when: None,
skip_empty: false,
from: None,
sh_before: None,
sh_after: None,
backup: false,
shape: Vec::new(),
determinism: None,
freeze_policy: None,
freeze_slots_dir: None,
rdf: Vec::new(),
rdf_inline: Vec::new(),
prefixes: BTreeMap::new(),
base: None,
};
pending.push(PendingWrite {
to: AGGREGATOR_REL_PATH.to_string(),
body,
frontmatter,
});
}
}
if pending.len() > MAX_PENDING_OUTPUTS {
return Err(AppError::fm_tpl(
22,
format!(
"sync materialized {} outputs, over the Fortune 5 limit of {MAX_PENDING_OUTPUTS}. Remediation: partition the ontology view or aggregate rows into bounded artifacts.",
pending.len()
),
));
}
let total_rendered_bytes = pending
.iter()
.fold(0usize, |total, pw| total.saturating_add(pw.body.len()));
if total_rendered_bytes > MAX_TOTAL_RENDERED_BYTES {
return Err(AppError::fm_tpl(
22,
format!(
"sync rendered {total_rendered_bytes} body bytes, over the Fortune 5 limit of {MAX_TOTAL_RENDERED_BYTES}. Remediation: reduce selected knowledge, split the run, or aggregate more compactly."
),
));
}
{
let mut seen: BTreeMap<PathBuf, (String, usize)> = BTreeMap::new();
for pw in &pending {
let admitted = crate::write::resolve_target(root, &pw.to)?;
let entry = seen.entry(admitted).or_insert_with(|| (pw.to.clone(), 0));
entry.1 += 1;
}
if let Some((target, (declared, n))) = seen.into_iter().find(|(_, (_, n))| *n > 1) {
return Err(AppError::fm_write(
8,
format!(
"{n} rendered templates resolve to the same admitted output `{}` (one declaration was `{declared}`). Remediation: make `to:` unique after path normalization; aliases such as `x` and `./x` are the same target.",
target.display()
),
));
}
}
drop(generate_guard);
generate_span.record(
"pipeline.duration_ms",
duration_ms(generate_start.elapsed()),
);
let emit_start = Instant::now();
let emit_span = tracing::info_span!(
"pipeline.emit",
"operation.name" = "pipeline.emit",
"operation.type" = "pipeline",
"pipeline.stage" = "emit",
"pipeline.duration_ms" = tracing::field::Empty,
"pipeline.files_generated" = tracing::field::Empty,
);
let emit_guard = emit_span.enter();
let mut written: Vec<PathBuf> = Vec::new();
let mut emit_err: Option<AppError> = None;
for pw in &pending {
if let Err(e) = apply(
root,
&pw.to,
&pw.body,
&pw.frontmatter,
opts,
&mut written,
&mut skipped,
&mut decisions,
) {
emit_err = Some(e);
break;
}
}
drop(emit_guard);
emit_span.record("pipeline.duration_ms", duration_ms(emit_start.elapsed()));
emit_span.record("pipeline.files_generated", written.len() as u64);
let pack_hashes: BTreeMap<String, String> = lock_entries
.iter()
.map(|e| {
(
e.name.clone(),
e.content_hash.trim_start_matches("blake3:").to_string(),
)
})
.collect();
let report = SyncReport {
written,
skipped,
graph_hash_hex,
decisions,
packs: pack_hashes,
closure,
};
if !opts.dry_run {
if let Err(receipt_err) = write_receipt(root, &report, graph.as_ref(), opts.receipt_origin)
{
return Err(match emit_err {
Some(e) => AppError::fm_write(
12,
format!(
"write stage failed ({e}); additionally could not persist a \
receipt reflecting the outputs written before that failure \
({receipt_err}). Remediation: inspect on-disk outputs manually \
(they are not yet bound by any receipt) before re-running sync."
),
),
None => receipt_err,
});
}
let portable_standing = if emit_err.is_none() {
crate::portable_receipt::PortableStanding::Alive
} else {
crate::portable_receipt::PortableStanding::PartialAlive
};
if let Err(env_err) = crate::portable_receipt::write_portable_envelope(
root,
&packs,
&report.graph_hash_hex,
&gates_attempted,
&[],
&portable_standing,
&report.decisions,
) {
return Err(match emit_err {
Some(e) => AppError::fm_write(
12,
format!(
"write stage failed ({e}); additionally could not persist \
the portable receipt envelope ({env_err}). Remediation: \
inspect on-disk outputs manually before re-running sync."
),
),
None => env_err,
});
}
if emit_err.is_none() && !lock_entries.is_empty() {
crate::pack::write_lock(root, &lock_entries)?;
}
}
if let Some(e) = emit_err {
return Err(e);
}
Ok(report)
}
pub(crate) struct GateFile {
pub message: Option<String>,
pub query: String,
}
impl GateFile {
pub(crate) fn message_prefix(&self) -> String {
self.message
.as_deref()
.map(|m| format!("{m}: "))
.unwrap_or_default()
}
}
pub(crate) fn parse_gate_source(src: &str) -> GateFile {
let mut message_lines: Vec<String> = Vec::new();
let mut query_lines: Vec<&str> = Vec::new();
let mut in_header = true;
for line in src.lines() {
if in_header {
if let Some(rest) = line.trim_start().strip_prefix("# MESSAGE:") {
message_lines.push(rest.trim().to_string());
continue;
}
in_header = false;
}
query_lines.push(line);
}
GateFile {
message: if message_lines.is_empty() {
None
} else {
Some(message_lines.join(" "))
},
query: query_lines.join("\n"),
}
}
pub(crate) enum GateOutcome {
Pass,
Violation(String),
NotAGate,
}
pub(crate) fn evaluate_gate(graph: &dyn GraphEngine, query: &str) -> Result<GateOutcome> {
match graph.query(query)? {
EngineQueryResults::Boolean(false) => Ok(GateOutcome::Pass),
EngineQueryResults::Boolean(true) => {
Ok(GateOutcome::Violation("ASK returned true".to_string()))
}
EngineQueryResults::Solutions(rows) => match rows.first() {
None => Ok(GateOutcome::Pass),
Some(first) => {
let bindings: Vec<String> = first
.iter()
.map(|(var, value)| format!("?{var} = {}", engine_value_display(value)))
.collect();
Ok(GateOutcome::Violation(format!(
"SELECT returned {} row(s); first row: {{ {} }}",
rows.len(),
bindings.join(", ")
)))
}
},
EngineQueryResults::Graph(_) => Ok(GateOutcome::NotAGate),
}
}
fn engine_value_display(value: &crate::graph::EngineValue) -> String {
match value {
crate::graph::EngineValue::Bool(b) => b.to_string(),
crate::graph::EngineValue::Int(n) => n.to_string(),
crate::graph::EngineValue::Float(f) => f.to_string(),
crate::graph::EngineValue::String(s) => s.clone(),
}
}
fn fixture_only_subjects(
graph: &dyn crate::graph::GraphEngine,
) -> Result<std::collections::BTreeSet<String>> {
const QUERY: &str =
"SELECT ?s WHERE { ?s ?p true . FILTER(CONTAINS(STR(?p), \"fixtureOnly\")) }";
match graph.query(QUERY)? {
EngineQueryResults::Solutions(rows) => Ok(rows
.into_iter()
.filter_map(|row| match row.get("s") {
Some(EngineValue::String(iri)) => Some(iri.clone()),
_ => None,
})
.collect()),
EngineQueryResults::Graph(_) | EngineQueryResults::Boolean(_) => {
Ok(std::collections::BTreeSet::default())
}
}
}
fn fixture_reference_in(
value: &serde_json::Value, subjects: &std::collections::BTreeSet<String>,
) -> Option<String> {
use serde_json::Value as J;
match value {
J::String(s) => subjects
.iter()
.find(|iri| s.contains(iri.as_str()))
.cloned(),
J::Array(items) => items.iter().find_map(|i| fixture_reference_in(i, subjects)),
J::Object(map) => map.values().find_map(|v| fixture_reference_in(v, subjects)),
J::Null | J::Bool(_) | J::Number(_) => None,
}
}
fn list_gate_files(gates_dir: &Path, pack_name: &str) -> Result<Vec<PathBuf>> {
if !gates_dir.is_dir() {
return Ok(Vec::new());
}
let entries = std::fs::read_dir(gates_dir).map_err(|e| {
AppError::fm_pack(
12,
format!(
"pack `{pack_name}`: gates directory `{}` exists but is unreadable: {e}. \
Remediation: fix the directory's permissions or remove it.",
gates_dir.display()
),
)
})?;
let mut paths: Vec<PathBuf> = Vec::new();
for entry in entries {
let path = entry
.map_err(|e| {
AppError::fm_pack(
12,
format!(
"pack `{pack_name}`: gates directory `{}` entry unreadable: {e}.",
gates_dir.display()
),
)
})?
.path();
if path.is_file() && path.extension().is_some_and(|e| e == "rq") {
paths.push(path);
}
}
paths.sort();
Ok(paths)
}
fn resolve_gate_entries(root: &Path, gates: &[PathBuf]) -> Result<Vec<(String, PathBuf)>> {
let mut resolved: Vec<(String, PathBuf)> = Vec::new();
for rel in gates {
let pattern = rel.to_string_lossy();
if !pattern.contains('*') && !pattern.contains('?') {
let path = root.join(rel);
resolved.push((rel_display(root, &path), path));
continue;
}
let matches = expand_gate_glob(root, &pattern)?;
if matches.is_empty() {
return Err(AppError::fm_config(
14,
format!(
"GLOB_NO_MATCHES: `{pattern}` matched no files under {}. \
Remediation: fix the [law].gates pattern, or create the gate \
files it names.",
root.display()
),
));
}
for matched in matches {
let path = root.join(&matched);
resolved.push((rel_display(root, &path), path));
}
}
Ok(resolved)
}
fn expand_gate_glob(root: &Path, pattern: &str) -> Result<Vec<String>> {
fn walk(dir: &Path, rel: &str, out: &mut Vec<String>) -> std::io::Result<()> {
let mut entries = std::fs::read_dir(dir)?;
let mut names: Vec<(String, std::path::PathBuf)> = Vec::new();
for entry in &mut entries {
let entry = entry?;
names.push((
entry.file_name().to_string_lossy().into_owned(),
entry.path(),
));
}
names.sort();
for (name, path) in names {
let child_rel = if rel.is_empty() {
name
} else {
format!("{rel}/{name}")
};
if path.is_dir() {
walk(&path, &child_rel, out)?;
} else {
out.push(child_rel);
}
}
Ok(())
}
fn match_component(pat: &[char], text: &[char]) -> bool {
match (pat.first(), text.first()) {
(None, None) => true,
(Some('*'), _) => {
(0..=text.len()).any(|skip| match_component(&pat[1..], &text[skip..]))
}
(Some('?'), Some(_)) => match_component(&pat[1..], &text[1..]),
(Some(p), Some(t)) if p == t => match_component(&pat[1..], &text[1..]),
_ => false,
}
}
fn match_components(pat: &[&str], text: &[&str]) -> bool {
match (pat.first(), text.first()) {
(None, None) => true,
(None, Some(_)) | (Some(_), None) => false,
(Some(p), Some(t)) => {
let pc: Vec<char> = p.chars().collect();
let tc: Vec<char> = t.chars().collect();
match_component(&pc, &tc) && match_components(&pat[1..], &text[1..])
}
}
}
let pat_parts: Vec<&str> = pattern
.split('/')
.filter(|p| !p.is_empty() && *p != ".")
.collect();
let mut files: Vec<String> = Vec::new();
walk(root, "", &mut files).map_err(|e| {
AppError::fm_law(
12,
format!(
"[law].gates glob `{pattern}` could not enumerate files under {}: {e}. \
Remediation: fix the directory's permissions or the pattern.",
root.display()
),
)
})?;
files.retain(|f| match_components(&pat_parts, f.split('/').collect::<Vec<_>>().as_slice()));
files.sort();
Ok(files)
}
fn refuse_with_portable_envelope(
root: &Path, packs: &[crate::pack::Pack], graph: &dyn GraphEngine, gates_attempted: &[String],
refusal_code: &str, dry_run: bool, original: AppError,
) -> AppError {
if dry_run {
return original;
}
let graph_hash = graph
.state_hash()
.map_or_else(|_| "UNKNOWN".to_string(), |h| hex32(&h));
let standing = crate::portable_receipt::PortableStanding::Refused(refusal_code.to_string());
match crate::portable_receipt::write_portable_envelope(
root,
packs,
&graph_hash,
gates_attempted,
&[format!("REFUSED:{refusal_code}")],
&standing,
&BTreeMap::new(),
) {
Ok(()) => original,
Err(env_err) => AppError::Validation(format!(
"{original}; additionally, the portable refusal envelope could not be \
written ({env_err})"
)),
}
}
pub(crate) fn rel_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
}
pub(crate) fn hash_file_or_missing(path: &Path) -> String {
match std::fs::read(path) {
Ok(bytes) => blake3::hash(&bytes).to_hex().to_string(),
Err(_) => "MISSING".to_string(),
}
}
type ExtractedRows = Option<(BTreeMap<String, Value>, Vec<Value>)>;
fn extract_query_results(
active_graph: &dyn GraphEngine, tpl_path: &Path, frontmatter: &Frontmatter, phase: &'static str,
) -> Result<ExtractedRows> {
if let Some(ask) = frontmatter.when.as_deref() {
tracing::debug!(
target: "ggen_engine::sync::query_extraction",
phase,
tpl = %tpl_path.display(),
"executing `when:` ASK guard"
);
match active_graph.query(ask)? {
EngineQueryResults::Boolean(true) => {}
EngineQueryResults::Boolean(false) => return Ok(None),
_ => {
return Err(AppError::fm_tpl(
16,
format!(
"`when:` in {} must be an ASK query. \
Remediation: use ASK {{ … }}.",
tpl_path.display()
),
));
}
}
}
let mut named: BTreeMap<String, Value> = BTreeMap::new();
for (key, query) in &frontmatter.sparql {
tracing::debug!(
target: "ggen_engine::sync::query_extraction",
phase,
tpl = %tpl_path.display(),
sparql_key = %key,
"executing named sparql query"
);
let value = sparql_to_value(active_graph, query)?;
if let Some(rows) = value.as_array() {
if rows.len() > MAX_QUERY_RESULT_ROWS {
return Err(AppError::fm_tpl(
21,
format!(
"{}: named `sparql:` result `{key}` produced {} row(s), over the Fortune 5 limit of {MAX_QUERY_RESULT_ROWS}. Remediation: constrain the query with a selective WHERE clause, grouping, or an explicit LIMIT.",
tpl_path.display(),
rows.len()
),
));
}
}
named.insert(key.clone(), value);
}
let results: Vec<Value> = if let Some(driver) = frontmatter.for_each.as_deref() {
let value = named.get(driver).ok_or_else(|| {
AppError::fm_tpl(
19,
format!(
"{}: `for_each: {driver}` does not name a declared `sparql:` result. \
Remediation: add that named query or correct `for_each`.",
tpl_path.display()
),
)
})?;
value.as_array().cloned().ok_or_else(|| {
AppError::fm_tpl(
19,
format!(
"{}: `for_each: {driver}` must name an array-valued SELECT/graph result, \
but that query returned {}. Remediation: point `for_each` at a SELECT \
result that yields the intended projection rows.",
tpl_path.display(),
value_type_name(value)
),
)
})?
} else {
named
.values()
.find_map(|v| v.as_array().cloned())
.unwrap_or_default()
};
Ok(Some((named, results)))
}
fn base_context(named: &BTreeMap<String, Value>, results: &[Value]) -> tera::Context {
let mut ctx = tera::Context::new();
ctx.insert("results", results);
for (k, v) in named {
ctx.insert(k, v);
}
ctx
}
fn row_context(named: &BTreeMap<String, Value>, results: &[Value], row: &Value) -> tera::Context {
let mut ctx = base_context(named, results);
ctx.insert("row", row);
if let Value::Object(map) = row {
for (k, v) in map {
ctx.insert(k, v);
}
}
ctx
}
fn render_str(
tera: &mut tera::Tera, template: &str, ctx: &tera::Context, tpl_path: &Path,
) -> Result<String> {
tera.render_str(template, ctx).map_err(|e| {
AppError::fm_tpl(
17,
format!(
"render failed for {}: {}. Available top-level context keys: {}.",
tpl_path.display(),
crate::template::tera_error_full_chain(&e),
context_key_summary(ctx)
),
)
})
}
fn render_optional_output_field(
tera: &mut tera::Tera, value: Option<&str>, ctx: &tera::Context, tpl_path: &Path,
) -> Result<Option<String>> {
value
.map(|template| render_str(tera, template, ctx, tpl_path))
.transpose()
}
fn render_optional_match_field(
tera: &mut tera::Tera, value: Option<&MatchSpec>, ctx: &tera::Context, tpl_path: &Path,
) -> Result<Option<MatchSpec>> {
value
.map(|spec| {
render_str(tera, spec.pattern(), ctx, tpl_path)
.map(|pattern| spec.with_pattern(pattern))
})
.transpose()
}
fn render_output_frontmatter(
tera: &mut tera::Tera, source: &Frontmatter, ctx: &tera::Context, tpl_path: &Path,
rendered_to: &str,
) -> Result<Frontmatter> {
let mut rendered = source.clone();
rendered.to = rendered_to.to_string();
rendered.before = render_optional_match_field(tera, source.before.as_ref(), ctx, tpl_path)?;
rendered.after = render_optional_match_field(tera, source.after.as_ref(), ctx, tpl_path)?;
rendered.skip_if = render_optional_match_field(tera, source.skip_if.as_ref(), ctx, tpl_path)?;
rendered.sh_before =
render_optional_output_field(tera, source.sh_before.as_deref(), ctx, tpl_path)?;
rendered.sh_after =
render_optional_output_field(tera, source.sh_after.as_deref(), ctx, tpl_path)?;
rendered.freeze_slots_dir =
render_optional_output_field(tera, source.freeze_slots_dir.as_deref(), ctx, tpl_path)?;
rendered.shape = source
.shape
.iter()
.map(|shape| render_str(tera, shape, ctx, tpl_path))
.collect::<Result<Vec<_>>>()?;
Ok(rendered)
}
fn render_aggregate_projection(
tera: &mut tera::Tera, body_template: &str, tpl_path: &Path, frontmatter: &Frontmatter,
named: &BTreeMap<String, Value>, results: &[Value],
) -> Result<Option<(String, Frontmatter)>> {
if results.is_empty() {
return Ok(None);
}
let mut body = String::new();
let mut canonical_frontmatter: Option<Frontmatter> = None;
for (row_index, row) in results.iter().enumerate() {
let ctx = row_context(named, results, row);
body.push_str(&render_str(tera, body_template, &ctx, tpl_path)?);
let candidate =
render_output_frontmatter(tera, frontmatter, &ctx, tpl_path, &frontmatter.to)?;
if let Some(expected) = &canonical_frontmatter {
if expected != &candidate {
return Err(AppError::fm_tpl(
20,
format!(
"{}: static `to:` with `for_each` produced row-varying output-phase \
frontmatter at row {row_index}. One target must have one composition, \
hook, shape, and ownership law. Remediation: make those properties \
invariant across rows or include a row binding in `to:` to fan out.",
tpl_path.display()
),
));
}
} else {
canonical_frontmatter = Some(candidate);
}
}
Ok(canonical_frontmatter.map(|frontmatter| (body, frontmatter)))
}
fn context_key_summary(ctx: &tera::Context) -> String {
let Value::Object(map) = ctx.clone().into_json() else {
return "(none)".to_string();
};
if map.is_empty() {
return "(none)".to_string();
}
map.iter()
.map(|(k, v)| match v {
Value::Array(items) => format!("{k} (array, {} items)", items.len()),
Value::Object(fields) => format!("{k} (object, {} fields)", fields.len()),
other => format!("{k} ({})", value_type_name(other)),
})
.collect::<Vec<_>>()
.join(", ")
}
fn value_type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[allow(clippy::too_many_arguments)]
fn apply(
root: &Path, rel_to: &str, body: &str, frontmatter: &Frontmatter, opts: SyncOptions,
written: &mut Vec<PathBuf>, skipped: &mut Vec<(PathBuf, String)>,
decisions: &mut BTreeMap<String, String>,
) -> Result<()> {
let target = crate::write::resolve_target(root, rel_to)?;
if frontmatter.skip_empty && body.trim().is_empty() {
let reason = "skip_empty: rendered body empty".to_string();
decisions.insert(rel_to.to_string(), format!("skipped: {reason}"));
skipped.push((PathBuf::from(rel_to), reason));
return Ok(());
}
validate_match_specs(frontmatter)?;
preflight_checksum_slot(root, rel_to, frontmatter)?;
let mut match_evidence = preflight_structured_matchers(root, rel_to, frontmatter)?;
if opts.dry_run {
match std::fs::read_to_string(&target) {
Ok(existing) if existing == body => {
let reason = "unchanged: content identical".to_string();
decisions.insert(rel_to.to_string(), format!("skipped: {reason}"));
skipped.push((PathBuf::from(rel_to), reason));
}
Ok(_) => {
let suffix = match_evidence_suffix(&match_evidence);
decisions.insert(
rel_to.to_string(),
format!("planned: write (dry-run){suffix}"),
);
written.push(PathBuf::from(rel_to));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let suffix = match_evidence_suffix(&match_evidence);
decisions.insert(
rel_to.to_string(),
format!("planned: write (dry-run){suffix}"),
);
written.push(PathBuf::from(rel_to));
}
Err(e) => {
return Err(AppError::fm_write(
9,
format!(
"dry-run: target `{}` exists but is unreadable as UTF-8: {e}. \
A non-dry-run sync would refuse here too. \
Remediation: remove or fix the target file.",
target.display()
),
));
}
}
return Ok(());
}
if let Some(cmd) = frontmatter.sh_before.as_deref() {
run_shell_hook(root, cmd, "sh_before")?;
match_evidence = preflight_structured_matchers(root, rel_to, frontmatter)?;
}
let evidence_suffix = match_evidence_suffix(&match_evidence);
match plan_write(root, rel_to, body, frontmatter)? {
WriteOutcome::Written => {
decisions.insert(rel_to.to_string(), format!("written{evidence_suffix}"));
written.push(PathBuf::from(rel_to));
if let Some(cmd) = frontmatter.sh_after.as_deref() {
run_shell_hook(root, cmd, "sh_after")?;
}
}
WriteOutcome::Injected => {
decisions.insert(rel_to.to_string(), format!("injected{evidence_suffix}"));
written.push(PathBuf::from(rel_to));
if let Some(cmd) = frontmatter.sh_after.as_deref() {
run_shell_hook(root, cmd, "sh_after")?;
}
}
WriteOutcome::Skipped(reason) => {
decisions.insert(
rel_to.to_string(),
format!("skipped: {reason}{evidence_suffix}"),
);
skipped.push((PathBuf::from(rel_to), reason));
}
}
Ok(())
}
fn match_evidence_suffix(evidence: &[String]) -> String {
if evidence.is_empty() {
String::new()
} else {
format!("; match={}", evidence.join(" | "))
}
}
fn run_shell_hook(root: &Path, cmd: &str, which: &str) -> Result<()> {
crate::shell_safety::check_shell_command_safe(cmd)?;
let status = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.current_dir(root)
.status()
.map_err(|e| AppError::fm_shell(2, format!("{which} failed to launch: {e}")))?;
if !status.success() {
return Err(AppError::fm_shell(
3,
format!("{which} command `{cmd}` exited with {status}. Remediation: fix the command or remove it from frontmatter."),
));
}
Ok(())
}
pub fn discover_templates(
root: &Path, config: &GgenConfig, packs: &[crate::pack::Pack],
) -> Result<Vec<(PathBuf, Template)>> {
let mut templates = load_templates(&root.join(&config.templates.dir))?;
for pack in packs {
for path in &pack.template_paths {
let content = std::fs::read_to_string(path)?;
if !content
.strip_prefix('\u{feff}')
.unwrap_or(&content)
.starts_with("---")
{
continue;
}
templates.push((path.clone(), parse_template_file(path)?));
}
}
Ok(templates)
}
fn load_templates(dir: &Path) -> Result<Vec<(PathBuf, Template)>> {
let mut paths: Vec<PathBuf> = Vec::new();
collect_tmpl_paths(dir, &mut paths)?;
paths.sort();
let mut out = Vec::with_capacity(paths.len());
for path in paths {
let content = std::fs::read_to_string(&path)?;
if !content
.strip_prefix('\u{feff}')
.unwrap_or(&content)
.starts_with("---")
{
continue;
}
let tpl = parse_template_file(&path)?;
out.push((path, tpl));
}
Ok(out)
}
fn parse_template_file(path: &Path) -> Result<Template> {
let content = std::fs::read_to_string(path)?;
let mut tpl = Template::parse(&content)
.map_err(|e| AppError::fm_tpl(6, format!("{}: {e}", path.display())))?;
if let Some(from) = tpl.frontmatter.from.clone() {
let base = path.parent().unwrap_or_else(|| Path::new("."));
let from_path = crate::write::resolve_target(base, &from).map_err(|e| {
AppError::fm_tpl(
8,
format!(
"{}: `from: {from}` is not a safe path (must stay inside the \
template's own directory): {e}",
path.display()
),
)
})?;
tpl.body = std::fs::read_to_string(&from_path).map_err(|e| {
AppError::fm_tpl(
7,
format!(
"{}: `from: {from}` unreadable at `{}`: {e}. \
Remediation: fix the `from:` path (relative to the template's own directory).",
path.display(),
from_path.display()
),
)
})?;
}
Ok(tpl)
}
fn admit_shape_files(
root: &Path, tpl_path: &Path, shapes: &[String], active_graph: &dyn GraphEngine,
closure: &mut BTreeMap<String, String>,
) -> Result<()> {
if shapes.is_empty() {
return Ok(());
}
let mut combined_shapes_ttl = String::new();
for shape in shapes {
let shape_path = crate::write::resolve_target(root, shape).map_err(|error| {
AppError::fm_tpl(
14,
format!(
"{}: `shape:` entry `{shape}` is not an admitted project path: {error}. Remediation: use a safe relative path inside the project.",
tpl_path.display()
),
)
})?;
let metadata = std::fs::metadata(&shape_path).map_err(|error| {
AppError::fm_tpl(
14,
format!(
"{}: `shape:` entry `{shape}` is unreadable at `{}`: {error}. Remediation: provide a readable regular file.",
tpl_path.display(),
shape_path.display()
),
)
})?;
if !metadata.is_file() {
return Err(AppError::fm_tpl(
14,
format!(
"{}: `shape:` entry `{shape}` resolves to `{}`, which is not a regular file. Remediation: point to a readable shape document.",
tpl_path.display(),
shape_path.display()
),
));
}
let bytes = std::fs::read(&shape_path).map_err(|error| {
AppError::fm_tpl(
14,
format!(
"{}: `shape:` entry `{shape}` could not be read at `{}`: {error}",
tpl_path.display(),
shape_path.display()
),
)
})?;
let closure_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
closure.insert(
rel_display(&closure_root, &shape_path),
blake3::hash(&bytes).to_hex().to_string(),
);
let text = String::from_utf8(bytes).map_err(|error| {
AppError::fm_tpl(
24,
format!(
"{}: `shape:` entry `{shape}` at `{}` is not valid UTF-8 Turtle: {error}",
tpl_path.display(),
shape_path.display()
),
)
})?;
combined_shapes_ttl.push_str(&text);
combined_shapes_ttl.push('\n');
}
let outcome = active_graph
.validate_shacl(&combined_shapes_ttl)
.map_err(|error| {
AppError::fm_tpl(
26,
format!(
"{}: `shape:` validation could not run: {error}. Remediation: use the \
default GraphLaw engine (no `--engine oxigraph`), which is the only \
engine with SHACL support.",
tpl_path.display()
),
)
})?;
if !outcome.conforms {
return Err(AppError::fm_tpl(
25,
format!(
"{}: `shape:` validation failed, {} violation(s): {}",
tpl_path.display(),
outcome.violations.len(),
outcome.violations.join("; ")
),
));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn check_determinism(
tera: &mut tera::Tera, body_template: &str, tpl_path: &Path,
frontmatter: &crate::template::Frontmatter, determinism_recheck: Option<&ExtractedRows>,
mode: ProjectionMode, first_render: &str, first_frontmatter: &Frontmatter,
) -> Result<()> {
let Some(recheck) = determinism_recheck else {
return Ok(());
};
let Some((named2, results2)) = recheck else {
return Err(AppError::fm_tpl(
9,
format!(
"{}: `determinism: true` violated — re-running the `when:` guard a second, \
independent time evaluated false after the first run evaluated true. \
Remediation: remove non-deterministic terms from `when:`.",
tpl_path.display()
),
));
};
let (second_render, second_frontmatter) = match mode {
ProjectionMode::Whole => {
let ctx = base_context(named2, results2);
let second_to = render_str(tera, &frontmatter.to, &ctx, tpl_path)?;
let second_frontmatter =
render_output_frontmatter(tera, frontmatter, &ctx, tpl_path, &second_to)?;
let second_render = render_str(tera, body_template, &ctx, tpl_path)?;
(second_render, second_frontmatter)
}
ProjectionMode::Row(idx) => {
let row2 = results2.get(idx).ok_or_else(|| {
AppError::fm_tpl(
9,
format!(
"{}: `determinism: true` violated — the second independent query \
execution produced {} row(s), which does not cover row index {idx}. \
Remediation: add a complete ORDER BY with a stable tie-break.",
tpl_path.display(),
results2.len()
),
)
})?;
let ctx = row_context(named2, results2, row2);
let second_to = render_str(tera, &frontmatter.to, &ctx, tpl_path)?;
let second_frontmatter =
render_output_frontmatter(tera, frontmatter, &ctx, tpl_path, &second_to)?;
let second_render = render_str(tera, body_template, &ctx, tpl_path)?;
(second_render, second_frontmatter)
}
ProjectionMode::Aggregate => render_aggregate_projection(
tera,
body_template,
tpl_path,
frontmatter,
named2,
results2,
)?
.ok_or_else(|| {
AppError::fm_tpl(
9,
format!(
"{}: `determinism: true` violated — the second independent `for_each` \
execution produced zero rows after the first produced an aggregate output.",
tpl_path.display()
),
)
})?,
};
if &second_frontmatter != first_frontmatter {
return Err(AppError::fm_tpl(
9,
format!(
"{}: `determinism: true` violated — the second independent execution \
produced different path, cardinality, composition, hook, shape, or \
ownership semantics.",
tpl_path.display()
),
));
}
if second_render != first_render {
return Err(AppError::fm_tpl(
9,
format!(
"{}: `determinism: true` violated — the second independent execution \
produced different output bytes.",
tpl_path.display()
),
));
}
Ok(())
}
fn check_determinism_guard_agrees_false(
tpl_path: &Path, determinism_recheck: Option<&ExtractedRows>,
) -> Result<()> {
let Some(recheck) = determinism_recheck else {
return Ok(());
};
if recheck.is_some() {
return Err(AppError::fm_tpl(
9,
format!(
"{}: `determinism: true` violated — the independent second load's \
`when:` guard evaluated true while the first evaluated false. \
Remediation: remove non-deterministic terms from `when:`.",
tpl_path.display()
),
));
}
Ok(())
}
fn check_determinism_rows_agree_empty(
tpl_path: &Path, determinism_recheck: Option<&ExtractedRows>, driver_desc: &str,
) -> Result<()> {
let Some(recheck) = determinism_recheck else {
return Ok(());
};
let Some((_, results2)) = recheck else {
return Err(AppError::fm_tpl(
9,
format!(
"{}: `determinism: true` violated — re-running the `when:` guard a second, \
independent time evaluated false after the first run evaluated true. \
Remediation: remove non-deterministic terms from `when:`.",
tpl_path.display()
),
));
};
if !results2.is_empty() {
return Err(AppError::fm_tpl(
9,
format!(
"{}: `determinism: true` violated — the independent second load's \
{driver_desc} produced {} row(s) after the first produced zero. \
Remediation: add a complete ORDER BY with a stable tie-break, or \
remove non-deterministic terms from the query.",
tpl_path.display(),
results2.len()
),
));
}
Ok(())
}
fn collect_tmpl_paths(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries = std::fs::read_dir(dir).map_err(|e| {
AppError::fm_config(
4,
format!(
"templates dir `{}` unreadable: {e}. Remediation: fix [templates].dir.",
dir.display()
),
)
})?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_tmpl_paths(&path, out)?;
} else if path.extension().is_some_and(|e| e == "tmpl") {
out.push(path);
}
}
Ok(())
}
fn build_independent_recheck_base_graph(
root: &Path, config: &GgenConfig, opts: SyncOptions, packs: &[crate::pack::Pack],
templates: &[(PathBuf, Template)],
) -> Result<Arc<dyn GraphEngine>> {
let fresh: Arc<dyn GraphEngine> = new_graph_engine(opts.engine)?;
let ontology_path = root.join(&config.ontology.source);
let (ontology_label, ttl) = read_ontology_file(root, &ontology_path)?;
let mut ontology_sources = Vec::with_capacity(
1 + packs
.iter()
.map(|pack| 1 + pack.extra_ontology_paths.len())
.sum::<usize>(),
);
ontology_sources.push((ontology_label, ttl));
for import in &config.ontology.imports {
ontology_sources.push(read_ontology_file(root, &root.join(import))?);
}
for pack in packs {
let pack_ttl = std::fs::read_to_string(&pack.ontology_path).map_err(|e| {
AppError::fm_pack(
4,
format!(
"pack `{}`: ontology `{}` unreadable: {e}",
pack.name,
pack.ontology_path.display()
),
)
})?;
ontology_sources.push((rel_display(root, &pack.ontology_path), pack_ttl));
for (declared, extra_path) in &pack.extra_ontology_paths {
let extra_ttl = std::fs::read_to_string(extra_path).map_err(|e| {
AppError::fm_pack(
4,
format!(
"pack `{}`: extra ontology `{declared}` unreadable at `{}`: {e}",
pack.name,
extra_path.display()
),
)
})?;
ontology_sources.push((declared.clone(), extra_ttl));
}
}
let ontology_documents: Vec<TurtleDocument<'_>> = ontology_sources
.iter()
.map(|(label, content)| TurtleDocument::new(label, content))
.collect();
let recheck_ontology_receipt = fresh.insert_turtle_documents(&ontology_documents)?;
tracing::debug!(
ontology.documents = recheck_ontology_receipt.documents,
ontology.parsed_quads = recheck_ontology_receipt.parsed_quads,
ontology.inserted_quads = recheck_ontology_receipt.inserted_quads,
"determinism-recheck ontology batch admitted (independent second load)"
);
for (_, tpl) in templates {
if declares_rdf_overlay(&tpl.frontmatter) {
continue;
}
if let Some(construct) = tpl.frontmatter.construct.as_deref() {
insert_construct(fresh.as_ref(), construct)?;
}
}
for rel in &config.law.rules {
let rule_path = root.join(rel);
let src = std::fs::read_to_string(&rule_path).map_err(|e| {
AppError::fm_law(
10,
format!(
"rule file `{}` unreadable: {e}. Remediation: fix [law].rules.",
rule_path.display()
),
)
})?;
fresh.load_rules(&src)?;
}
let mut any_hook_loaded = false;
for pack in packs {
let hook_path = pack.root.join("hook.ttl");
if !hook_path.is_file() {
continue;
}
let hook_ttl = std::fs::read_to_string(&hook_path).map_err(|e| {
AppError::fm_pack(
14,
format!(
"pack `{}`: hook.ttl at `{}` exists but is unreadable: {e}. \
Remediation: fix the file's permissions/encoding or remove it.",
pack.name,
hook_path.display()
),
)
})?;
fresh.load_hook_pack(&hook_ttl)?;
any_hook_loaded = true;
}
if !config.law.rules.is_empty() || any_hook_loaded {
fresh.materialize()?;
}
Ok(fresh)
}
fn declares_rdf_overlay(fm: &Frontmatter) -> bool {
!fm.rdf.is_empty() || !fm.rdf_inline.is_empty()
}
fn build_turtle_prolog(prefixes: &BTreeMap<String, String>, base: Option<&str>) -> String {
let mut out = String::new();
if let Some(b) = base {
out.push_str("@base <");
out.push_str(b);
out.push_str("> .\n");
}
for (name, iri) in prefixes {
out.push_str("@prefix ");
out.push_str(name);
out.push_str(": <");
out.push_str(iri);
out.push_str("> .\n");
}
out
}
fn build_rdf_overlay(
base: &Arc<dyn GraphEngine>, tpl_path: &Path, fm: &Frontmatter,
) -> Result<Option<Arc<dyn GraphEngine>>> {
use std::fmt::Write as _;
if !declares_rdf_overlay(fm) {
return Ok(None);
}
let overlay = DeterministicGraph::new()?;
let base_ntriples: String =
base.canonical_quads()?
.into_iter()
.fold(String::new(), |mut doc, line| {
let _ = writeln!(doc, "{line} .");
doc
});
if !base_ntriples.is_empty() {
overlay.insert_turtle(&base_ntriples)?;
}
let prolog = build_turtle_prolog(&fm.prefixes, fm.base.as_deref());
let template_dir = tpl_path.parent().unwrap_or_else(|| Path::new("."));
for rdf_file in &fm.rdf {
let rdf_path = crate::write::resolve_target(template_dir, rdf_file).map_err(|e| {
AppError::fm_tpl(
18,
format!(
"{}: `rdf: {rdf_file}` is not a safe path (must stay inside the \
template's own directory): {e}",
tpl_path.display()
),
)
})?;
let content = std::fs::read_to_string(&rdf_path).map_err(|e| {
AppError::fm_tpl(
11,
format!(
"{}: `rdf: {rdf_file}` unreadable at `{}`: {e}. \
Remediation: fix the path (relative to the template's own directory).",
tpl_path.display(),
rdf_path.display()
),
)
})?;
overlay.insert_turtle(&format!("{prolog}{content}"))?;
}
for inline in &fm.rdf_inline {
overlay.insert_turtle(&format!("{prolog}{inline}"))?;
}
Ok(Some(Arc::new(overlay)))
}
fn insert_construct(graph: &dyn GraphEngine, construct: &str) -> Result<()> {
use std::fmt::Write as _;
let EngineQueryResults::Graph(triples) = graph.query(construct)? else {
return Err(AppError::fm_graph(
7,
"`construct:` frontmatter must be a CONSTRUCT/DESCRIBE query. \
Remediation: use CONSTRUCT { … } WHERE { … }.",
));
};
let mut doc = String::new();
for triple in triples {
let _ = writeln!(doc, "{} .", triple.ntriples);
}
if !doc.is_empty() {
graph.insert_turtle(&doc)?;
}
Ok(())
}
fn read_prev_head(receipt_path: &Path, log_path: &Path) -> Result<Option<SyncReceipt>> {
match std::fs::read_to_string(log_path) {
Ok(raw) => {
if let Some(line) = raw.lines().rev().find(|l| !l.trim().is_empty()) {
let prev: SyncReceipt = serde_json::from_str(line).map_err(|e| {
AppError::fm_chain(
3,
format!(
"receipt log `{}` tail malformed: {e}. \
Remediation: run `ggen receipt history` and repair the log.",
log_path.display()
),
)
})?;
return Ok(Some(prev));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(AppError::fm_chain(
3,
format!("receipt log `{}` unreadable: {e}", log_path.display()),
))
}
}
match std::fs::read_to_string(receipt_path) {
Ok(raw) => {
let prev: SyncReceipt = serde_json::from_str(&raw).map_err(|e| {
AppError::fm_chain(
3,
format!(
"previous receipt `{}` malformed: {e}. \
Remediation: verify or remove the stale receipt.",
receipt_path.display()
),
)
})?;
Ok(Some(prev))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(AppError::fm_chain(
3,
format!(
"previous receipt `{}` unreadable: {e}",
receipt_path.display()
),
)),
}
}
pub(crate) fn first_declared_chain_rule_in_log(
log_path: &Path, code: u16,
) -> Result<Option<usize>> {
let raw = match std::fs::read_to_string(log_path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(AppError::fm_chain(
code,
format!("receipt log `{}` unreadable: {e}", log_path.display()),
))
}
};
for (idx, line) in raw.lines().filter(|l| !l.trim().is_empty()).enumerate() {
let receipt: SyncReceipt = serde_json::from_str(line).map_err(|e| {
AppError::fm_chain(
code,
format!(
"receipt log `{}` entry {idx} malformed: {e}. \
Remediation: run `ggen receipt history` and repair the log.",
log_path.display()
),
)
})?;
if receipt.record.chain_rule.is_some() {
return Ok(Some(idx));
}
}
Ok(None)
}
fn closure_equivalence_class(key: &str) -> Option<&'static str> {
if key.starts_with("receipt-log:") {
return None;
}
match key {
"ggen.toml" | "project" | "actuator" => Some("config"),
_ => Some("source"),
}
}
fn compare_closure_class(
class: &'static str, current: &BTreeMap<String, String>, previous: &BTreeMap<String, String>,
) -> EquivalenceStatus {
let cur: BTreeMap<&String, &String> = current
.iter()
.filter(|(k, _)| closure_equivalence_class(k) == Some(class))
.collect();
let prev: BTreeMap<&String, &String> = previous
.iter()
.filter(|(k, _)| closure_equivalence_class(k) == Some(class))
.collect();
if cur == prev {
return EquivalenceStatus::Equivalent;
}
let mut diffs: Vec<String> = Vec::new();
for (k, v) in &cur {
match prev.get(*k) {
None => diffs.push(format!("added `{k}`")),
Some(pv) if pv != v => diffs.push(format!("changed `{k}`")),
Some(_) => {}
}
}
for k in prev.keys() {
if !cur.contains_key(*k) {
diffs.push(format!("removed `{k}`"));
}
}
let shown = diffs.iter().take(4).cloned().collect::<Vec<_>>().join(", ");
let suffix = if diffs.len() > 4 {
format!(" (+{} more)", diffs.len() - 4)
} else {
String::new()
};
EquivalenceStatus::Divergent(format!(
"closure {class} hashes differ from previous receipt: {shown}{suffix}"
))
}
fn verify_check_axis(name: &str) -> &'static str {
if name.contains("test") {
"test"
} else if name.contains("clippy") || name.contains("lint") {
"lint"
} else if name.contains("fmt") {
"fmt"
} else {
"gate"
}
}
fn verify_outcome_map(
items: &[AdmissionItem], test_axis: bool,
) -> BTreeMap<String, ObservedOutcome> {
items
.iter()
.filter_map(|item| {
let name = item.evidence_id.strip_prefix("ver:")?;
if (verify_check_axis(name) == "test") == test_axis {
Some((name.to_string(), item.observed_outcome.clone()))
} else {
None
}
})
.collect()
}
fn compare_verify_class(
class: &'static str, test_axis: bool, current_items: &[AdmissionItem],
prev_epoch: &ReceiptEpochV2,
) -> EquivalenceStatus {
let prev_items: &[AdmissionItem] = match &prev_epoch.admission {
AdmissionLedger::Recorded(items) => items,
AdmissionLedger::LegacyUnrecorded => &[],
};
let cur = verify_outcome_map(current_items, test_axis);
let prev = verify_outcome_map(prev_items, test_axis);
if cur.is_empty() || prev.is_empty() {
return EquivalenceStatus::Unknown;
}
if cur == prev {
return EquivalenceStatus::Equivalent;
}
let mut diffs: Vec<String> = Vec::new();
for (name, outcome) in &cur {
match prev.get(name) {
None => diffs.push(format!("added `{name}`")),
Some(prev_outcome) if prev_outcome != outcome => {
diffs.push(format!("changed `{name}`"));
}
Some(_) => {}
}
}
for name in prev.keys() {
if !cur.contains_key(name) {
diffs.push(format!("removed `{name}`"));
}
}
let shown = diffs.iter().take(4).cloned().collect::<Vec<_>>().join(", ");
let suffix = if diffs.len() > 4 {
format!(" (+{} more)", diffs.len() - 4)
} else {
String::new()
};
EquivalenceStatus::Divergent(format!(
"external ver:Check {class} outcomes differ from previous receipt: {shown}{suffix}"
))
}
struct ClassProducerFact {
exit: i64,
identity: String,
}
fn prev_class_identity(prev_epoch: &ReceiptEpochV2, class: &str) -> Option<String> {
let AdmissionLedger::Recorded(items) = &prev_epoch.admission else {
return None;
};
let prefix = format!("class:{class}:");
items
.iter()
.find_map(|i| i.evidence_id.strip_prefix(&prefix).map(str::to_string))
}
fn compare_producer_class(
class: &'static str, current: Option<&ClassProducerFact>, prev_epoch: &ReceiptEpochV2,
) -> EquivalenceStatus {
let Some(fact) = current else {
return EquivalenceStatus::Unknown;
};
if fact.exit != 0 {
return EquivalenceStatus::Divergent(format!(
"{class} producer recorded red evidence (exit {})",
fact.exit
));
}
match prev_class_identity(prev_epoch, class) {
None => EquivalenceStatus::Unknown,
Some(prev) if prev == "skipped" => EquivalenceStatus::Unknown,
Some(prev) if prev == fact.identity => EquivalenceStatus::Equivalent,
Some(prev) => EquivalenceStatus::Divergent(format!(
"{class} identity changed from previous receipt: {prev} -> {}",
fact.identity
)),
}
}
#[allow(clippy::too_many_lines)]
pub(crate) fn write_receipt(
root: &Path, report: &SyncReport, graph: &dyn GraphEngine, receipt_origin: Option<&'static str>,
) -> Result<()> {
use std::io::Write as _;
let mut outputs = BTreeMap::new();
for rel in report.decisions.keys() {
let target = root.join(rel);
match std::fs::read(&target) {
Ok(bytes) => {
outputs.insert(rel.clone(), blake3::hash(&bytes).to_hex().to_string());
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(AppError::fm_chain(
8,
format!(
"output `{}` cannot be read for receipt binding: {e}. \
Remediation: fix the file's permissions.",
target.display()
),
))
}
}
}
let payload = ReceiptPayload {
graph_hash: report.graph_hash_hex.clone(),
outputs,
packs: report.packs.clone(),
decisions: report.decisions.clone(),
closure: report.closure.clone(),
build_provenance: Some(crate::build_provenance::BUILD_PROVENANCE_HEX.to_string()),
};
let payload_bytes = serde_json::to_vec(&payload)?;
let payload_hash_hex = blake3::hash(&payload_bytes).to_hex().to_string();
let receipt_path = root.join(RECEIPT_REL_PATH);
let log_path = root.join(RECEIPT_LOG_REL_PATH);
let prev_head = read_prev_head(&receipt_path, &log_path)?;
let prev_chain_standing = match &prev_head {
Some(prev) => match prev.record.verify_chain().map_err(|e| {
AppError::fm_chain(9, format!("previous receipt chain recompute failed: {e}"))
})? {
ChainVerification::Verified(ChainStanding::LegacyV2Unbound) => {
if let Some(first) = first_declared_chain_rule_in_log(&log_path, 9)? {
return Err(AppError::fm_chain(
9,
format!(
"previous receipt head is invalid: chain-rule downgrade -- the head \
verifies only as `{}` but receipt log record {first} declared a \
chain rule. Refusing to extend a downgraded chain. Remediation: run \
`ggen receipt history` and restore the receipts.",
ChainStanding::LegacyV2Unbound.as_str()
),
));
}
Some(ChainStanding::LegacyV2Unbound)
}
ChainVerification::Verified(standing) => Some(standing),
ChainVerification::Mismatch { rule, recomputed } => {
return Err(AppError::fm_chain(
9,
format!(
"previous receipt head is invalid: stored chain hash {} does not \
match recompute {} (chain rule `{}`). Refusing to extend a tampered \
chain. Remediation: run `ggen receipt history` and restore the \
receipts.",
prev.record.chain_hash_hex,
hex32(&recomputed),
rule.as_str()
),
));
}
},
None => None,
};
let prev_chain_hash_hex = match &prev_head {
Some(prev) => prev.record.chain_hash_hex.clone(),
None => "0".repeat(64),
};
let mut admission_items: Vec<AdmissionItem> = report
.decisions
.iter()
.map(|(path, reason)| {
if reason.contains("DRIFT:") {
AdmissionItem {
evidence_id: path.clone(),
observed_outcome: ObservedOutcome::Fail,
decision: AdmissionDecision::Quarantined,
reason: reason.clone(),
obligations_discharged: Vec::new(),
obligations_created: vec![format!("FROZEN-DRIFT:{path}")],
}
} else {
AdmissionItem {
evidence_id: path.clone(),
observed_outcome: ObservedOutcome::Pass,
decision: AdmissionDecision::Admitted,
reason: reason.clone(),
obligations_discharged: Vec::new(),
obligations_created: Vec::new(),
}
}
})
.collect();
let constitution_law_query: &str = "PREFIX ccn: <http://seanchatmangpt.github.io/packs/ggen-constitution#>\nSELECT ?law ?mech WHERE { ?law a ccn:Law ; ccn:mechanized ?mech . } ORDER BY ?law";
if let EngineQueryResults::Solutions(rows) = graph.query(constitution_law_query)? {
for row in rows {
let Some(EngineValue::String(law_iri)) = row.get("law") else {
continue;
};
let law_id = law_iri
.rsplit(['#', '/'])
.next()
.unwrap_or(law_iri.as_str())
.to_string();
let mechanized = matches!(row.get("mech"), Some(EngineValue::Bool(true)));
admission_items.push(if mechanized {
AdmissionItem {
evidence_id: format!("constitution:{law_id}"),
observed_outcome: ObservedOutcome::Pass,
decision: AdmissionDecision::Admitted,
reason: "ccn:mechanized=true (static human-authored assertion in \
packs/ggen-constitution-pack/ontology.ttl, not independently \
re-verified by this pipeline)"
.to_string(),
obligations_discharged: vec![law_id],
obligations_created: Vec::new(),
}
} else {
AdmissionItem {
evidence_id: format!("constitution:{law_id}"),
observed_outcome: ObservedOutcome::Fail,
decision: AdmissionDecision::Admitted,
reason: "ccn:mechanized=false: no enforcing mechanism found for this \
constitutional principle yet -- an open obligation, not a \
refusal this sync detected"
.to_string(),
obligations_discharged: Vec::new(),
obligations_created: vec![law_id],
}
});
}
}
let verify_check_query: &str = "PREFIX ver: <http://seanchatmangpt.github.io/packs/ggen-verify#>\nSELECT ?name ?exit WHERE { ?c a ver:Check ; ver:name ?name ; ver:exitCode ?exit . } ORDER BY ?name";
let known_divergence_query: &str = "PREFIX ver: <http://seanchatmangpt.github.io/packs/ggen-verify#>\nSELECT ?name WHERE { ?kd a ver:KnownDivergence ; ver:name ?name . } ORDER BY ?name";
let mut allowlisted_divergences: BTreeSet<String> = BTreeSet::new();
if let EngineQueryResults::Solutions(rows) = graph.query(known_divergence_query)? {
for row in rows {
if let Some(EngineValue::String(name)) = row.get("name") {
allowlisted_divergences.insert(name.clone());
}
}
}
let mut ver_checks: BTreeMap<String, i64> = BTreeMap::new();
if let EngineQueryResults::Solutions(rows) = graph.query(verify_check_query)? {
for row in rows {
let Some(EngineValue::String(name)) = row.get("name") else {
continue;
};
let Some(EngineValue::Int(exit)) = row.get("exit") else {
continue;
};
ver_checks.insert(name.clone(), *exit);
}
}
for (name, exit) in &ver_checks {
let pass = *exit == 0;
let allowlisted = allowlisted_divergences.contains(name);
admission_items.push(AdmissionItem {
evidence_id: format!("ver:{name}"),
observed_outcome: if pass {
ObservedOutcome::Pass
} else {
ObservedOutcome::Fail
},
decision: AdmissionDecision::Admitted,
reason: if pass {
"ver:Check exitCode=0 (externally-run evidence admitted via the graph)".to_string()
} else if allowlisted {
format!(
"ver:Check exitCode={exit}, allowlisted as ver:KnownDivergence -- \
recorded as Fail, does not lower the component axis"
)
} else {
format!(
"ver:Check exitCode={exit} -- red external evidence; lowers the \
`{}` component axis by the meet law",
verify_check_axis(name)
)
},
obligations_discharged: Vec::new(),
obligations_created: Vec::new(),
});
}
let docs_manifest_query: &str = "PREFIX ver: <http://seanchatmangpt.github.io/packs/ggen-verify#>\nSELECT ?path ?sha WHERE { ?e a ver:DocsManifestEntry ; ver:path ?path ; ver:sha256 ?sha . } ORDER BY ?path";
let receipts_check_query: &str = "PREFIX ver: <http://seanchatmangpt.github.io/packs/ggen-verify#>\nSELECT ?exit ?head WHERE { ?c a ver:ReceiptsCheck ; ver:exitCode ?exit ; ver:chainHead ?head . } ORDER BY ?head";
let evidence_check_query: &str = "PREFIX ver: <http://seanchatmangpt.github.io/packs/ggen-verify#>\nSELECT ?exit ?dig WHERE { ?c a ver:EvidenceCheck ; ver:exitCode ?exit ; ver:requiredSetDigest ?dig . } ORDER BY ?dig";
let binary_check_query: &str = "PREFIX ver: <http://seanchatmangpt.github.io/packs/ggen-verify#>\nSELECT ?exit ?ha WHERE { ?c a ver:BinaryCheck ; ver:exitCode ?exit ; ver:hashA ?ha ; ver:hashB ?hb . } ORDER BY ?ha";
let binary_skip_query: &str = "PREFIX ver: <http://seanchatmangpt.github.io/packs/ggen-verify#>\nSELECT ?s WHERE { ?c a ver:BinaryCheck ; ver:skipped ?s . }";
let docs_fact: Option<ClassProducerFact> = {
let mut entries: Vec<(String, String)> = Vec::new();
if let EngineQueryResults::Solutions(rows) = graph.query(docs_manifest_query)? {
for row in rows {
if let (Some(EngineValue::String(path)), Some(EngineValue::String(sha))) =
(row.get("path"), row.get("sha"))
{
entries.push((path.clone(), sha.clone()));
}
}
}
if entries.is_empty() {
None
} else {
entries.sort();
let mut hasher = blake3::Hasher::new();
for (path, sha) in &entries {
hasher.update(path.as_bytes());
hasher.update(b"\n");
hasher.update(sha.as_bytes());
hasher.update(b"\n");
}
Some(ClassProducerFact {
exit: 0,
identity: hasher.finalize().to_hex().to_string(),
})
}
};
let read_exit_ident = |query: &str, ident_var: &str| -> Result<Option<ClassProducerFact>> {
if let EngineQueryResults::Solutions(rows) = graph.query(query)? {
for row in rows {
if let (Some(EngineValue::Int(exit)), Some(EngineValue::String(ident))) =
(row.get("exit"), row.get(ident_var))
{
return Ok(Some(ClassProducerFact {
exit: *exit,
identity: ident.clone(),
}));
}
}
}
Ok(None)
};
let receipts_fact = read_exit_ident(receipts_check_query, "head")?;
let evidence_fact = read_exit_ident(evidence_check_query, "dig")?;
let binary_fact = read_exit_ident(binary_check_query, "ha")?;
let mut binary_skipped = false;
if let EngineQueryResults::Solutions(rows) = graph.query(binary_skip_query)? {
for row in rows {
binary_skipped |= matches!(row.get("s"), Some(EngineValue::Bool(true)))
|| matches!(row.get("s"), Some(EngineValue::String(s)) if s == "true");
}
}
let mut producer_red = false;
let push_producer_item =
|items: &mut Vec<AdmissionItem>, class: &str, fact: &ClassProducerFact, what: &str| {
let pass = fact.exit == 0;
items.push(AdmissionItem {
evidence_id: format!("class:{class}:{}", fact.identity),
observed_outcome: if pass {
ObservedOutcome::Pass
} else {
ObservedOutcome::Fail
},
decision: AdmissionDecision::Admitted,
reason: if pass {
format!("{what} producer evidence admitted (exit 0)")
} else {
format!(
"{what} producer recorded red evidence (exit {}) -- lowers the \
`gate` component axis by the meet law",
fact.exit
)
},
obligations_discharged: Vec::new(),
obligations_created: Vec::new(),
});
};
if let Some(fact) = &docs_fact {
push_producer_item(&mut admission_items, "docs", fact, "docs-manifest");
}
if let Some(fact) = &receipts_fact {
producer_red |= fact.exit != 0;
push_producer_item(&mut admission_items, "receipts", fact, "receipts-check");
}
if let Some(fact) = &evidence_fact {
producer_red |= fact.exit != 0;
push_producer_item(&mut admission_items, "evidence", fact, "evidence-check");
}
if binary_skipped {
admission_items.push(AdmissionItem {
evidence_id: "class:compiled_binary:skipped".to_string(),
observed_outcome: ObservedOutcome::Unknown,
decision: AdmissionDecision::Admitted,
reason: "binary-check producer ran with --skip: explicit Unknown marker \
(skipped, not absent); the compiled_binary class stays Unknown"
.to_string(),
obligations_discharged: Vec::new(),
obligations_created: Vec::new(),
});
} else if let Some(fact) = &binary_fact {
producer_red |= fact.exit != 0;
push_producer_item(
&mut admission_items,
"compiled_binary",
fact,
"binary-check",
);
}
let prev_epoch = match (&prev_head, prev_chain_standing) {
(Some(_), Some(ChainStanding::LegacyV2Unbound)) => Some(ReceiptEpochV2::legacy_bounded()),
(Some(prev), _) => Some(read_receipt_epoch(&prev.record).map_err(|e| {
AppError::fm_chain(10, format!("previous receipt epoch unreadable: {e}"))
})?),
(None, _) => None,
};
let prev_ceiling = match &prev_epoch {
Some(epoch) => epoch.standing_ceiling,
None => CeilingLevel::Green,
};
let provisional_level = if admission_items
.iter()
.any(|i| i.decision == AdmissionDecision::Refused)
{
AndonLevel::Red
} else if admission_items
.iter()
.any(|i| i.decision == AdmissionDecision::Quarantined)
{
AndonLevel::Yellow
} else {
AndonLevel::Green
};
let mut components = ComponentLevels::uniform(provisional_level);
for (name, exit) in &ver_checks {
let evidence_level = if *exit == 0 || allowlisted_divergences.contains(name) {
AndonLevel::Green
} else {
AndonLevel::Red
};
let axis = match verify_check_axis(name) {
"test" => &mut components.test,
"lint" => &mut components.lint,
"fmt" => &mut components.fmt,
_ => &mut components.gate,
};
*axis = (*axis).min(evidence_level);
}
if producer_red {
components.gate = components.gate.min(AndonLevel::Red);
}
let equivalence = match (&prev_head, &prev_epoch) {
(Some(prev), Some(prev_epoch)) => {
let mut map = EquivalenceMap::all_unknown();
map.source = compare_closure_class("source", &report.closure, &prev.payload.closure);
map.config = compare_closure_class("config", &report.closure, &prev.payload.closure);
map.tests = compare_verify_class("tests", true, &admission_items, prev_epoch);
map.gates = compare_verify_class("gates", false, &admission_items, prev_epoch);
map.docs = compare_producer_class("docs", docs_fact.as_ref(), prev_epoch);
map.evidence = compare_producer_class("evidence", evidence_fact.as_ref(), prev_epoch);
map.compiled_binary = if binary_skipped {
EquivalenceStatus::Unknown
} else {
compare_producer_class("compiled_binary", binary_fact.as_ref(), prev_epoch)
};
map.receipts = match &receipts_fact {
None => EquivalenceStatus::Unknown,
Some(fact) if fact.exit != 0 => EquivalenceStatus::Divergent(format!(
"receipts producer recorded red evidence (exit {}): the receipt log \
failed schema validation or chain replay",
fact.exit
)),
Some(fact) if fact.identity == prev.record.chain_hash_hex => {
EquivalenceStatus::Equivalent
}
Some(fact) => EquivalenceStatus::Divergent(format!(
"receipts producer replayed chain head {} does not match the \
verified previous head {}",
fact.identity, prev.record.chain_hash_hex
)),
};
map
}
_ => EquivalenceMap::all_unknown(),
};
let mut epoch_builder =
ReceiptEpochV2Builder::new(prev_ceiling, components).equivalence(equivalence);
for item in admission_items {
epoch_builder = epoch_builder.admission_item(item);
}
let epoch = epoch_builder
.build()
.map_err(|e| AppError::fm_chain(11, format!("receipt epoch construction failed: {e}")))?;
let legacy_andon = match epoch.andon {
AndonLevel::Green => Andon::Green,
AndonLevel::Yellow => Andon::Overridden {
by: "ggen.sync.v2".to_string(),
reason: "quarantined admission item(s); see receipt v2.admission".to_string(),
at: 0,
},
AndonLevel::Red => Andon::Halted {
unmet: Vec::new(),
refusals: Vec::new(),
at: 0,
},
};
let legacy_obligation_count = epoch.obligation_count.remaining().unwrap_or(0);
let mut record = ReceiptRecord {
version: RECEIPT_RECORD_VERSION,
instruction_id: 0,
activity_idx: 0,
activity: Some("ggen.sync".to_string()),
node_kind: 0,
ts_ns: 0,
duration_ms: None,
origin: receipt_origin.map(str::to_string),
object_ids: vec![format!("law:{}", &payload_hash_hex[..16])],
payload_hash_hex,
prev_chain_hash_hex,
chain_hash_hex: String::new(),
andon: legacy_andon,
obligation_count: legacy_obligation_count,
signature_hex: None,
schema: SCHEMA_V2.to_string(),
v2: Some(epoch),
chain_rule: Some(CHAIN_RULE_V2_FOLD.to_string()),
};
let chain = record
.recompute_chain_hash()
.map_err(|e| AppError::fm_chain(2, format!("receipt chain computation failed: {e}")))?;
record.chain_hash_hex = hex32(&chain);
{
use ed25519_dalek::Signer as _;
let signing_key = crate::keys::resolve_signing_key(root)?;
let signature = signing_key.sign(record.chain_hash_hex.as_bytes());
record.signature_hex = Some(hex::encode(signature.to_bytes()));
}
let receipt = SyncReceipt { record, payload };
if let Some(parent) = receipt_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut line = serde_json::to_vec(&receipt)?;
line.push(b'\n');
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut f| f.write_all(&line))
.map_err(|e| {
AppError::fm_chain(
4,
format!("receipt log `{}` append failed: {e}", log_path.display()),
)
})?;
std::fs::write(&receipt_path, serde_json::to_vec(&receipt)?)?;
Ok(())
}
fn turtle_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub(crate) fn hex32(bytes: &[u8; 32]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(64);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::{collections::BTreeMap, path::Path};
use super::{
check_determinism_guard_agrees_false, check_determinism_rows_agree_empty,
hash_file_or_missing, resolve_gate_entries, ExtractedRows, SyncReceipt,
RECEIPT_LOG_REL_PATH, RECEIPT_REL_PATH,
};
#[test]
fn gate_glob_expansion_is_sorted_and_fail_closed() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::create_dir_all(root.join("gates")).expect("mkdir gates");
for name in ["c_gate.rq", "a_gate.rq", "b_gate.rq"] {
std::fs::write(root.join("gates").join(name), "ASK { FILTER(false) }")
.expect("write gate file");
}
let resolved = resolve_gate_entries(root, &[std::path::PathBuf::from("gates/*.rq")])
.expect("glob resolves");
let rels: Vec<&str> = resolved.iter().map(|(rel, _)| rel.as_str()).collect();
assert_eq!(
rels,
vec!["gates/a_gate.rq", "gates/b_gate.rq", "gates/c_gate.rq"]
);
let err = resolve_gate_entries(root, &[std::path::PathBuf::from("gates/*.norq")])
.expect_err("non-matching glob refuses");
let msg = err.to_string();
assert!(msg.contains("FM-CONFIG-014"), "{msg}");
assert!(msg.contains("GLOB_NO_MATCHES"), "{msg}");
assert!(msg.contains("gates/*.norq"), "{msg}");
let literal = resolve_gate_entries(root, &[std::path::PathBuf::from("gates/a_gate.rq")])
.expect("literal resolves");
assert_eq!(literal.len(), 1);
assert_eq!(literal[0].0, "gates/a_gate.rq");
let qmark = resolve_gate_entries(root, &[std::path::PathBuf::from("gates/?_gate.rq")])
.expect("?-glob resolves");
assert_eq!(qmark.len(), 3, "? matches exactly one char: {qmark:?}");
}
#[test]
#[ignore = "one-time destructive receipt migration; bind GGEN_RECEIPT_MIGRATION_ROOT for a project-scoped legacy chain"]
fn reseal_receipt_log_under_post_f1_chain_hash_formula() {
use ed25519_dalek::Signer as _;
let root = std::env::var_os("GGEN_RECEIPT_MIGRATION_ROOT").map_or_else(
|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("workspace root")
.to_path_buf()
},
std::path::PathBuf::from,
);
let log_path = root.join(RECEIPT_LOG_REL_PATH);
let head_path = root.join(RECEIPT_REL_PATH);
let content = std::fs::read_to_string(&log_path).expect("read receipt-log.jsonl");
let mut receipts: Vec<SyncReceipt> = content
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).expect("parse SyncReceipt line"))
.collect();
assert!(
!receipts.is_empty(),
"receipt log is empty — nothing to migrate"
);
let signing_key = crate::keys::resolve_signing_key(&root).expect("resolve signing key");
let mut prev_chain_hash_hex = "0".repeat(64);
let mut resealed = 0usize;
let mut unchanged = 0usize;
for receipt in &mut receipts {
receipt.record.prev_chain_hash_hex = prev_chain_hash_hex.clone();
let new_chain = receipt
.record
.recompute_chain_hash()
.expect("recompute_chain_hash under current (post-F1) formula");
let new_chain_hex = hex::encode(new_chain);
if new_chain_hex == receipt.record.chain_hash_hex {
unchanged += 1;
} else {
receipt.record.chain_hash_hex = new_chain_hex.clone();
let signature = signing_key.sign(receipt.record.chain_hash_hex.as_bytes());
receipt.record.signature_hex = Some(hex::encode(signature.to_bytes()));
resealed += 1;
}
prev_chain_hash_hex = receipt.record.chain_hash_hex.clone();
}
let mut running_prev = "0".repeat(64);
for (i, receipt) in receipts.iter().enumerate() {
assert_eq!(
receipt.record.prev_chain_hash_hex, running_prev,
"record {i}: chain linkage broken after migration"
);
let recomputed = receipt
.record
.recompute_chain_hash()
.unwrap_or_else(|e| panic!("record {i}: recompute failed post-migration: {e}"));
assert_eq!(
hex::encode(recomputed),
receipt.record.chain_hash_hex,
"record {i}: chain_hash_hex does not self-verify post-migration"
);
running_prev = receipt.record.chain_hash_hex.clone();
}
let mut out = String::new();
for receipt in &receipts {
out.push_str(&serde_json::to_string(receipt).expect("serialize SyncReceipt"));
out.push('\n');
}
std::fs::write(&log_path, out).expect("write resealed receipt-log.jsonl");
let head = receipts.last().expect("non-empty receipts");
std::fs::write(
&head_path,
serde_json::to_vec(head).expect("serialize head SyncReceipt"),
)
.expect("write resealed receipt.json head");
eprintln!(
"migration complete: {resealed} records re-sealed, {unchanged} already matched current formula (total {})",
receipts.len()
);
}
#[test]
fn unreadable_closure_input_binds_as_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let gone = dir.path().join("deleted.tmpl");
assert_eq!(hash_file_or_missing(&gone), "MISSING");
let real = dir.path().join("real.tmpl");
std::fs::write(&real, b"body").expect("write");
assert_eq!(
hash_file_or_missing(&real),
blake3::hash(b"body").to_hex().to_string()
);
}
fn tpl_path() -> &'static Path {
Path::new("templates/example.tmpl")
}
#[test]
fn guard_agrees_false_is_noop_when_determinism_not_requested() {
assert!(check_determinism_guard_agrees_false(tpl_path(), None).is_ok());
}
#[test]
fn guard_agrees_false_ok_when_recheck_guard_also_false() {
let recheck: ExtractedRows = None;
assert!(check_determinism_guard_agrees_false(tpl_path(), Some(&recheck)).is_ok());
}
#[test]
fn guard_agrees_false_errors_when_recheck_guard_evaluated_true() {
let recheck: ExtractedRows = Some((BTreeMap::new(), Vec::new()));
let err = check_determinism_guard_agrees_false(tpl_path(), Some(&recheck))
.expect_err("must reject a guard-true recheck after a guard-false primary");
let msg = err.to_string();
assert!(msg.contains("FM-TPL-009"), "{msg}");
assert!(
msg.contains("evaluated true while the first evaluated false"),
"{msg}"
);
}
#[test]
fn rows_agree_empty_is_noop_when_determinism_not_requested() {
assert!(
check_determinism_rows_agree_empty(tpl_path(), None, "named `for_each:` driver")
.is_ok()
);
}
#[test]
fn rows_agree_empty_ok_when_recheck_also_zero_rows() {
let recheck: ExtractedRows = Some((BTreeMap::new(), Vec::new()));
assert!(check_determinism_rows_agree_empty(
tpl_path(),
Some(&recheck),
"named `for_each:` driver"
)
.is_ok());
}
#[test]
fn rows_agree_empty_errors_when_recheck_guard_evaluated_false() {
let recheck: ExtractedRows = None;
let err = check_determinism_rows_agree_empty(
tpl_path(),
Some(&recheck),
"named `for_each:` driver",
)
.expect_err("must reject a guard-false recheck after a zero-row primary");
assert!(err.to_string().contains("FM-TPL-009"));
}
#[test]
fn rows_agree_empty_errors_when_recheck_produced_rows_after_primary_produced_zero() {
let recheck: ExtractedRows =
Some((BTreeMap::new(), vec![serde_json::json!({"name": "alice"})]));
let err =
check_determinism_rows_agree_empty(tpl_path(), Some(&recheck), "implicit row fan-out")
.expect_err("must reject a non-empty recheck after a zero-row primary");
let msg = err.to_string();
assert!(msg.contains("FM-TPL-009"), "{msg}");
assert!(msg.contains("implicit row fan-out"), "{msg}");
assert!(msg.contains("produced 1 row"), "{msg}");
}
}