# ADR-001: Nickel as First-Class Form Definition Format

**Status**: Accepted | **Date**: 2026-03-01 | **Supersedes**: None

## Context

TypeDialog forms were defined exclusively in TOML. TOML works well as a runtime serialization
target — zero overhead, pure data, direct `serde` deserialization — but has no type system, no
schema language, and no composition mechanism. Three structural problems accumulated:

**Incomplete contract validation.** `ContractValidator` was a Rust reimplementation of a subset
of Nickel stdlib predicates. The critical failure mode was fail-open: unknown predicates returned
`Ok(())` silently. A field with `String | std.string.IpAddr` received no validation, no error.
Every new predicate required a parallel Rust implementation guaranteed to drift from the Nickel
reference.

**I/O coupled to form execution.** Fragment lazy-loading (`includes = ["tls.toml"]`) triggered
`std::fs::read_to_string` inside the executor loop. Execution had hidden I/O side-effects. The
two-phase execution design (selectors first, then load fragments) existed specifically to work
around not knowing which fragments to load before Phase 1 ran. `${constraint.*}` interpolation
required a raw-string pre-pass before `toml::from_str` could be called.

**No static guarantees at load time.** A malformed TOML form was only detected when the executor
reached the bad field, mid-interaction, after the user had already answered several questions.

## Decision

Support Nickel (`.ncl`) as a first-class form definition format alongside TOML. Forms in Nickel
are loaded via `nickel export --format json` as a subprocess, deserializing the JSON output
directly into `FormDefinition`. If `nickel export` fails for any reason — contract violation,
syntax error, missing import — the process aborts before any interaction begins.

TOML remains fully supported. No migration is required for existing forms.

Three structural changes accompany this decision:

1. Remove `ContractValidator`. Validation is the Nickel interpreter's responsibility.
2. Replace fragment lazy-loading with Nickel native imports. `build_element_list` becomes a
   pure in-memory operation.
3. Add `when_false` to `FieldDefinition` to control output behavior for conditionally-hidden
   fields, enabling correct Nickel roundtrip for all schema fields.

## Rationale

1. **Fail-safe by default** — `nickel export` failing hard is better than fail-open validation.
   The user receives Nickel's own error message, which is precise and references the contract.
2. **Single source of truth for contracts** — The Nickel interpreter evaluates every predicate in
   the stdlib. No parallel Rust implementation, no drift, no coverage gaps.
3. **Static composition at load time** — Nickel's module system (`import`, `let`, merge operators)
   replaces runtime fragment loading. The executor receives a fully composed form; it performs no I/O.
4. **Proven pattern** — Config loading in typedialog already uses `nickel export` via subprocess.
   The contract between the Nickel CLI and the Rust consumer is established and tested.
5. **Backward compatibility** — TOML continues to work unchanged. Adoption of `.ncl` is opt-in
   per form, not a flag day.

## Consequences

- **Positive**:
  - Form validation is complete: every Nickel contract is evaluated by the Nickel interpreter
  - `build_element_list` is pure; executor has no I/O side-effects
  - `${constraint.*}` interpolation eliminated; Nickel handles constant references natively
  - Two-phase execution simplified: Phase 2 iterates an in-memory list, no file I/O
  - Form authors gain Nickel's full composition model: imports, let bindings, merge operators

- **Negative**:
  - `.ncl` forms require the `nickel` CLI binary on `PATH` at runtime
  - TOML forms do not benefit from the new static guarantees
  - `when_false = "default"` requires form authors to declare `default` values on all
    conditionally-hidden fields used in Nickel roundtrip

## Implementation

### Loading a `.ncl` form

```rust
pub fn load_form(path: impl AsRef<Path>) -> Result<FormDefinition> {
    match path.as_ref().extension().and_then(|e| e.to_str()) {
        Some("ncl") => load_from_ncl(path),
        _           => load_from_file(path),   // TOML path, unchanged
    }
}

pub fn load_from_ncl(path: impl AsRef<Path>) -> Result<FormDefinition> {
    let json = NickelCli::export(path.as_ref())?;  // aborts if nickel export fails
    serde_json::from_value(json).map_err(|e| ErrorWrapper::new(format!(
        "Failed to deserialize Nickel export as FormDefinition: {}", e
    )))
}
```

### Form defined in Nickel

```nickel
# form.ncl
let tls = import "fragments/tls.ncl" in
{
  name = "Server Configuration",
  elements = [
    { type = "field", name = "host", field_type = "text", prompt = "Host: " },
    { type = "field", name = "tls_enabled", field_type = "confirm", prompt = "Enable TLS?" },
  ] @ tls.elements,
}
```

`nickel export --format json form.ncl` produces the fully composed `FormDefinition` JSON.
If `tls.ncl` is missing or has a type error, the subprocess exits non-zero and the process
aborts immediately.

### `when_false` for conditional fields in roundtrip

```toml
[fields.tls_cert_path]
when       = "tls_enabled == true"
when_false = "default"    # inject field.default into results when condition is false
default    = "/etc/ssl/cert.pem"
```

When `tls_enabled` is false, `tls_cert_path` is never shown. With `when_false = "default"`,
the executor inserts `"/etc/ssl/cert.pem"` into results, so the roundtrip writer always has a
value for every field in the Nickel schema regardless of which branches the user navigated.

## Related ADRs

- No prior ADRs. This is the first recorded decision.

## Alternatives Considered

1. **Expand `ContractValidator`** — Rejected. Every new predicate requires a parallel Rust
   implementation. Fail-open default is a correctness hazard. Duplication introduces drift
   by construction.

2. **`nickel-lang-core` as a library crate** — Considered. Would eliminate the subprocess and
   the `PATH` dependency. Rejected: `nickel-lang-core` has no stable crates.io API surface;
   linking the Nickel runtime increases binary size for all backends; the subprocess model is
   already proven in the config-loading path.

3. **Per-field `nickel check` during interaction** — Considered for interactive validation per
   keystroke. Rejected: form execution is stateful (answer N may be required to evaluate
   contract N+1). Subprocess overhead per field is not acceptable. Load-time `nickel export`
   plus post-execution `nickel typecheck` (existing roundtrip path) covers both ends without
   per-field cost.

4. **JSON Schema or CUE as schema layer over TOML** — Rejected. Structurally inferior to Nickel
   contracts for configuration data: no gradual types, no first-class functions, no `std`
   predicate library. Adopting a second schema language while Nickel is already a first-class
   citizen adds complexity without comparable expressiveness.
