Skip to content

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

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

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

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

Mirrors dig-node's scope-explicit service registration (dig_ecosystem#526) into dig-relay's src/service.rs: install/uninstall/start/stop all accept --scope <auto|system|user> (default auto, clap ValueEnum). Root cause: an elevated dig-installer install (--with-relay) previously always registered a user-level systemd unit / launchd agent, which has no session under sudo/root and therefore does not survive a reboot. --scope system (or auto as root) registers /etc/systemd/system/ + multi-user.target.wants/ (Linux) or /Library/LaunchDaemons + RunAtLoad (macOS) instead.

Contract

  • resolve_scope(choice: ScopeChoice, os_supports_user: bool, is_root: bool) -> ServiceScope — pure, no cfg!/geteuid()/filesystem inside it. AutoSystem if is_root or !os_supports_user (Windows), else User. Explicit System/User always win.
  • install best-effort deregisters the OTHER scope before registering at the resolved one (cross-scope migration), so a host upgrading from a prior user-level install doesn't end up with two registrations binding the relay's ports.
  • uninstall --scope auto as root removes the service at BOTH scopes, reporting per-scope success/failure (the uninstall_scopes pure helper decides this, same shape as resolve_scope).
  • No flag == auto == today's unelevated-user-level behaviour, unchanged.

Cross-check against dig-node's #526 PR

Could not cross-check — as of this PR, no dig-node PR for #526 exists yet (gh pr list --repo DIG-Network/dig-node --search "526" returns none; the only trace is a stale dn-526 worktree at dig-node's origin/main tip with zero diff). Implemented to the naming given in the task spec verbatim: ScopeChoice (Auto/System/User), ServiceScope (System/User), resolve_scope, --scope <auto|system|user>. This needs re-verification against dig-node's actual PR once it lands, since dig-installer emits one argument form for both components.

Blast radius checked (gitnexus disabled per CLAUDE.md §2.0 override — done via ripgrep + direct read)

  • src/service.rs: install/uninstall/start/stop signatures changed (added a ScopeChoice param) — the ONLY caller is src/main.rs's Command match arms, updated in this PR. grep -rn "service::install\|service::uninstall\|service::start\|service::stop" across the whole repo (excluding target/) confirms src/main.rs is the only call site.
  • Old manager() helper removed (superseded by manager_at(ServiceScope)); no other caller.
  • No wire/protocol file touched (src/wire.rs, src/pex.rs, GPL-2.0-vendored types untouched).
  • Risk: LOW — pure new decision logic + a signature change with exactly one call site, both covered by tests below.

Constraints honored

  • Table-driven resolve_scope_covers_every_combination test: all 12 (ScopeChoice x os_supports_user x is_root) rows, runs with zero privileges on any host (no #[cfg(unix)]-only guard).
  • uninstall_scopes_removes_both_only_for_auto_as_root isolates the ONE combination (Auto + root) that must widen to both scopes from every other combination that must not — varying only is_root.
  • No test requires actual root/Administrator to pass.
  • No decision logic added to src/bin (n/a here — this crate has no src/bin).
  • No vendored wire file touched.

Evidence

  • cargo fmt --check — clean.
  • cargo clippy --all-targets -- -D warnings — clean, zero warnings.
  • cargo test254 lib tests + all integration suites green (22 in service::tests, up from 17; the 5 new: resolve_scope_covers_every_combination, uninstall_scopes_removes_both_only_for_auto_as_root, other_scope_is_the_opposite, scope_label_matches_dig_node_wording, plus the table itself).
  • Load-bearing proof: committed the real resolve_scope, then reverted ONLY its body to a stub (ServiceScope::User unconditionally) via a plain file copy (not git checkout, per the destructive-revert HARD RULE) — both resolve_scope_covers_every_combination and uninstall_scopes_removes_both_only_for_auto_as_root failed for the right reason (Auto, os_supports_user=true, is_root=true) = User, expected System), then restored the real implementation and confirmed a byte-identical, zero-diff working tree.

Docs

SPEC.md §9 gains the normative scope-resolution contract; runbooks/configuration.md documents --scope + the elevated-install example.

Version

Cargo.toml 0.15.10.16.0 (minor — backwards-compatible new capability; no existing behaviour changes for a caller that never passes --scope).

