Skip to content

feat(service): scope-explicit install/uninstall/start/stop (--scope) - #123

Open
MichaelTaylor3d wants to merge 5 commits into
mainfrom
feat/526-service-scope
Open

feat(service): scope-explicit install/uninstall/start/stop (--scope)#123
MichaelTaylor3d wants to merge 5 commits into
mainfrom
feat/526-service-scope

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What changed

dig-node install was hardcoded to prefer a user-level registration on every non-Windows
platform (const PREFERS_USER_LEVEL: bool = true). Run under sudo dig-installer, that tries a
systemd --user unit — and root has no systemd --user D-Bus session
(Failed to connect to bus: Operation not permitted, observed as exited with 6), so an elevated
headless 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.service
is WantedBy=multi-user.target as root; packaging/macos/scripts/postinstall bootstraps
launchctl … system), so the CLI verb was the outlier. This makes the CLI coherent with the
packages, without changing the no-root desktop UX.

The compile-time constant is retired and replaced by a runtime scope threaded through the verbs:

pub enum ServiceScope { System, User }                       // the resolved answer
pub enum ScopeChoice  { Auto, System, User }                 // what the operator asked for
                                                             // clap ValueEnum: auto|system|user
pub fn resolve_scope(choice: ScopeChoice, os_supports_user: bool, is_root: bool) -> ServiceScope;
pub fn uninstall_scopes(choice: ScopeChoice, os_supports_user: bool, is_root: bool) -> Vec<ServiceScope>;
pub fn install_at_scope<T: ServiceBackend, O: ServiceBackend>(
    target: &T, other: Option<(&O, ServiceScope)>, plan: &InstallPlan,
) -> io::Result<(ReinstallReport, Option<ScopeRemoval>)>;
pub enum RemovalMode { Requested, Swept }    // Requested = remove unconditionally; Swept = probe-gated
pub fn remove_registration<B: ServiceBackend + ?Sized>(
    backend: &B, scope: ServiceScope, mode: RemovalMode,
) -> ScopeRemoval;
pub fn remove_registrations(
    targets: &[(&dyn ServiceBackend, ServiceScope, RemovalMode)],
) -> Vec<ScopeRemoval>;
pub struct ScopeRemoval {
    pub scope: ServiceScope,
    pub found: bool,          // what the PROBE saw — advisory; false does NOT prove absence
    pub removed: bool,        // the OS deregistration succeeded — authoritative
    pub indeterminate: bool,  // absence never established (probe failed AND removal failed)
    pub error: Option<String>,
}

CLI surface (identical on dig-node and the dign alias):

dig-node install   [--scope <auto|system|user>]   # default: auto
dig-node uninstall [--scope <auto|system|user>]
dig-node start     [--scope <auto|system|user>]
dig-node stop      [--scope <auto|system|user>]
  • resolve_scope is the ONE scope decision and it is PURE — no cfg!, no geteuid, no
    filesystem. auto = system when root, user otherwise; an explicit --scope is AUTHORITATIVE and
    never silently overridden; an OS with no user domain (Windows SCM) always resolves to system.
  • Cross-scope migration in install — the other scope is probed and, if it holds a
    registration, 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 auto sweeps BOTH scopes and reports per scope (result.removed_scopes); a
    scope found-but-not-removed, or one left indeterminate, is an ERROR naming the scope — never a
    silent success.
  • The existence probe is ADVISORY; the OS deregistration is AUTHORITATIVE (RemovalMode). The
    scope the operator NAMED is deregistered unconditionally — as uninstall always did pre-#526 —
    because launchctl print gui/<uid>/<label> cannot see a per-user agent from a session with no GUI
    domain 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-smoke job
    caught this for real
    (first push: no service registration … was found, exit 6, on a service
    the same job had just installed); the fix has a named regression test and the leg is now green.
  • An unprivileged --scope system is refused loudly, never downgraded to user scope (a silent
    downgrade produces exactly the registration that does not survive a reboot).
  • The §565 privileged-target gate now genuinely fires on unix system scope — correct, and its
    refusal now names the offending path level (security::first_unprivileged_ancestor) instead
    of only the program path, so an operator can act on it. The dig-installer's root-owned
    /opt/dig/bin clears the gate.

