ontoref/crates/ontoref-daemon/src/config_coherence.rs
Jesús Pérez 401294de5d
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
feat: config surface, NCL contracts, override-layer mutation, on+re update
Config surface — per-project config introspection, coherence verification, and
  audited mutation without destroying NCL structure (ADR-008):

  - crates/ontoref-daemon/src/config.rs — typed DaemonNclConfig (parse-at-boundary
    pattern); all section structs derive ConfigFields + config_section(id, ncl_file)
    emitting inventory::submit!(ConfigFieldsEntry{...}) at link time
  - crates/ontoref-derive/src/lib.rs — #[derive(ConfigFields)] proc-macro; serde
    rename support; serde_rename_of() helper extracted to fix excessive_nesting
  - crates/ontoref-daemon/src/main.rs — 3-tuple bootstrap block (nickel_import_path,
    loaded_ncl_config: Option<DaemonNclConfig>, stdin_raw); apply_ui_config takes
    &UiConfig; NATS call site typed; resolve_asset_dir cfg(feature = "ui")
  - crates/ontoref-daemon/src/api.rs — config GET/PUT endpoints, quickref, coherence,
    cross-project comparison; index_section_fields() extracted (excessive_nesting)
  - crates/ontoref-daemon/src/config_coherence.rs — multi-consumer coherence;
    merge_meta_into_section() extracted; and() replaces unnecessary and_then

  NCL contracts for ontoref's own config:
  - .ontoref/contracts.ncl — LogConfig (LogLevel, LogRotation, PositiveInt) and
    DaemonConfig (Port, optional overrides); std.contract.from_validator throughout
  - .ontoref/config.ncl — log | C.LogConfig applied
  - .ontology/manifest.ncl — contracts_path, log/daemon contract refs, daemon section
    with DaemonRuntimeConfig consumer and 7 declared fields

  Protocol:
  - adrs/adr-008-ncl-first-config-validation-and-override-layer.ncl — NCL contracts
    as single validation gate; Rust structs are contract-trusted; override-layer
    mutation writes {section}.overrides.ncl + _overrides_meta, never touches source

  on+re update:
  - .ontology/core.ncl — config-surface node (28 practices); adr-lifecycle extended
    to adr-007 + adr-008; 6 new edges (ManifestsIn daemon, DependsOn ontology-crate,
    Complements api-catalog-surface/dag-formalized/self-describing/adopt-ontoref)
  - .ontology/state.ncl — protocol-maturity blocker and self-description-coverage
    catalyst updated for session 2026-03-26
  - README.md / CHANGELOG.md updated
2026-03-26 20:20:22 +00:00

376 lines
13 KiB
Rust

