NCL-First Config Validation and Override-Layer Mutation
Accepted
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.
Constraints
- Hard 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
- Hard Rust config structs must not implement custom field validation; all field constraints live in NCL contracts applied before JSON reaches Rust
- Soft 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
Alternatives considered
- Duplicate validation in Rust (validator crate or custom Deserialize) — 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.
- Direct NCL file mutation (read → merge JSON → overwrite) — 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.
- Separate config store (JSON or TOML side-file) — 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.