Backwards compatibility

--scope defaults to auto, and auto unprivileged on a user-capable OS resolves to User
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_LEVEL const.

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:

Symbol changed Callers found Where
PREFERS_USER_LEVEL (removed) 4 all inside service.rs (SystemServiceBackend::new, query_installed, launchd_domain_target)
SystemServiceBackend::new 4 + 1 test all inside service.rs
query_installed, launchd_domain_target private, 3 service.rs only
ensure_service_target_is_safe 1 + 3 tests service.rs only
service::{install,uninstall,start,stop} 4 entrypoint.rs dispatch ONLY (repo-wide grep)
security::dir_is_privileged 3 (self_heal, tls, service) unchanged signature; now delegates to the new first_unprivileged_ancestor

Cross-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-smoke legs — a REAL
install/start/reinstall/uninstall round-trip against real systemctl/launchctl/sc.exe on
ubuntu-latest, macos-14 and windows-latest, which is the only evidence that the new scope path
works against actual OS service managers.

cargo test -p dig-node-service — 264 unit + 101 integration, all green:

running 264 tests
test result: ok. 264 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.07s
... (integration) 7, 5, 4, 12, 1, 2, 10, 7, 4, 48 tests — every one `result: ok`

The 12-row decision table (the primary evidence — deliberately NOT #[cfg(unix)]-gated, since a
cfg-gated scope test is unfalsifiable on a Windows host):

test service::tests::resolve_scope_decision_table_is_exhaustive_across_choice_os_and_privilege ... ok

Each new test proved load-bearing by mutation (committed first, then reverting only the fix):

Mutation Test that FAILED
swap the Auto privilege branches resolve_scope_decision_table…, scope_choice_default_is_auto…
move the migration AFTER reinstall (the placement bug) install_deregisters_the_other_scope_before_creating_at_the_requested_one
drop the incomplete-removal guard uninstall_fails_loudly_when_a_found_registration_could_not_be_removed, uninstall_reports_an_indeterminate_scope…
always allow an unprivileged system scope a_system_scope_registration_requires_root_on_a_user_capable_os
stop naming the offending path level a_refused_system_target_names_the_offending_directory_level
remove --scope from the stop verb every_service_verb_accepts_the_scope_flag_defaulting_to_auto
probe-gate EVERY scope (the defect the macOS smoke leg caught) a_requested_scope_is_removed_even_when_the_probe_cannot_see_it
probe-gate NO scope a_swept_scope_is_never_written_to_on_a_probe_that_sees_nothing, install_touches_nothing_at_the_other_scope_when_it_holds_no_registration

A false green was caught and fixed during that pass: contains(offending_path) is VACUOUS, because
every 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 both
sides) — the new code RAISES it:

service.rs regions service.rs lines workspace TOTAL regions
origin/main 72.61% 71.18% 67.28%
this branch 77.81% 76.28% 68.23%

(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 -- --check clean; cargo clippy -p dig-node-service --all-targets --all-features -- -D warnings clean.

Docs kept coherent in the same unit

  • SPEC.md §9.1 rewritten as the normative scope model (the per-scope on-disk locations, what makes
    a registration survive a reboot, the resolution rules, the migration + sweep contracts, the
    --scope default), §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 under sudo.
  • README.md — the operator-facing scope/--scope explanation.

Version

0.69.0 (minor — a new backwards-compatible capability; feat). Cargo.lock re-synced in the
same commit. No package.json in this repo.

NOT proven here

A real headless-Linux sudo dig-installer run: there is no Linux host in this lane, and an
elevation-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.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review July 31, 2026 15:07
MichaelTaylor3d and others added 4 commits July 31, 2026 08:15
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
MichaelTaylor3d force-pushed the feat/526-service-scope branch from 4d19fe1 to a4eaa67 Compare July 31, 2026 15:21
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant