Skip to content

D121 — Native local upgrades repair themselves

Status: implemented (2026-07-31); Homebrew path amended by D199. Supersedes the manual-restart portion of D95, for supported native local package upgrades only. The Cask no longer runs upgrade-repair (its install steps are sandboxed): the next cartographer sync does the replacement.

Decision. cartographer upgrade-repair is a non-interactive, idempotent command invoked by both official native update paths — install.sh and the Homebrew Cask post-install hook generated from .goreleaser.yaml — and available for manual retry. On a running installed service it gracefully replaces the process (launchctl kill SIGTERM relying on the plist's KeepAlive; systemctl --user restart on Linux) and polls /health until it returns 200, status:"ok" and the installed binary version, then reconciles the already-configured providers through the same in-process sync runner as plain cartographer sync. cartographer service restart --wait exposes the same version-gated replacement on its own; plain restart is unchanged.

Safety boundary. The effective config is discovered from the installed plist/unit (the argument after serve --config), so a custom-config installation is never verified against the standard endpoint; an installed but malformed definition fails before any process is signaled. Replacement is SIGTERM, never kickstart -k, so in-flight HTTP requests drain and pending pushes flush. Proof is bounded: connection failures, non-200 responses and the previous version are transient and retried, a malformed /health body fails fast, and the timeout error names the endpoint, the expected version and the last observed status/version. Zero mounted KBs are a valid state (/health is 200 while /ready is 503, D84), so verification uses health, not readiness. A service that is stopped or not installed is left exactly as it is. If /health already advertises the installed version the restart is skipped entirely, which makes a retry after a partial repair free of a second drain. Provider sync is best-effort and runs only when the client's server_url is loopback HTTP on the same port as the native service — never --auto-trust, never an invented approval, never disconnect/connect, never a deletion of user-owned configuration. Exit codes separate the two failure domains: 1 is a verified service with a pending sync (the binary update stands), 2 is a running service that could not be verified (no sync attempted).

Rationale. D95 assumed an operator-initiated drain was the only safe way to replace a running process, and forbade restarting from a Cask hook. That protection is now provided by the sequence itself — graceful termination, bounded health/version proof, preserved service intent — so the cost it imposed (a skew warning the user had to act on, plus providers left pointing at stale configuration until a manual sync) is no longer justified. D95's version-skew reporting remains as-is for Kubernetes, remote servers, and for any failure of this repair path.