//! Multi-consumer config coherence verification.
//!
//! For each section in a project's `config_surface`, this module compares the
//! fields present in the exported NCL JSON against the fields declared by each
//! consumer. A field absent from all consumers is "unclaimed" — it exists in
//! the config but nothing reads it.
//!
//! Coherence is checked from two directions:
//! - **NCL-only fields**: present in NCL, not claimed by any consumer.
//! - **Consumer-only fields**: a consumer declares a field that doesn't exist
//! in the NCL export. The consumer either references a renamed/removed field
//! or the NCL contract is incomplete.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::cache::NclCache;
use crate::registry::{ConfigSection, ConfigSurface};
/// Merge optional NCL `_meta_*` record fields into a quickref section object.
/// Only sets `rationale` when the section object currently has an empty value.
fn merge_meta_into_section(
obj: &mut serde_json::Map<String, serde_json::Value>,
meta_val: &serde_json::Value,
) {
if let Some(rationale) = meta_val.get("rationale").and_then(|v| v.as_str()) {
if obj["rationale"].as_str().unwrap_or("").is_empty() {
obj["rationale"] = serde_json::Value::String(rationale.to_owned());
}
}
if let Some(alt) = meta_val.get("alternatives_rejected") {
obj.insert("alternatives_rejected".to_owned(), alt.clone());
}
if let Some(constraints) = meta_val.get("constraints") {
obj.insert("constraints".to_owned(), constraints.clone());
}
if let Some(see_also) = meta_val.get("see_also") {
obj.insert("see_also".to_owned(), see_also.clone());
}
}
/// Status of a section's coherence check.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CoherenceStatus {
/// All fields are claimed by at least one consumer, and no consumer
/// references a field absent from the NCL export.
Ok,
/// Some fields are unclaimed or a consumer references missing fields —
/// worth reviewing but not necessarily a bug.
Warning,
/// The NCL export failed; coherence could not be checked.
Error,
}
/// Per-consumer coherence result within a section.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsumerCoherenceReport {
pub consumer_id: String,
pub kind: String,
/// Fields the consumer declared but are absent in the NCL export.
pub missing_in_ncl: Vec<String>,
/// Fields in the NCL export that this consumer doesn't declare.
/// Non-empty when the consumer has an explicit field list (not "reads
/// all").
pub extra_in_ncl: Vec<String>,
}
/// Full coherence report for one config section.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectionCoherenceReport {
pub section_id: String,
/// All top-level keys in the NCL export for this section (excluding
/// `_meta_*` and `_overrides_meta` keys).
pub ncl_fields: Vec<String>,
pub consumers: Vec<ConsumerCoherenceReport>,
/// Fields present in NCL but claimed by no consumer.
pub unclaimed_fields: Vec<String>,
pub status: CoherenceStatus,
}
/// Coherence report for an entire project.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectCoherenceReport {
pub project_slug: String,
pub sections: Vec<SectionCoherenceReport>,
pub has_config_surface: bool,
}
impl ProjectCoherenceReport {
/// Overall status: worst status across all sections.
pub fn overall_status(&self) -> CoherenceStatus {
if self
.sections
.iter()
.any(|s| s.status == CoherenceStatus::Error)
{
CoherenceStatus::Error
} else if self
.sections
.iter()
.any(|s| s.status == CoherenceStatus::Warning)
{
CoherenceStatus::Warning
} else {
CoherenceStatus::Ok
}
}
}
/// Run coherence check for all sections of a project's config surface.
///
/// `section_filter` — if `Some`, only check this section id.
pub async fn check_project(
slug: &str,
surface: &ConfigSurface,
project_root: &Path,
cache: &NclCache,
import_path: Option<&str>,
section_filter: Option<&str>,
) -> ProjectCoherenceReport {
let sections_to_check: Vec<&ConfigSection> = surface
.sections
.iter()
.filter(|s| section_filter.is_none_or(|f| s.id == f))
.collect();
let mut section_reports = Vec::with_capacity(sections_to_check.len());
for section in sections_to_check {
let report = check_section(section, surface, project_root, cache, import_path).await;
section_reports.push(report);
}
ProjectCoherenceReport {
project_slug: slug.to_owned(),
sections: section_reports,
has_config_surface: true,
}
}
async fn check_section(
section: &ConfigSection,
surface: &ConfigSurface,
project_root: &Path,
cache: &NclCache,
import_path: Option<&str>,
) -> SectionCoherenceReport {
let ncl_path = project_root.join(&surface.config_root).join(&section.file);
let ncl_export = cache.export(&ncl_path, import_path).await;
let (json, _) = match ncl_export {
Ok(pair) => pair,
Err(e) => {
warn!(
section = %section.id,
path = %ncl_path.display(),
error = %e,
"config coherence: nickel export failed"
);
return SectionCoherenceReport {
section_id: section.id.clone(),
ncl_fields: vec![],
consumers: vec![],
unclaimed_fields: vec![],
status: CoherenceStatus::Error,
};
}
};
// Collect top-level keys for this section from the export.
// A section NCL file may export { server = { ... }, _meta_server = {...} }
// We want the section key matching section.id; if the whole file is the
// section value, use all keys.
let ncl_fields: BTreeSet<String> = extract_section_fields(&json, &section.id);
// Build consumer reports.
let mut all_claimed: BTreeSet<String> = BTreeSet::new();
let mut consumer_reports = Vec::with_capacity(section.consumers.len());
for consumer in &section.consumers {
let consumer_fields: BTreeSet<String> = if consumer.fields.is_empty() {
// Empty field list means the consumer claims all NCL fields.
all_claimed.extend(ncl_fields.iter().cloned());
consumer_reports.push(ConsumerCoherenceReport {
consumer_id: consumer.id.clone(),
kind: format!("{:?}", consumer.kind),
missing_in_ncl: vec![],
extra_in_ncl: vec![],
});
continue;
} else {
consumer.fields.iter().cloned().collect()
};
all_claimed.extend(consumer_fields.iter().cloned());
let missing_in_ncl: Vec<String> =
consumer_fields.difference(&ncl_fields).cloned().collect();
let extra_in_ncl: Vec<String> = ncl_fields.difference(&consumer_fields).cloned().collect();
consumer_reports.push(ConsumerCoherenceReport {
consumer_id: consumer.id.clone(),
kind: format!("{:?}", consumer.kind),
missing_in_ncl,
extra_in_ncl,
});
}
let unclaimed_fields: Vec<String> = ncl_fields.difference(&all_claimed).cloned().collect();
let has_missing = consumer_reports
.iter()
.any(|c| !c.missing_in_ncl.is_empty());
let status = if !unclaimed_fields.is_empty() || has_missing {
CoherenceStatus::Warning
} else {
CoherenceStatus::Ok
};
SectionCoherenceReport {
section_id: section.id.clone(),
ncl_fields: ncl_fields.into_iter().collect(),
consumers: consumer_reports,
unclaimed_fields,
status,
}
}
/// Extract the field names for a section from the NCL export JSON.
///
/// If the JSON has a top-level key matching `section_id`, returns the keys of
/// that sub-object. Otherwise treats the entire top-level object as the section
/// fields. Strips `_meta_*` and `_overrides_meta` keys from the result.
fn extract_section_fields(json: &serde_json::Value, section_id: &str) -> BTreeSet<String> {
let obj = if let Some(sub) = json.get(section_id).and_then(|v| v.as_object()) {
sub.keys().cloned().collect()
} else if let Some(top) = json.as_object() {
top.keys()
.filter(|k| !k.starts_with("_meta_") && *k != "_overrides_meta")
.cloned()
.collect()
} else {
BTreeSet::new()
};
obj
}
/// Generate a quickref document for a project's config surface.
///
/// Combines: NCL export values + manifest metadata (descriptions, rationales,
/// consumers) + override history from `_overrides_meta` + coherence status.
pub async fn build_quickref(
slug: &str,
surface: &ConfigSurface,
project_root: &Path,
cache: &NclCache,
import_path: Option<&str>,
section_filter: Option<&str>,
) -> serde_json::Value {
let entry_point = surface.entry_point_path(project_root);
let full_export = cache.export(&entry_point, import_path).await.ok();
let coherence = check_project(
slug,
surface,
project_root,
cache,
import_path,
section_filter,
)
.await;
let coherence_by_id: BTreeMap<String, &SectionCoherenceReport> = coherence
.sections
.iter()
.map(|s| (s.section_id.clone(), s))
.collect();
let sections: Vec<serde_json::Value> = surface
.sections
.iter()
.filter(|s| section_filter.is_none_or(|f| s.id == f))
.map(|section| {
let current_values = full_export
.as_ref()
.and_then(|(json, _)| json.get(&section.id))
.cloned()
.unwrap_or(serde_json::Value::Null);
// Extract _meta_{section} from the section's own NCL file.
let meta_key = format!("_meta_{}", section.id);
let section_ncl_path =
project_root.join(&surface.config_root).join(&section.file);
let meta = tokio::task::block_in_place(|| {
// Use a sync export via the cached path — avoids async recursion.
std::process::Command::new("nickel")
.args(["export", "--format", "json"])
.arg(&section_ncl_path)
.current_dir(project_root)
.output()
.ok()
.and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok())
.and_then(|j| j.get(&meta_key).cloned())
});
// Extract override history from _overrides_meta if present.
let overrides = current_values
.as_object()
.and(full_export.as_ref())
.and_then(|(j, _)| {
j.get("_overrides_meta")
.and_then(|m| m.get("entries"))
.cloned()
})
.unwrap_or(serde_json::Value::Array(vec![]));
let coh = coherence_by_id.get(&section.id);
let coherence_summary = serde_json::json!({
"unclaimed_fields": coh.map(|c| c.unclaimed_fields.as_slice()).unwrap_or(&[]),
"status": coh.map(|c| format!("{:?}", c.status)).unwrap_or_else(|| "unknown".into()),
});
let consumers: Vec<serde_json::Value> = section
.consumers
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"kind": format!("{:?}", c.kind),
"ref": c.reference,
"fields": c.fields,
})
})
.collect();
let mut s = serde_json::json!({
"id": section.id,
"file": section.file,
"mutable": section.mutable,
"description": section.description,
"rationale": section.rationale,
"contract": section.contract,
"current_values": current_values,
"overrides": overrides,
"consumers": consumers,
"coherence": coherence_summary,
});
if let Some(meta_val) = meta {
if let Some(obj) = s.as_object_mut() {
merge_meta_into_section(obj, &meta_val);
}
}
s
})
.collect();
serde_json::json!({
"project": slug,
"generated_at": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
"config_root": surface.config_root.display().to_string(),
"entry_point": surface.entry_point,
"kind": format!("{:?}", surface.kind),
"sections": sections,
"overall_coherence": format!("{:?}", coherence.overall_status()),
})
}