let d = import "adr-defaults.ncl" in d.make_adr { id = "adr-022", title = "ncl-sync: Nickel Configuration Sync Daemon", status = 'Accepted, date = "2026-04-16", context = "Every `prvng` CLI invocation that reads configuration runs `nickel export --format json` at least once, often multiple times. There are 124 call sites across the Nu codebase; each export costs 2-5s. The Nu module parse cost (~600-1200ms for 345 files) is a separate problem. This plan targets only the Nickel export cost. `lib_provisioning/config/cache/` already existed with the correct API shape (`cache-lookup`, `lookup-nickel-cache`, etc.) but every function was a no-op — the infrastructure was there but never wired to actual storage. Additionally, `nu_plugin_nickel` already implements file-content-based caching (`nickel-eval` command) but lacked `--import-path` support, which is why all 124 call sites used `^nickel export` directly instead of the plugin.", decision = "A Rust daemon (`ncl-sync`) compiles NCL to JSON proactively and maintains a shared cache at `~/.cache/provisioning/config-cache/`. The daemon and the `nu_plugin_nickel` plugin share a single cache directory and a single key derivation strategy: `SHA256(file_content + sorted_import_paths_joined_by_colon + format)`. This makes the key content-addressed — identical file content produces the same key regardless of path, and the daemon's pre-warmed entries are immediately visible to `nickel-eval` without any coordination protocol. Nu call sites in the hot path replace `^nickel export --format json ... | from json` with `nickel-eval ... --import-path [...]`. For soft-failure call sites (where export failure is acceptable), a `ncl-eval-soft` wrapper in `lib_provisioning/utils/nickel_processor.nu` isolates the single necessary `try/catch` and exposes clean call sites. The daemon is started by `prvng platform start` via `ncl-sync-start` in `service-manager.nu` and stopped by `prvng platform stop`. Nu processes signal re-export needs after mutations by writing `.sync-.json` sidecar files (atomic rename); the daemon drains these every 500ms.", rationale = [ { claim = "Shared cache dir + content-based key eliminates the need for a socket or IPC between daemon and Nu processes", detail = "The plugin's `lookup_cache` reads `~/.cache/provisioning/config-cache/.json` directly from disk. The daemon writes to the same path. There is no runtime coordination — the plugin simply finds the file or falls back to direct `nickel export`. Alternative: daemon exposes a Unix socket for reads (prvng-cli daemon plan) — requires Nu processes to know the socket path, handle connection failures, and adds 10-15ms of socket overhead. The file-based approach gives <5ms reads and zero coupling.", }, { claim = "Content-addressed key (SHA256 of file content) is more correct than path+mtime-based key", detail = "A path+mtime key would falsely invalidate the cache if a file is touched without content change (e.g. `git checkout`, `touch`). A content-based key ensures that identical NCL files share a cache entry regardless of path, and that the cache only misses when the file actually changed. The tradeoff is that the key computation requires reading the file — mitigated by the daemon doing this proactively at warm-up rather than on each Nu invocation.", }, { claim = "Extending nu_plugin_nickel with --import-path is the correct fix for the 124 ^nickel call sites", detail = "The plugin existed precisely for this purpose but lacked `--import-path` support, forcing all provisioning code to use `^nickel export` directly. Adding `--import-path` to `nickel-eval` and `nickel-export` unblocks the migration. The plugin already converts JSON to Nu values natively (eliminating `| from json`), handles caching, and preserves error semantics via `LabeledError`.", }, { claim = "ncl-sync does not require NATS, SurrealDB, or any platform service", detail = "The daemon watches the filesystem via `notify`, runs `nickel` as a subprocess, and writes JSON files. It has no network dependencies. If ncl-sync depended on NATS to function, it would have a bootstrap circularity: NATS is a platform service whose configuration is described in NCL. A config cache daemon cannot depend on the services whose configuration it caches.", }, { claim = "Nu processes are never writers to the cache directory", detail = "Single-writer principle: only ncl-sync writes `.json` files to the cache. Nu processes write `.sync-.json` sidecar files as signals to the daemon, then immediately continue execution. The daemon drains sidecars and writes cache entries. This prevents concurrent-write corruption of cache files without requiring locks.", }, ], consequences = { positive = [ "prvng component list, workflow list: ~1.5s (from ~3-7s) — Nu module parse only, no nickel export stall", "prvng deploy: ~3-5s (from ~15-30s) — multiple nickel exports are cache hits", "Cache survives across prvng invocations — warm-up on platform start amortizes the cost for the whole session", "nu_plugin_nickel is now usable for all config reads (--import-path gap closed)", ], negative = [ "Nu startup cost (~1.2s module parse) is unaffected — a separate problem", "First invocation of the day: cache cold until daemon warm-up completes (~500ms-2s)", "ncl-sync binary must be installed and in PATH for performance benefits; absence degrades gracefully to direct nickel export", ], }, alternatives_considered = [ { option = "prvng-cli daemon: route read-only CLI commands to a separate Rust HTTP server via Unix socket", why_rejected = "Solves only specific read commands (<100ms), not the general nickel export cost. Adds a second daemon with socket/PID lifecycle. Nu call sites still need output formatting to match Nu tables. Doesn't help operation-path commands that also call nickel export.", }, { option = "Lazy-load Nu modules (refactor main_provisioning/mod.nu)", why_rejected = "The dispatcher already lazy-loads commands/ subdirectory. The Nu interpreter startup (~200-400ms) is unavoidable regardless. Module parse cost is ~600-1200ms — a real problem but separate from the nickel export stall. This plan targets nickel export; module parse is a future orthogonal improvement.", }, { option = "Nu-side cache with file-mtime check (no daemon)", why_rejected = "Nu processes are ephemeral — no proactive warming. First command of each session still pays the nickel export cost. Concurrent Nu processes (Makefile, CI) cause cache stampede: multiple processes miss simultaneously and all run nickel export. No file watching — cache becomes stale silently after NCL edits.", }, { option = "Separate cache directories for daemon and plugin", why_rejected = "Requires a coordination protocol (socket, IPC, or manifest polling) so the plugin can find daemon-written entries. The shared-directory approach eliminates coordination entirely — the key derivation IS the coordination protocol.", }, ], ontology_check = { decision_string = "ncl-sync Rust daemon + nu_plugin_nickel shared cache at ~/.cache/provisioning/config-cache/ with content-based key SHA256(content+imports+format)", invariants_at_risk = ["config-driven-always", "type-safety-nickel"], verdict = 'Safe, }, related_adrs = ["adr-023-ncl-export-wrapper"], constraints = [ { id = "ncl-sync-single-writer", claim = "Nu processes NEVER write .json files to the cache directory directly", scope = "provisioning/core/nulib/", severity = 'Hard, check = { tag = 'Grep, pattern = "save.*config-cache.*\\.json", paths = ["provisioning/core/nulib/"], must_be_empty = true }, rationale = "Single-writer principle: concurrent Nu processes writing cache files would corrupt manifest state and produce partial JSON. Only ncl-sync daemon writes to the cache directory.", }, { id = "ncl-sync-no-platform-services", claim = "ncl-sync binary must not depend on platform-nats, platform-db, or surrealdb", scope = "provisioning/platform/crates/ncl-sync/Cargo.toml", severity = 'Hard, check = { tag = 'Grep, pattern = "platform-nats|platform-db|surrealdb", paths = ["provisioning/platform/crates/ncl-sync/"], must_be_empty = true }, rationale = "Bootstrap circularity: NATS and SurrealDB are platform services whose configuration is managed by ncl-sync. The daemon cannot depend on services it configures.", }, ], }