feat(service): scope-explicit install/uninstall/start/stop (--scope) - #123
Open
MichaelTaylor3d wants to merge 5 commits into
Open
feat(service): scope-explicit install/uninstall/start/stop (--scope)#123MichaelTaylor3d wants to merge 5 commits into
MichaelTaylor3d wants to merge 5 commits into
Conversation
Scope-explicit service verbs so an elevated installer can register a system-level, reboot-surviving dig-node service. Co-Authored-By: Claude <noreply@anthropic.com>
Retire the unconditional `PREFERS_USER_LEVEL` decision const and thread a runtime ServiceScope through the service verbs, so an elevated installer can register a system-level (boot-started) dig-node service on a headless host while an unelevated desktop install keeps today's user-level behaviour. * resolve_scope(choice, os_supports_user, is_root) is the ONE scope decision and is PURE: every input is a parameter, so the complete 12-row decision table is asserted on any host at any privilege level. * install clears any registration at the OTHER scope BEFORE creating at the requested one, so a host upgrading from a user-level install never runs two units racing for the node's port. * uninstall --scope auto sweeps both scopes and reports per scope; anything short of a complete removal is an error, never a silent success. * The §565 privileged-target gate now genuinely fires on unix system scope, so its refusal names the offending path LEVEL instead of just the program path. Refs #526. Co-Authored-By: Claude <noreply@anthropic.com>
…ENT_LOG Bump to 0.69.0 (minor: a new backwards-compatible --scope flag). Co-Authored-By: Claude <noreply@anthropic.com>
…to default A lib-level scope test cannot see a verb that forgot to accept the flag, so assert it against the BUILT binary's own help (and that an unknown value is a usage error rather than a silent fallback to the default). Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
force-pushed
the
feat/526-service-scope
branch
from
July 31, 2026 15:21
4d19fe1 to
a4eaa67
Compare
…dvisory The macOS service-smoke leg caught this: launchctl print gui/<uid>/<label> cannot see a per-user agent from a session with no GUI domain, so a probe-gated uninstall reported "no service registration was found" and exited 6 on a service that WAS registered. The OS deregistration call is the authority, not the probe. A RemovalMode now distinguishes the scope the operator NAMED (removed unconditionally, which is also the pre-#526 behaviour) from a scope merely being SWEPT for a stale registration (probe-gated, so a scope nobody asked about is never written to). A scope whose absence was never established is reported as indeterminate rather than as a clean uninstall. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
dig-node installwas hardcoded to prefer a user-level registration on every non-Windowsplatform (
const PREFERS_USER_LEVEL: bool = true). Run undersudo dig-installer, that tries asystemd --userunit — and root has no systemd--userD-Bus session(
Failed to connect to bus: Operation not permitted, observed asexited with 6), so an elevatedheadless install could not get a reboot-surviving registration at all. dig-node's own native
packages already register SYSTEM scope (
packaging/linux/systemd/net.dignetwork.dig-node.serviceis
WantedBy=multi-user.targetas root;packaging/macos/scripts/postinstallbootstrapslaunchctl … system), so the CLI verb was the outlier. This makes the CLI coherent with thepackages, without changing the no-root desktop UX.
The compile-time constant is retired and replaced by a runtime scope threaded through the verbs:
CLI surface (identical on
dig-nodeand thedignalias):resolve_scopeis the ONE scope decision and it is PURE — nocfg!, nogeteuid, nofilesystem.
auto= system when root, user otherwise; an explicit--scopeis AUTHORITATIVE andnever silently overridden; an OS with no user domain (Windows SCM) always resolves to system.
install— the other scope is probed and, if it holds aregistration, deregistered BEFORE creating at the requested scope. Without it, a host upgrading
from a user-level install ends up with two units both binding the node port and the stale one can
win. Best-effort and reported (
result.migrated_from_scope), never fatal.uninstall --scope autosweeps BOTH scopes and reports per scope (result.removed_scopes); ascope found-but-not-removed, or one left indeterminate, is an ERROR naming the scope — never a
silent success.
RemovalMode). Thescope the operator NAMED is deregistered unconditionally — as
uninstallalways did pre-#526 —because
launchctl print gui/<uid>/<label>cannot see a per-user agent from a session with no GUIdomain and false-negatives on a service that IS registered. A merely-SWEPT scope stays
probe-gated, so a scope nobody asked about is never written to. The macOS
service-smokejobcaught this for real (first push:
no service registration … was found, exit 6, on a servicethe same job had just installed); the fix has a named regression test and the leg is now green.
--scope systemis refused loudly, never downgraded to user scope (a silentdowngrade produces exactly the registration that does not survive a reboot).
refusal now names the offending path level (
security::first_unprivileged_ancestor) insteadof only the program path, so an operator can act on it. The dig-installer's root-owned
/opt/dig/binclears the gate.Backwards compatibility
--scopedefaults toauto, andautounprivileged on a user-capable OS resolves toUser—byte-for-byte the pre-change behaviour. An older dig-installer that passes no flag keeps working
(asserted by
scope_choice_default_is_auto_so_an_older_installer_passing_no_flag_is_unchanged).No public item was removed except the private
PREFERS_USER_LEVELconst.Blast radius checked
gitnexus is disabled in the loop (CLAUDE.md §2.0 temp override), so the radius was taken with
repo-wide ripgrep + direct reads:
PREFERS_USER_LEVEL(removed)service.rs(SystemServiceBackend::new,query_installed,launchd_domain_target)SystemServiceBackend::newservice.rsquery_installed,launchd_domain_targetservice.rsonlyensure_service_target_is_safeservice.rsonlyservice::{install,uninstall,start,stop}entrypoint.rsdispatch ONLY (repo-wide grep)security::dir_is_privilegedfirst_unprivileged_ancestorCross-repo consumers (dig-installer, the native packages) drive the CLI, not the Rust API, and
the flag is optional with an unchanged default — so nothing outside this repo must change to keep
working. Risk assessed MEDIUM: privileged-service registration is custody-adjacent (the §565 LPE
gate sits on this path), but the gate got STRICTER on unix, not looser, and no existing default
moved.
Test evidence (real output)
Every PR check is GREEN, including all three
service-smokelegs — a REALinstall/start/reinstall/uninstall round-trip against real
systemctl/launchctl/sc.exeonubuntu-latest,macos-14andwindows-latest, which is the only evidence that the new scope pathworks against actual OS service managers.
cargo test -p dig-node-service— 264 unit + 101 integration, all green:The 12-row decision table (the primary evidence — deliberately NOT
#[cfg(unix)]-gated, since acfg-gated scope test is unfalsifiable on a Windows host):
Each new test proved load-bearing by mutation (committed first, then reverting only the fix):
Autoprivilege branchesresolve_scope_decision_table…,scope_choice_default_is_auto…reinstall(the placement bug)install_deregisters_the_other_scope_before_creating_at_the_requested_oneuninstall_fails_loudly_when_a_found_registration_could_not_be_removed,uninstall_reports_an_indeterminate_scope…a_system_scope_registration_requires_root_on_a_user_capable_osa_refused_system_target_names_the_offending_directory_level--scopefrom thestopverbevery_service_verb_accepts_the_scope_flag_defaulting_to_autoa_requested_scope_is_removed_even_when_the_probe_cannot_see_ita_swept_scope_is_never_written_to_on_a_probe_that_sees_nothing,install_touches_nothing_at_the_other_scope_when_it_holds_no_registrationA false green was caught and fixed during that pass:
contains(offending_path)is VACUOUS, becauseevery ancestor is a string prefix of the program path the message already prints — the test now
asserts the exact naming phrase instead.
Coverage (
cargo llvm-cov -p dig-node-service --lib --summary-only, the same command on bothsides) — the new code RAISES it:
service.rsregionsservice.rslinesorigin/main(This repo's CI measures coverage across all targets and is measure-only; the lib-only figures above
are directly comparable to each other.)
cargo fmt --all -- --checkclean;cargo clippy -p dig-node-service --all-targets --all-features -- -D warningsclean.Docs kept coherent in the same unit
SPEC.md§9.1 rewritten as the normative scope model (the per-scope on-disk locations, what makesa registration survive a reboot, the resolution rules, the migration + sweep contracts, the
--scopedefault), §9.2c updated for the path-level refusal, §8.1 lists the flag.DEVELOPMENT_LOG.md— a durable entry on root having no systemd user bus undersudo.README.md— the operator-facing scope/--scopeexplanation.Version
0.69.0 (minor — a new backwards-compatible capability;
feat).Cargo.lockre-synced in thesame commit. No
package.jsonin this repo.NOT proven here
A real headless-Linux
sudo dig-installerrun: there is no Linux host in this lane, and anelevation-dependent test would give every other lane a false RED on an unelevated dev box
(dig_ecosystem#1865). Every scope decision is proven host-independently instead; the end-to-end
elevated install stays dig_ecosystem#526's own acceptance step.
Refs dig_ecosystem#526.