Not merging — parent lane (dig_ecosystem#526) orchestrator gates + merges.


Review round 2 — the 6 blocking findings (5407e2f)

Fixed by adopting dig-node #123's contract verbatim wherever the two diverged, and by giving the
operations a way to express their own failure.

# Finding Fix Proving test
1 uninstall_scopes diverged unelevated Auto sweeps BOTH scopes at either privilege, resolved one first uninstall_scopes_sweeps_both_for_auto_at_either_privilege
2 uninstall reported success having removed nothing uninstall_outcomePermissionDenied (found-but-not-removed / indeterminate), NotFound (nothing removed) uninstall_outcome_fails_on_anything_less_than_a_complete_removal
3 registered polarity inverted unconditionally false on success; detail in removed_scopes + scopes[] uninstall_outcome_success_reports_not_registered
4 migration destroyed a working registration before a failable install pure ensure_privilege_for_scope evaluated BEFORE any side effect ensure_privilege_for_scope_refuses_only_unprivileged_system + install_deregisters_the_other_scope_before_creating
5 deregister_at neither probe-gated nor reported RemovalMode::Swept probe-gating over a real per-OS query_installed, result returned in result.migration + the summary; SPEC weakened to what a root-session probe can actually prove install_sweep_does_not_touch_a_scope_holding_nothing, install_reports_a_sweep_it_could_not_complete, a_swept_scope_with_an_unanswerable_probe_is_indeterminate
6 resolve_scope truth table diverged on Windows --scope user platform checked FIRST → resolves to system, not a hard error resolve_scope_covers_every_combination

Blast radius checked

src/service.rs only, plus its SPEC §9 contract text. Callers traced with ripgrep across the repo:
install/uninstall/start/stop/status are called from src/main.rs alone (the emit
envelope), and no consumer read the removed failed_scopes field or the private deregister_at.
The signature-visible surface (ScopeChoice, resolve_scope, Outcome) is unchanged; everything
added (ServiceBackend, InstallPlan, RemovalMode, remove_registration, install_at_scope,
uninstall_outcome, RelayServiceBackend) is new and private except ScopeRemoval.

Behaviour change a caller can see (intentional, and the point of findings 2/3): dig-relay uninstall now exits NON-ZERO when there was nothing to remove or when removal could not be
completed, where before it always exited 0. This matches dig-node #123. Any wrapper that runs an
unconditional dig-relay uninstall as a pre-step must tolerate a NotFound exit.

Verification

  • 32/32 service:: unit tests, 264 lib tests, every integration suite green locally.
  • Each of the 6 fixes mutation-proven load-bearing: reverting only that fix turns only its test
    RED — 7/7 mutations RED (including "sweep moved after create", which pins the ORDER rather than
    the outcome).
  • CI green on 5407e2f: build-test, coverage, deny, commitlint, version-increment, CodeQL (runs
    30664544796 / 30664542276 / 30664544763 / 30664544817).
  • No #[cfg(unix)]-gated scope tests: every decision is a pure function or runs against a mock
    backend, so the whole table executes on a Windows dev host and on the Linux runner alike.

Version stays 0.16.0 — already the feat-minor bump over main's 0.15.1; this round is
pre-merge review remediation inside the same unreleased feature.

MichaelTaylor3d and others added 2 commits July 31, 2026 07:11
Co-Authored-By: Claude <noreply@anthropic.com>
Mirrors dig-node's scope-explicit service registration: install/uninstall/start/stop
accept --scope <auto|system|user> (default auto, unchanged behaviour), resolved by a
pure resolve_scope(choice, os_supports_user, is_root) so an elevated dig-installer
install can register a reboot-surviving system-scope service instead of a user-level
one with no session under sudo. install cross-scope-migrates (best-effort deregisters
the other scope first); uninstall --scope auto as root removes both scopes.

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review July 31, 2026 14:31

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES-REQUIRED - reviewed head bada0bb4bc390d8a3e9f72eea8547a50467902b5

Gates are genuinely green for THIS sha (fmt/clippy/build-test/coverage/deny/CodeQL x3/commitlint/version-increment; runs exist, none skipped), zero prior review threads, no vendored wire file or GPL header touched, SERVICE_LABEL scoping correct, and the resolve_scope 12-row table is real, host-independent and load-bearing. Reboot survival also checks out as a MECHANISM: autostart: true + manager_at(System) on service-manager 0.7.1 reaches systemctl enable (the multi-user.target.wants symlink) / the launchd system domain - not merely a written unit file.

The blocker is the one thing this PR could not check when it was written: dig-node's #526 PR now exists (DIG-Network/dig-node#123, head 7c93cd9b) and the two surfaces DO NOT match.

Side-by-side

Surface dig-relay (this PR) dig-node #123 Match
Enum + variant names ScopeChoice{Auto,System,User}, ServiceScope{System,User} identical YES
Flag + value spelling --scope <auto / system / user>, default auto, clap ValueEnum identical YES
Flag placement global arg per-subcommand ScopeArg flatten install --scope system parses on BOTH, installer-safe; note only
Verbs carrying it install/uninstall/start/stop same YES
resolve_scope(Auto, true, root) System System YES
resolve_scope(Auto, true, !root) User User YES
resolve_scope(User, false, *) (Windows) User, then manager_at errors Unsupported System - platform short-circuits BEFORE the explicit choice NO
explicit system, unelevated, unix proceeds; fails deep inside systemctl ensure_privilege_for_scope -> Err(PermissionDenied) with actionable text NO
uninstall_scopes(Auto, true, !root) [User] [User, System] - auto always sweeps both NO
uninstall reporting Ok + exit 0 even when NOTHING was removed Err(PermissionDenied) on anything unresolved, Err(NotFound) when nothing removed NO
other-scope migration unconditional; every error silently swallowed PROBE-GATED and REPORTED as a ScopeRemoval NO
scope value in JSON "system" / "user" same YES

Vocabulary matches; semantics do not. Because dig-installer emits ONE argument form to both components, every NO row is a runtime-only behaviour split - precisely the failure class this mirror exists to prevent.

Six blocking findings posted inline, most-severe first, plus one non-gating note which I resolve myself.

Comment thread src/service.rs Outdated
is_root: bool,
) -> Vec<ServiceScope> {
if choice == ScopeChoice::Auto && is_root {
vec![ServiceScope::System, ServiceScope::User]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER 1 - uninstall_scopes diverges from dig-node in the DEFAULT unelevated case.

Here Auto + unelevated returns [User]. dig-node #123's uninstall_scopes returns [User, System]: auto sweeps BOTH scopes regardless of privilege, and its test asserts uninstall_scopes(Auto, true, false) == vec![User, System] with the comment "auto sweeps BOTH scopes so an uninstall can never leave a registration behind".

Concrete failure: dig-installer (elevated) registered dig-relay at system scope. Later a plain dig-relay uninstall - no flag, unelevated, which is what an uninstall script or an operator actually runs - resolves to [User] only, finds nothing there, and exits 0 reporting success. The system unit survives, keeps auto-starting at every boot, and keeps binding 9450/9451. dig-node given the identical command sweeps the system scope and fails loudly if it cannot remove it.

The test at line 754 hard-codes the divergent row (Auto, true, false -> vec![User]), so the suite currently DEFENDS the divergence instead of catching it.

BLOCKS merge.

Comment thread src/service.rs Outdated

let summary = if failed.is_empty() {
format!(
"dig-relay: uninstalled service \"{SERVICE_LABEL}\" ({} scope(s): {})",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER 2 - uninstall reports SUCCESS having removed nothing.

Every per-scope failure is collected into failed and the function still returns Ok(Outcome). src/main.rs:326 emit maps Ok to ExitCode::SUCCESS and prints an ok:true envelope. So an uninstall where the registration was found but could not be deleted (unelevated, against a system unit), or where nothing existed at all, is indistinguishable from a clean removal for any caller gating on the exit code or on ok.

Three distinct problems:

  1. Contract divergence - dig-node's uninstall_outcome returns Err(PermissionDenied) for anything found-but-not-removed or indeterminate, and Err(NotFound) when nothing was removed: "an uninstall that leaves a registration behind - or cannot tell whether it did - must never report success".
  2. Regression vs v0.15.1, which did mgr.uninstall(...)? and propagated the error as a non-zero exit. This PR silently converts a previously-failing uninstall into a success; the PR body's backwards-compatibility claim ("no existing behaviour changes for a caller that never passes --scope") does not hold for uninstall.
  3. SPEC.md self-contradiction (CLAUDE.md 4.2) - the new SPEC section 9 bullet states that a registration left behind "is a defect ... not an acceptable partial removal": a MUST this code cannot satisfy, because it returns success in exactly that state.

Note also that uninstall's reporting has NO test at all, which is why the defect is green.

BLOCKS merge.

Comment thread src/service.rs Outdated
summary,
json!({
"installed": false,
"registered": !removed.is_empty(),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER 3 - the uninstall JSON registered field has inverted polarity.

"registered": !removed.is_empty() means that after a SUCCESSFUL uninstall (removed non-empty) the envelope reports registered: true alongside installed: false; when nothing was removed it reports registered: false. At v0.15.1 the field was unconditionally "registered": false. Any consumer of the --json envelope (the stable machine contract, CLAUDE.md 6.2) now reads "still registered" precisely when removal WORKED.

The value actually being computed is "something was removed", which removed_scopes already carries.

BLOCKS merge - a one-line polarity bug on a machine-readable contract.

Comment thread src/service.rs Outdated

let (mgr, user_level) = manager()?;
let resolved = resolve_scope(scope, PREFERS_USER_LEVEL, is_root());
deregister_at(other_scope(resolved));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER 4 - the migration destroys a WORKING registration before an install that can still fail.

deregister_at(other_scope(resolved)) runs unconditionally BEFORE manager_at(resolved)? and before mgr.install(...)?, and dig-relay has no equivalent of dig-node's ensure_privilege_for_scope. So on Linux/macOS an unelevated dig-relay install --scope system - an operator following the new runbook text, or any wrapper that passes the flag - will (1) stop and uninstall the user-scope unit that was working, then (2) fail inside service-manager writing /etc/systemd/system with a raw EACCES. The host ends with NO registration at all, from a command that should have refused before any side effect. dig-node returns Err(PermissionDenied) with actionable text and leaves everything intact.

This is a denial primitive introduced by a migration feature - side-effect ordering, not a papercut. Fix shape: a pure ensure_privilege_for_scope(resolved, PREFERS_USER_LEVEL, is_root()) evaluated BEFORE any side effect (mirroring dig-node, table-testable), and perform the other-scope removal only once the new registration is provably creatable.

BLOCKS merge.

Comment thread src/service.rs
label: label().expect("SERVICE_LABEL is a constant, always parses"),
});
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER 5 - deregister_at is neither probe-gated nor reported, and is a silent no-op in the one scenario it exists for.

  1. Not reported. Every error is discarded (if let Ok(mgr), let _ = ...). dig-node's SPEC mandates the opposite - "any removal at a scope the caller did not name MUST be probe-gated, so a scope nobody asked about is never written to" - and it returns a ScopeRemoval so the migration outcome reaches the caller. Here an elevated install that could NOT clear the old registration reports unqualified success and leaves two registrations racing for 9450/9451: exactly the state the migration exists to prevent. The bar was "non-fatal but REPORTED"; this is non-fatal and invisible.

  2. Root cannot see the user scope. Under sudo, manager_at(ServiceScope::User) calls set_level(User), so service-manager issues systemctl --user / launchctl ... gui/<uid> - which addresses ROOT'S OWN user domain, never the desktop user's session (root has no systemd --user / D-Bus session). For the headline scenario - an elevated installer migrating a host that previously had a user-level unit - the migration therefore removes NOTHING, and the stale user-level registration can still win resolution or double-bind the ports. Relocating closes nothing if the old location still wins.

Doc consequence: the new SPEC section 9 claim that install "best-effort deregisters the service at the OTHER scope ... so a host upgrading from a prior install at the other scope never ends up with two registrations both binding the relay's ports" is unsatisfiable as written (CLAUDE.md 4.2). Either implement a scope probe that works from root (inspect /etc/systemd/system versus the real user's ~/.config/systemd/user, or enumerate sessions via loginctl), or weaken the SPEC to what the code can prove - and surface the migration result in the Outcome either way.

BLOCKS merge.

Comment thread src/service.rs
}
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER 6 - the resolve_scope truth table differs from dig-node on the two (User, os_supports_user=false) rows.

dig-node checks the platform FIRST (an early return ServiceScope::System when os_supports_user is false), so an explicit --scope user on Windows resolves to System, documented there as "there is exactly one scope, so even an explicit --scope user cannot be honoured" and asserted in its own table. dig-relay honours the explicit User, and manager_at (line 123) then converts it into Err(ErrorKind::Unsupported).

Result: dig-node install --scope user on Windows installs at system scope; dig-relay install --scope user on Windows hard-fails. One installer argument form, two outcomes, detectable only at runtime. The relay test at line 749 ("explicit User wins even where the OS has no user manager") asserts the divergent row, locking it in.

Adopt dig-node's ordering (it is the mirrored contract, and it is the non-failing one for an installer), fix that table row, and update SPEC.md section 9, which currently documents the relay semantics. Any rename/reorder in this module must be call-graph-aware, not find-and-replace.

BLOCKS merge.

Comment thread src/main.rs
…n partial uninstall

Resolves the six blocking review findings on #20 by adopting dig-node #123's
scope contract verbatim where the two diverged, and by making the operations
able to express their own failure.

- resolve_scope checks the platform FIRST, so an explicit --scope user on
  Windows resolves to system instead of hard-failing (one installer argument
  form, one outcome on every OS).
- uninstall_scopes sweeps BOTH scopes under auto at either privilege level: an
  unelevated `dig-relay uninstall` on a host registered at system scope must
  still address that system unit.
- install refuses a scope the caller cannot register at (ensure_privilege_for_scope,
  pure) BEFORE any side effect, so an unprivileged --scope system no longer
  destroys a working registration ahead of a create that cannot succeed.
- The cross-scope sweep is probe-gated and its result is REPORTED in
  result.migration; the named scope is still removed unconditionally, since the
  OS delete is authoritative and the probe can false-negative from a root session.
- uninstall returns PermissionDenied for found-but-not-removed or indeterminate
  and NotFound when nothing was removed; on success the JSON reports
  registered: false (the inverted polarity is gone).

Closes #526

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