ontoref/adrs/adr-008-ncl-first-config-validation-and-override-layer.ncl

94 lines
8.8 KiB
Plaintext
Raw Permalink Normal View History

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
let d = import "adr-defaults.ncl" in
d.make_adr {
id = "adr-008",
title = "NCL-First Config Validation and Override-Layer Mutation",
status = 'Accepted,
date = "2026-03-26",
context = "The config surface feature adds per-project config introspection and mutation to ontoref-daemon. Two design questions arise: (1) Where does config field validation live — in NCL contracts, in Rust struct validation, or both? (2) How does a PUT /config/{section} request mutate a project's config without corrupting the source NCL files? Direct mutation via nickel export → JSON → write-back destroys NCL comments, contract annotations, and section merge structure. Duplicating validation in both NCL and Rust creates two sources of truth with guaranteed divergence. The config surface spans ontoref's own .ontoref/config.ncl and all consumer-project configs, making the choice of validation ownership a protocol-level constraint.",
decision = "NCL contracts (std.contract.from_validator) are the single validation layer for all config fields. Rust serde structs are contract-trusted readers: they carry #[serde(default)] and consume pre-validated JSON from nickel export — no validator(), no custom Deserialize, no duplicate field constraints. Config mutation via PUT /projects/{slug}/config/{section} never modifies the original NCL source files. Instead it writes a {section}.overrides.ncl file containing only the changed fields plus a _overrides_meta audit record (actor, reason, timestamp, previous values), then appends a single idempotent import line to the entry-point NCL (using NCL's & merge operator so the override wins). nickel export validates the merged result against the section's declared contract before the mutation is committed; validation failure reverts the override file and returns the nickel error verbatim. Ontoref demonstrates this pattern on itself: .ontoref/contracts.ncl declares LogConfig and DaemonConfig contracts applied in .ontoref/config.ncl.",
rationale = [
{
claim = "NCL contracts are the correct validation boundary",
detail = "nickel export runs the contract check before any JSON reaches Rust. A value that violates LogLevel (must be one of error/warn/info/debug/trace) is rejected by nickel with a precise error message pointing to the exact field and contract. If Rust also validates, the two validators must stay in sync forever — and will diverge. Concentrating validation in NCL means the contract file is the authoritative spec for both the schema documentation and the runtime constraint.",
},
{
claim = "Override-layer mutation preserves NCL structure integrity",
detail = "A round-trip of nickel export → JSON → overwrite produces a file with no comments, no contract annotations, no merge structure, and no section rationale. The override layer avoids this entirely: the source file is immutable, the override file contains only changed fields, and NCL's & merge operator applies them at export time. The override file is git-versioned, human-readable, and revertable by deletion. nickel export on the merged entry point validates the result through the declared contract — the same validation that runs in production.",
},
{
claim = "Audit metadata in _overrides_meta closes the mutation traceability gap",
detail = "Each override file carries a top-level _overrides_meta record with managed_by = 'ontoref', created_at, and an entries array (field, from, to, reason, actor, ts). This record is consumed by GET /config/quickref to render an override history timeline and by GET /config/coherence to flag fields whose current value differs from the contract default. The metadata is a first-class NCL record — not a comment — so it survives export and is queryable by agents.",
},
],
consequences = {
positive = [
"Config field constraints are documented once (NCL contract) and enforced at the nickel export boundary — no duplication",
"Original NCL source files are immutable under daemon operation — diffs are clean, history is readable",
"nickel export validates override correctness before committing — contract violations return verbatim nickel errors to the caller",
"Override files are deletable to revert — no migration needed",
"_overrides_meta enables GET /config/quickref to render full change history with reasons",
"Ontoref's own .ontoref/contracts.ncl serves as a working example for consumer projects adopting the pattern",
],
negative = [
"Override layer requires the entry-point NCL to use & merge operators; SingleFile configs without section-level imports need a one-time restructure before overrides work",
"nickel export is the validation gate — validation errors are nickel syntax, not structured JSON; callers must parse nickel error output to surface friendly messages",
"#[serde(default)] on all Rust config structs means a missing NCL field silently uses the Rust default instead of erroring; the NCL contract (with its own defaults) is the intended fallback, not Rust",
],
},
alternatives_considered = [
{
option = "Duplicate validation in Rust (validator crate or custom Deserialize)",
why_rejected = "Two validators for the same field inevitably diverge. The NCL contract is already the authoritative schema for documentation, MCP export, and quickref generation — adding a Rust duplicate makes it decorative. validator crate adds 8+ transitive dependencies and requires annotation churn across every config struct.",
},
{
option = "Direct NCL file mutation (read → merge JSON → overwrite)",
why_rejected = "nickel export → JSON write-back destroys comments, contract annotations (| C.LogConfig), section merge structure, and in-file rationale. The resulting file is syntactically valid but semantically impoverished. Once a file is overwritten this way, the original structure cannot be recovered from git history if the file was also changed manually between sessions.",
},
{
option = "Separate config store (JSON or TOML side-file)",
why_rejected = "A side-file in a different format bypasses NCL type safety entirely — the merge operator and contract validation no longer apply. The daemon would need a custom merge algorithm to reconcile the side-file with the source NCL, and agents would need to understand two config representations. NCL's & merge operator is purpose-built for this use case.",
},
],
constraints = [
{
id = "override-layer-only",
claim = "The daemon must never write to original NCL config source files during a PUT /config/{section} mutation; only {section}.overrides.ncl may be created or modified",
scope = "crates/ontoref-daemon/src/api.rs (config mutation endpoints)",
severity = 'Hard,
check = { tag = 'Grep, pattern = "overrides\\.ncl", paths = ["crates/ontoref-daemon/src/api.rs"], must_be_empty = false },
rationale = "Immutability of source files is the safety contract the override layer provides. Violating it removes the ability to revert by deletion and breaks git diff clarity.",
},
{
id = "ncl-first-validation",
claim = "Rust config structs must not implement custom field validation; all field constraints live in NCL contracts applied before JSON reaches Rust",
scope = "crates/ontoref-daemon/src/config.rs",
severity = 'Hard,
check = { tag = 'Grep, pattern = "impl.*Validate|#\\[validate", paths = ["crates/ontoref-daemon/src/config.rs"], must_be_empty = true },
rationale = "Duplicate validation creates two diverging sources of truth. NCL contracts with std.contract.from_validator are the specified validation layer; Rust structs are downstream consumers of pre-validated data.",
},
{
id = "overrides-meta-required",
claim = "Every {section}.overrides.ncl file written by the daemon must contain a top-level _overrides_meta record with managed_by, created_at, and entries fields",
scope = "crates/ontoref-daemon/src/api.rs (override file generation)",
severity = 'Soft,
check = { tag = 'Grep, pattern = "_overrides_meta", paths = ["crates/ontoref-daemon/src/api.rs"], must_be_empty = false },
rationale = "Without audit metadata the override file is opaque — no way to surface change history in quickref or trace who changed what and why. Soft because deletion-based revert remains available even without metadata.",
},
],
related_adrs = ["adr-002", "adr-007"],
ontology_check = {
decision_string = "NCL contracts (std.contract.from_validator) are the single validation gate; Rust structs are contract-trusted with #[serde(default)]; config mutation uses override-layer files, never modifying original NCL sources",
invariants_at_risk = ["dag-formalized", "protocol-not-runtime"],
verdict = 'Safe,
},
}