One launcher for the daemon: shell, just and the user service run it through the same script
Accepted
Context
The daemon had three ways to be started and none of them agreed with the others.
`ontoref-daemon` (install/ontoref-daemon-boot, ADR-004) ran it in the foreground with the config piped in from `nickel export`, SOPS/Vault merged, and projects registered on boot. It was the only path that read config.ncl, and it could not put the daemon in the background.
`ontoref services start` spawned the binary directly with `\^ontoref-daemon … &`. Nushell has no background `&` and the escaped caret is a literal, so it never started anything; had it run, it would have skipped the config pipe and printed port 7890 for a daemon that defaults to 7891.
No service unit existed. ADR-004 withdrew its `bash-wrapper-zero-deps` constraint on 2026-07-21 because nothing ran the daemon under launchd or systemd, and wrote down the condition for its return: daemon-under-a-service-manager "arrives with its unit file and its own ADR". The FSM dimension operational-mode (local → daemon) carries the same absence as its blocker: "Daemon not run as a SERVICE".
Under all three, the daemon applied config.ncl OVER its own command-line flags (main.rs, `load_config_overrides`), and the stdin path applied `port` only. A service started with `--idle-timeout 0` would have been shut down by a config that says 1800.
Decision
Every way the daemon runs goes through ONE launcher: install/ontoref-daemon-boot, installed as `ontoref-daemon`. It keeps its source name because the OCI bundle and the published installer ship it under that name. It has four verbs over one pipeline:
run foreground, the ADR-004 pipeline (the default; `ontoref-daemon --port N` still works) start background from any directory, with pid and log in $XDG_STATE_HOME/ontoref/ stop | restart | status against that pid and /health service install | uninstall | restart | status a per-user unit: launchd on macOS, systemd --user on Linux
The unit, the just recipes (`just daemon*`), `ontoref services` and the INSTALLER all call the launcher. None of them spawns the binary, and none of them stops one behind its manager's back. The launcher's own options (--config, --sops, --vault, --binary) and the daemon's flags pass through every verb, so a setting means the same thing in all of them.
Replacing the binary is part of running it. The installer writes the new binary to a new inode and renames it into place — an in-place overwrite leaves a file whose pages no longer match its code signature, and the process killed by that is the NEXT one exec'd from it, which is what a service manager creates after an install. Then it asks the launcher to restart the service, through the manager. `service restart` answers exit 3 when no service is installed, and only that answer sends the installer down the dev path, where killing the process is the right act because nothing is supervising it.
Precedence is flag > environment > config.ncl > built-in default. The daemon records which options came from its command line (clap's value source), and one function applies the config under that rule for both config sources: the stdin pipe and the project's config.ncl.
A service is restarted when it exits, so it never idles out: `service install` forces `--idle-timeout 0` and refuses an explicit one. A service manager does not read the user's profile, so the unit pins the directories where nickel, nu and curl were found (plus sops, vault and jq when requested), and install refuses when any of them is not resolvable. One daemon serves every registered project, so `start` refuses when one is already running and names it.
Constraints
- Hard The service unit execs the launcher's `run`, and the just recipes, `ontoref services` and the installer call the launcher; none of them spawns the daemon binary, and none stops a supervised one except through its manager.
- Hard An option given on the daemon's command line is never overridden by config.ncl, from the stdin pipe or from the project config.
- Hard `service install` refuses when nickel, nu or curl is not resolvable, and refuses an explicit --idle-timeout; the unit it writes forces --idle-timeout 0 and pins the directories its tools were found in.
Alternatives considered
- Fix `ontoref services` in Nushell and add the service there — rejected: A launchd or systemd unit would then exec nu, the dispatcher and a module chain to start one binary, and the config pipe would exist twice: once in bash for the shell and once in nu for the service. It keeps two pipelines, which is the drift this decision removes.
- Ship static launchd/systemd unit files under install/ — rejected: The unit's content is the installing user's: their HOME, where their nickel lives, their config path and flags. A static file would need templating at install time anyway, and a second copy of the daemon's arguments that the launcher does not see.
- Keep config > flag and document it — rejected: A service could not guarantee --idle-timeout 0, and every flag would be advisory. Documenting an inversion does not make the flag mean what it says.
- Allow several daemons, one per project — rejected: One daemon serves every registered project (qa ontoref-multi-tier-management). A second one on another port would split the registry and the actor sessions for no gain the first cannot give.
Anti-patterns
- Every surface starting the daemon its own way — A verb, a recipe and a service unit each spawn the binary with their own arguments. Each reads config differently or not at all, reports its own idea of the port, and tracks its own pid. The divergence is invisible until the surfaces are compared.
- A config file that silently overrides an explicit command-line flag — Defaults are overwritten by config, and so are values the operator typed, because the code cannot tell them apart. The flag is accepted and ignored, and the setting that matters for the run is the one the config happened to carry.
- Stopping a supervised process by name instead of through its manager — Code that needs the process gone signals it by process name. Under a supervisor this appears to work — a restart policy sees an unsuccessful exit and brings the unit back — so the caller is right by accident and learns nothing about the manager it bypassed. The same code, on a host where no manager owns the process, stops it and never starts it again, while reporting success. Both outcomes come from the same line, and which one you get depends on a policy the caller never read.
Perspectives