Forráskód Böngészése

Merge branch 'usmrad' of Tilo15/usm into master

Billy Barrow 2 hete
szülő
commit
32cd0eac7f

+ 1 - 0
.gitignore

@@ -4,3 +4,4 @@
 /installer/dist
 __pycache__/
 *.pyc
+*.image.tar*

+ 1 - 1
MANIFEST.usm

@@ -1,6 +1,6 @@
 {
   "name": "usm",
-  "version": "0.0.1",
+  "version": "0.1.2",
   "summary": "Universal Source Manifest",
   "licences": [ {"name": "GPLv3", "category": "libre", "text": "src/LICENSE"} ],
   "flags": ["ninjaStyleProgress"],

+ 47 - 1
README.md

@@ -509,4 +509,50 @@ podman load -i <name>-<version>.image.tar.xz
 podman run --rm <name>:<version>
 ```
 
-The artifact is `<name>-<version>.image.tar.xz` next to the deploy context; `podman load -i` accepts the xz-compressed archive directly. Tag characters outside `[A-Za-z0-9_.-]` (usm release suffixes like `+`, for instance) are mapped to dashes in the tag; the artifact filename keeps the unsanitised version.
+The artifact is `<name>-<version>.image.tar.xz` next to the deploy context; `podman load -i` accepts the xz-compressed archive directly. Tag characters outside `[A-Za-z0-9_.-]` (usm release suffixes like `+`, for instance) are mapped to dashes in the tag; the artifact filename keeps the unsanitised version.
+
+## usmrad — Rapid Application Deployment
+
+`usmrad` is a dev-loop supervisor for applications managed by USM manifests: point it at a git branch and it builds and installs the app into A/B slots under an application directory, runs the active instance, and swaps slots when new commits (or updated upstream USM packages) land — keeping the previous instance running when a build fails. It is configured entirely through the environment:
+
+| Variable | Meaning | Default |
+|---|---|---|
+| `USMRAD_REPO` | git repo URL (required) | — (usage error without it) |
+| `USMRAD_BRANCH` | git branch | `master` |
+| `USMRAD_POLL` | poll interval in minutes; fractional accepted (`0.25` = 15 s) | `0` = disabled |
+| `USMRAD_APPDIR` | application directory | `/app` |
+| `USMRAD_USM_REPOS` | semicolon-delimited `.usmr` URIs (http(s) URLs or local paths, same forms `usm add-repo` accepts) | empty = repos baked into the image only |
+| `USMRAD_TERM_GRACE` | seconds between SIGTERM and SIGKILL when terminating an instance | `10` |
+
+Invocation is `usmrad <exec> [args...]`: `<exec>` is resolved inside the active slot, everything after it is passed verbatim to the executable, which runs with cwd `<appdir>/pwd` and usmrad's inherited environment. The layout under the appdir:
+
+```
+<appdir>/a                install root A          (USM_DESTDIR target)
+<appdir>/b                install root B
+<appdir>/pwd              app working directory   (cwd of the launched exec)
+<appdir>/repo             git checkout, owned by usmrad
+<appdir>/build            persistent usm build path (outside the repo so it
+                          survives reset/clean → incremental rebuilds)
+<appdir>/current          symlink → a|b           (the active slot)
+<appdir>/deployed-commit  last successfully deployed commit SHA
+```
+
+Semantics:
+
+- All output — usm runs, build logs, app stdout/stderr — streams to the container logs
+- Rebuild triggers are new commits on the branch or updated dependent packages in upstream USM repositories
+- A failed build or install keeps the previous instance running; with no previous instance to fall back to, usmrad exits 251
+- When the app exits — for any reason, polling or not — usmrad exits with the child's exit code; restarts belong to the container supervisor
+- SPM integration rides the configured helper (`usm-spm-apk` in the Alpine container), so dependencies may come from `apk` as well as USM repositories
+
+An image with `usmrad` as entrypoint is built from `containers/usmrad/Containerfile`: the hosted installer at `https://packages.astrologue.nz/install-usm.sh` (downloaded to a file and run as `bash <file> -y` — the installer is a bash script, so `sh` only works where `sh` is bash — never `curl | sh`, whose pipe path re-execs against `/dev/tty`) brings in USM, usmrad and the apk SPM wiring; the image enrolls and adds the `web-stack` repository at build time so its packages resolve out of the box. The run pattern (USMRAD_USM_REPOS only needed for repositories beyond web-stack):
+
+```bash
+podman run --rm \
+  -e USMRAD_REPO=https://git.example/my-app.git \
+  -e USMRAD_BRANCH=main \
+  -e USMRAD_POLL=1 \
+  -e USMRAD_USM_REPOS='https://packages.astrologue.nz/repo/web-stack/repo.usmr' \
+  -v ./data:/app/pwd \
+  usmrad my-app --port 8080
+```

+ 35 - 0
containers/usmrad-fedora/Containerfile

@@ -0,0 +1,35 @@
+# usmrad — Rapid Application Deployment supervisor image (Fedora).
+#
+# Build context: the repo root (nothing local is required — USM and usmrad
+# both arrive via the hosted installer, which bundles usmrad):
+#
+#   podman build -f containers/usmrad-fedora/Containerfile -t usmrad-fedora .
+#
+# git is required by usmrad at runtime and curl for https fetches; the
+# installer carries bash itself. The installer auto-detects dnf on PATH,
+# wires the usm-spm-dnf helper plus the lib64 libdir into the generated
+# usm.config, and shims both usm and usmrad into /usr/bin.
+#
+# The system is enrolled and the web-stack repository added at build time so
+# its packages (statum, astralis, inversion, the invercargill libraries, …)
+# resolve immediately; USMRAD_USM_REPOS adds further repositories at runtime.
+#
+# Run pattern:
+#
+#   podman run --rm \
+#     -e USMRAD_REPO=https://git.example/my-app.git \
+#     -e USMRAD_BRANCH=main \
+#     -e USMRAD_POLL=1 \
+#     -v ./data:/app/pwd \
+#     usmrad-fedora my-app --port 8080
+
+FROM fedora:latest
+# python3-dnf is the SPM helper's container bootstrap (README: System
+# package manager integration) — the base image ships no dnf python module
+RUN dnf install -y git curl ca-certificates python3-dnf && dnf clean all
+RUN curl -fsSL https://packages.astrologue.nz/install-usm.sh -o /tmp/install-usm.sh \
+    && bash /tmp/install-usm.sh -y \
+    && rm /tmp/install-usm.sh
+RUN usm enroll -y \
+    && usm add-repo -y https://packages.astrologue.nz/repo/web-stack/repo.usmr
+ENTRYPOINT ["usmrad"]

+ 33 - 0
containers/usmrad/Containerfile

@@ -0,0 +1,33 @@
+# usmrad — Rapid Application Deployment supervisor image.
+#
+# Build context: the repo root (nothing local is required — USM and usmrad
+# both arrive via the hosted installer, which bundles usmrad):
+#
+#   podman build -f containers/usmrad/Containerfile -t usmrad .
+#
+# bash is required by usm scripts, git by usmrad at runtime, and curl +
+# ca-certificates for https fetches. The installer auto-detects apk on PATH,
+# wires the usm-spm-apk helper plus the lib libdir into the generated
+# usm.config, and shims both usm and usmrad into /usr/bin.
+#
+# The system is enrolled and the web-stack repository added at build time so
+# its packages (statum, astralis, inversion, the invercargill libraries, …)
+# resolve immediately; USMRAD_USM_REPOS adds further repositories at runtime.
+#
+# Run pattern:
+#
+#   podman run --rm \
+#     -e USMRAD_REPO=https://git.example/my-app.git \
+#     -e USMRAD_BRANCH=main \
+#     -e USMRAD_POLL=1 \
+#     -v ./data:/app/pwd \
+#     usmrad my-app --port 8080
+
+FROM alpine:latest
+RUN apk add --no-cache bash git curl ca-certificates
+RUN curl -fsSL https://packages.astrologue.nz/install-usm.sh -o /tmp/install-usm.sh \
+    && bash /tmp/install-usm.sh -y \
+    && rm /tmp/install-usm.sh
+RUN usm enroll -y \
+    && usm add-repo -y https://packages.astrologue.nz/repo/web-stack/repo.usmr
+ENTRYPOINT ["usmrad"]

+ 34 - 16
installer/build_config.sh

@@ -184,6 +184,28 @@ build_component() {
     esac
 }
 
+# Install one shim: a /usr/bin wrapper exporting the bundled lib directory
+# and exec'ing the named binary from target_dir
+write_shim() {
+    local shim_path="$1"
+    local binary_name="$2"
+    local target_dir="$3"
+    local sudo="$4"
+
+    local shim_content="#!/bin/bash
+export LD_LIBRARY_PATH=\"${target_dir}/lib\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}\"
+exec \"${target_dir}/bin/${binary_name}\" \"\$@\"
+"
+
+    if [[ -n "$sudo" ]]; then
+        echo "$shim_content" | $sudo tee "$shim_path" >/dev/null
+        $sudo chmod +x "$shim_path"
+    else
+        echo "$shim_content" > "$shim_path"
+        chmod +x "$shim_path"
+    fi
+}
+
 # Install shim to /usr/bin
 install_shim() {
     local target_dir="$1"
@@ -191,27 +213,23 @@ install_shim() {
     local config_dir="/etc/usm"
     local config_path="$config_dir/usm.config"
     local sudo=""
-    
+
     if ! is_root; then
         sudo=$(get_sudo)
     fi
-    
+
     log_step "Installing shim to $shim_path..."
-    
-    local shim_content="#!/bin/bash
-export LD_LIBRARY_PATH=\"${target_dir}/lib\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}\"
-exec \"${target_dir}/bin/usm\" \"\$@\"
-"
-    
-    if [[ -n "$sudo" ]]; then
-        echo "$shim_content" | $sudo tee "$shim_path" >/dev/null
-        $sudo chmod +x "$shim_path"
-    else
-        echo "$shim_content" > "$shim_path"
-        chmod +x "$shim_path"
-    fi
-    
+
+    write_shim "$shim_path" "usm" "$target_dir" "$sudo"
+
     log_info "Shim installed to $shim_path"
+
+    # usmrad ships in the same payload; shim it only when the binary is
+    # present so older payloads (without src/rad) still install cleanly
+    if [[ -x "${target_dir}/bin/usmrad" ]]; then
+        write_shim "/usr/bin/usmrad" "usmrad" "$target_dir" "$sudo"
+        log_info "usmrad shim installed to /usr/bin/usmrad"
+    fi
     
     # Generate USM configuration file only if it doesn't already exist
     if [[ -f "$config_path" ]]; then

+ 2 - 2
installer/ui.sh

@@ -126,7 +126,7 @@ show_installation_summary() {
     print_list_item "3" "USM - Universal Source Manifest"
     
     echo ""
-    echo -e "${YELLOW}Step $step_num:${NC} Create shim at /usr/bin/usm"
+    echo -e "${YELLOW}Step $step_num:${NC} Create shims at /usr/bin (usm, plus usmrad when bundled)"
     
     echo ""
     if ! confirm "Do you want to continue with the installation?" "n"; then
@@ -144,7 +144,7 @@ show_completion() {
     echo -e "${GREEN}✓ Installation Complete!${NC}"
     echo ""
     echo -e "  ${CYAN}Binary:${NC}     ${target_dir}/bin/usm"
-    echo -e "  ${CYAN}Shim:${NC}       /usr/bin/usm"
+    echo -e "  ${CYAN}Shims:${NC}      /usr/bin/usm, /usr/bin/usmrad (when bundled)"
     echo ""
     echo -e "Run ${BOLD}usm --help${NC} to get started."
     echo ""

+ 65 - 2
spm/apk/usm-spm-apk

@@ -36,6 +36,14 @@
 # "not-found" (documented limitation, the same offline stance as the
 # emerge helper).
 #
+# Every package object also carries a "conflicts" array: apk has no
+# standalone conflict field, but dependency blockers ("!virtual", e.g.
+# dropbear-scp depends on !openssh-client) name the provider families
+# the package cannot coexist with. `apk info --depends <pkg>` (which,
+# with a primed index cache, also answers for NOT-YET-INSTALLED
+# packages) lists them, and each blocker virtual is resolved to concrete
+# provider names with the same exact-provides search used for refs.
+#
 # apk has no per-download progress output on a non-tty stdout; it
 # prints one "(k/N) Installing pkg (ver)" line per package instead, so
 # "package" events are emitted at package-completion granularity
@@ -132,10 +140,40 @@ sp_search() {
     fi
 }
 
+sp_info() {
+    if [ "$PRIMED" = 1 ]; then
+        apk info --cache-dir "$CACHE" "$@"
+    else
+        apk info --no-cache "$@"
+    fi
+}
+
 
+# apk prefixes diagnostics with "ERROR:"/"WARNING:" and, for solver
+# failures, follows the headline with indented detail lines naming the
+# actual conflicting packages ("conflicts:", "breaks:", "satisfies:").
+# Return the headline plus those details (up to 8, joined with "; ") on
+# one line so error events carry the whole report instead of only its
+# first line.
 apk_diag_line() {
     [ -r "$1" ] || return 0
-    sed -n 's/^\(ERROR\|WARNING\):[[:space:]]*//p' "$1" | head -n 1
+    awk '
+        /^(ERROR|WARNING):[[:space:]]*/ && diag == "" {
+            sub(/^(ERROR|WARNING):[[:space:]]*/, "")
+            diag = $0
+            details = 0
+            next
+        }
+        diag != "" && details < 8 && /^[[:space:]]+[^[:space:]]/ {
+            line = $0
+            sub(/^[[:space:]]+/, "", line)
+            diag = diag "; " line
+            details++
+            next
+        }
+        diag != "" && /^[^[:space:]]/ { exit }
+        END { if (diag != "") print diag }
+    ' "$1"
 }
 
 strip_version() {
@@ -304,6 +342,30 @@ counts_for() {
     printf '%s %s\n' "$_cf_dep" "$_cf_inst"
 }
 
+# Print the "conflicts" JSON array for a package: apk's dependency
+# blockers ("!virtual") resolve to the concrete provider names the
+# package cannot coexist with (e.g. dropbear-scp blocks every provider
+# of openssh-client). One `apk info --depends` plus one exact search per
+# blocker, only in query mode; empty array when nothing conflicts.
+conflicts_for() {
+    _cq_name=$1
+    _cq_deps=$(sp_info --depends -- "$_cq_name" 2>"$WORK/cf.err" || true)
+    printf '%s\n' "$_cq_deps" \
+        | sed -n 's/^[[:space:]]*!\([^[:space:]=<>~]*\).*/\1/p' >"$WORK/cfblk"
+    while IFS= read -r _cq_blk; do
+        [ -n "$_cq_blk" ] || continue
+        sp_search -x -- "$_cq_blk" 2>>"$WORK/cf.err" || true
+    done <"$WORK/cfblk" | strip_version | sort -u >"$WORK/cfn"
+    _cq_json=""
+    _cq_first=1
+    while IFS= read -r _cq_c; do
+        [ -n "$_cq_c" ] || continue
+        if [ "$_cq_first" = 1 ]; then _cq_first=0; else _cq_json="$_cq_json,"; fi
+        _cq_json="$_cq_json\"$(json_escape "$_cq_c")\""
+    done <"$WORK/cfn"
+    printf '[%s]' "$_cq_json"
+}
+
 cmd_query() {
     [ $# -ge 1 ] || die_usage
     mkwork
@@ -341,7 +403,8 @@ cmd_query() {
         _q_cnts=$(counts_for "$_q_nm")
         _q_dep=${_q_cnts%% *}
         _q_inst=${_q_cnts##* }
-        _q_out="$_q_out{\"name\":\"$(json_escape "$_q_nm")\",\"resources\":[${_q_res}],\"dependency-count\":$_q_dep,\"installed-dependency-count\":$_q_inst}"
+        _q_conf=$(conflicts_for "$_q_nm")
+        _q_out="$_q_out{\"name\":\"$(json_escape "$_q_nm")\",\"resources\":[${_q_res}],\"dependency-count\":$_q_dep,\"installed-dependency-count\":$_q_inst,\"conflicts\":$_q_conf}"
     done
     _q_out="$_q_out]}"
     emit "$_q_out"

+ 82 - 3
spm/apt/usm-spm-apt

@@ -126,6 +126,7 @@ UNPACK_LINE = re.compile(r"^Unpacking\s+(\S+)")
 SETUP_LINE = re.compile(r"^Setting up\s+(\S+)")
 INST_LINE = re.compile(r"^Inst\s+(\S+)")
 ERROR_LINE = re.compile(r"^E:\s+")
+CONFLICTS_FIELD = re.compile(r"^(Conflicts|Breaks):\s*(.*)$", re.IGNORECASE)
 
 
 class HelperError(Exception):
@@ -319,6 +320,60 @@ def dpkg_installed_names():
     return set(process.stdout.split())
 
 
+def collect_conflict_names(value, names):
+    """Fold one Conflicts:/Breaks: field value into the names set.
+
+    Values are comma-separated lists whose entries may carry version
+    constraints ("foo (<< 2)"), alternations ("foo | bar") and multiarch
+    qualifiers ("foo:i386"); only the bare names are kept.
+    """
+    for entry in value.split(","):
+        for alternative in entry.split("|"):
+            name = alternative.split("(", 1)[0].strip()
+            if name:
+                names.add(plain_name(name))
+
+
+def parse_conflict_names(text):
+    """Bare names from every Conflicts:/Breaks: field of show output.
+
+    apt-cache show may print several stanzas (candidate plus installed
+    version); all fields are unioned, and folded continuation lines
+    (leading whitespace) belong to the field above them.
+    """
+    names = set()
+    field = None
+    for line in text.splitlines():
+        match = CONFLICTS_FIELD.match(line)
+        if match is not None:
+            field = match.group(1)
+            collect_conflict_names(match.group(2), names)
+        elif field is not None and line[:1] in (" ", "\t") and line.strip():
+            collect_conflict_names(line.strip(), names)
+        else:
+            field = None
+    return sorted(names)
+
+
+def apt_conflicts(name):
+    """The package's conflict names from `apt-cache show` metadata.
+
+    One read-only metadata query per candidate package, query mode only;
+    any failure or timeout degrades to [] so the contract member stays
+    present.
+    """
+    try:
+        process = subprocess.run(
+            ["apt-cache", "show", name],
+            stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+            universal_newlines=True, env=apt_environment(), timeout=30)
+    except (OSError, subprocess.SubprocessError):
+        return []
+    if process.returncode != 0:
+        return []
+    return parse_conflict_names(process.stdout)
+
+
 def build_solo_cache():
     """An apt cache whose dpkg status is empty (nothing installed).
 
@@ -475,6 +530,7 @@ def run_query(args):
             "resources": candidates[name]["resources"],
             "dependency-count": dependency_count,
             "installed-dependency-count": installed_dependency_count,
+            "conflicts": apt_conflicts(name),
         })
 
     sys.stdout.write(json.dumps({
@@ -502,9 +558,32 @@ def classify_apt_failure(output):
     return EXIT_TRANSACTION
 
 
-def collect_error_messages(output, limit=5):
-    messages = [line.strip() for line in output.splitlines()
-                if ERROR_LINE.match(line)]
+def collect_error_messages(output, limit=8):
+    """E: lines plus the indented dependency detail they summarize.
+
+    apt reports solver failures as a "The following packages have unmet
+    dependencies:" block whose per-package lines are indented, followed
+    by the final E: lines. Keeping only the E: lines would hide the
+    actual conflicts, so the detail block is folded in, all joined with
+    "; " and clipped to the last `limit` messages.
+    """
+    messages = []
+    in_detail = False
+    for line in output.splitlines():
+        stripped = line.strip()
+        if ERROR_LINE.match(line):
+            messages.append(stripped)
+            in_detail = False
+        elif "have unmet dependencies" in line:
+            messages.append(stripped)
+            in_detail = True
+        elif in_detail:
+            if not stripped:
+                continue
+            if line[:1] in (" ", "\t"):
+                messages.append(stripped)
+            else:
+                in_detail = False
     return "; ".join(messages[-limit:])
 
 

+ 23 - 0
spm/dnf/usm-spm-dnf

@@ -231,6 +231,28 @@ def closure_counts(closure_base, installed_keys, package, closure_cache):
     return counts
 
 
+def conflicts_for(package):
+    """Names this package conflicts with (rpm Conflicts metadata).
+
+    The sack's primary metadata already carries the conflicts tags that
+    `dnf repoquery --conflicts` prints, so this is an in-process read
+    with no extra repository query or timeout. Version constraints are
+    dropped and arch qualifiers ("glib2(x86-64)") reduced to bare names;
+    anything hawkey cannot name (rich/boolean dependencies) or any
+    surprise degrades to [].
+    """
+    names = set()
+    try:
+        for reldep in package.conflicts:
+            name = getattr(reldep, "name", None)
+            if not name:
+                continue
+            names.add(name.split("(", 1)[0])
+    except Exception:
+        return []
+    return sorted(names)
+
+
 def cmd_plan(args):
     """Resolve the material install set without touching the system.
 
@@ -313,6 +335,7 @@ def run_query(args):
             "resources": entry["resources"],
             "dependency-count": dependency_count,
             "installed-dependency-count": installed_dependency_count,
+            "conflicts": conflicts_for(entry["package"]),
         })
 
     sys.stdout.write(json.dumps({

+ 13 - 0
spm/emerge/usm-spm-emerge

@@ -433,6 +433,18 @@ def pretend_counts(atom, cache):
     return counts
 
 
+def conflicts_for(name):
+    """Always [] -- Gentoo conflict metadata is not cheaply queryable.
+
+    Portage records conflicts only as "!cat/pkg" blockers inside ebuild
+    DEPEND/RDEPEND metadata, which Portage exposes per-ebuild through
+    its metadata cache; there is no single inexpensive query for them
+    across candidates. The contract's "conflicts" member is therefore
+    emitted as an empty array here (documented limitation).
+    """
+    return []
+
+
 def cmd_query(args):
     try:
         return run_query(args)
@@ -494,6 +506,7 @@ def run_query(args):
             "resources": candidates[name]["resources"],
             "dependency-count": dependency_count,
             "installed-dependency-count": installed_dependency_count,
+            "conflicts": conflicts_for(name),
         })
 
     sys.stdout.write(json.dumps({

+ 86 - 10
spm/pacman/usm-spm-pacman

@@ -26,6 +26,14 @@
 #                 `pacman -Qo`), the same offline stance as the emerge
 #                 helper. No package-name heuristics are used.
 #
+# Every package object also carries a "conflicts" array, parsed from the
+# same sync databases' %CONFLICTS% stanzas (the field `pacman -Si`
+# prints as "Conflicts With :") during the single index pass, with the
+# installed database's desc files scanned the same way so candidates
+# known only locally are covered too. Entries may carry a "=version"
+# suffix (e.g. "shadow=4.14.0"); the suffix is stripped and the bare
+# name kept, as with soname provides.
+#
 # dependency-count / installed-dependency-count model "a solo install on
 # a from-scratch machine": the closure comes from `pacman -S --print`
 # run against a throwaway --dbpath whose local database is empty and
@@ -127,13 +135,33 @@ map_add() {
 # "=major-arch" suffix is stripped from soname provides so lib: refs
 # carrying the full soname match exactly), $WORK/filemap maps
 # "path-without-leading-slash<TAB>package" from the %FILES% sections of
-# the .files databases. Both stay empty (and the lookups degrade to the
-# installed database) when the databases are missing.
+# the .files databases, and $WORK/conflicts maps
+# "package<TAB>conflict" from the %CONFLICTS% sections of the sync .db
+# files and the installed database's desc files. All stay empty (and the
+# lookups degrade to the installed database) when the databases are
+# missing.
 build_repo_index() {
     [ "$REPO_INDEXED" = 0 ] || return 0
     REPO_INDEXED=1
     : >"$WORK/provides"
     : >"$WORK/filemap"
+    : >"$WORK/conflicts"
+    # installed packages' conflicts come from the local database's desc
+    # files (same %CONFLICTS% stanza as the sync dbs), covering
+    # candidates the sync databases do not know; one pass over all desc
+    # files, no bsdtar needed
+    cat /var/lib/pacman/local/*/desc 2>/dev/null | awk -v conflfile="$WORK/conflicts" '
+        /^%NAME%$/      { mode = "name";  next }
+        /^%CONFLICTS%$/ { mode = "confl"; next }
+        /^%/            { mode = "";      next }
+        NF == 0         { next }
+        mode == "name"  { pkg = $1; mode = ""; next }
+        mode == "confl" && pkg != "" {
+            c = $1
+            sub(/=.*$/, "", c)
+            print pkg "\t" c >> conflfile
+        }
+    ' || true
     if ! command -v bsdtar >/dev/null 2>&1; then
         warn "bsdtar not found in PATH; repository lookups are unavailable"
         return 0
@@ -142,17 +170,23 @@ build_repo_index() {
     for _ri_db in "$SYNC_DIR"/*.db; do
         [ -f "$_ri_db" ] || continue
         _ri_any=1
-        bsdtar -xOf "$_ri_db" 2>>"$WORK/index.err" | awk '
-            /^%NAME%$/     { mode = "name"; next }
-            /^%PROVIDES%$/ { mode = "prov";  next }
-            /^%/           { mode = "";      next }
-            NF == 0        { next }
-            mode == "name" { pkg = $1; mode = ""; next }
+        bsdtar -xOf "$_ri_db" 2>>"$WORK/index.err" | awk -v conflfile="$WORK/conflicts" '
+            /^%NAME%$/      { mode = "name";  next }
+            /^%PROVIDES%$/  { mode = "prov";  next }
+            /^%CONFLICTS%$/ { mode = "confl"; next }
+            /^%/            { mode = "";      next }
+            NF == 0         { next }
+            mode == "name"  { pkg = $1; mode = ""; next }
             mode == "prov" && pkg != "" {
                 p = $1
                 sub(/=.*$/, "", p)
                 print pkg "\t" p
             }
+            mode == "confl" && pkg != "" {
+                c = $1
+                sub(/=.*$/, "", c)
+                print pkg "\t" c >> conflfile
+            }
         ' >>"$WORK/provides" || true
     done
     for _ri_db in "$SYNC_DIR"/*.files; do
@@ -197,6 +231,26 @@ repo_file_prefix_owners() {
         "$WORK/filemap" 2>/dev/null
 }
 
+repo_conflict_owners() {
+    awk -F'\t' -v p="$1" '$1 == p { print $2 }' "$WORK/conflicts" 2>/dev/null
+}
+
+# Print the "conflicts" JSON array for a package: the bare names its
+# %CONFLICTS% stanza lists in the sync/local databases (query mode
+# only; empty array when nothing conflicts or no database knows the
+# package).
+conflicts_for() {
+    _cq_name=$1
+    build_repo_index
+    _cq_json=""
+    _cq_first=1
+    for _cq_c in $(repo_conflict_owners "$_cq_name" | sort -u); do
+        if [ "$_cq_first" = 1 ]; then _cq_first=0; else _cq_json="$_cq_json,"; fi
+        _cq_json="$_cq_json\"$(json_escape "$_cq_c")\""
+    done
+    printf '[%s]' "$_cq_json"
+}
+
 # Map every owner name read from STDIN onto _om_ref; succeeds when at
 # least one name arrived.
 owners_to_map() {
@@ -210,9 +264,30 @@ owners_to_map() {
     [ "$_om_any" = 1 ]
 }
 
+# pacman prefixes diagnostics with "error:" and, for solver failures,
+# follows the headline with ":: " detail lines naming the conflicting
+# packages (":: dropbear and openssh are in conflict"). Return the
+# headline plus those details (up to 8, joined with "; ") on one line so
+# error events carry the whole report instead of only its first line.
 pacman_diag_line() {
     [ -r "$1" ] || return 0
-    sed -n 's/^[[:space:]]*error:[[:space:]]*//p' "$1" | head -n 1
+    awk '
+        /^[[:space:]]*error:[[:space:]]*/ && diag == "" {
+            sub(/^[[:space:]]*error:[[:space:]]*/, "")
+            diag = $0
+            details = 0
+            next
+        }
+        diag != "" && details < 8 && /^[[:space:]]*::[[:space:]]*/ {
+            line = $0
+            sub(/^[[:space:]]*::[[:space:]]*/, "", line)
+            diag = diag "; " line
+            details++
+            next
+        }
+        diag != "" && /^[[:space:]]+[^:[:space:]]/ { exit }
+        END { if (diag != "") print diag }
+    ' "$1"
 }
 
 record_providers() {
@@ -450,7 +525,8 @@ cmd_query() {
         _q_cnts=$(counts_for "$_q_nm")
         _q_dep=${_q_cnts%% *}
         _q_inst=${_q_cnts##* }
-        _q_out="$_q_out{\"name\":\"$(json_escape "$_q_nm")\",\"resources\":[${_q_res}],\"dependency-count\":$_q_dep,\"installed-dependency-count\":$_q_inst}"
+        _q_conf=$(conflicts_for "$_q_nm")
+        _q_out="$_q_out{\"name\":\"$(json_escape "$_q_nm")\",\"resources\":[${_q_res}],\"dependency-count\":$_q_dep,\"installed-dependency-count\":$_q_inst,\"conflicts\":$_q_conf}"
     done
     _q_out="$_q_out]}"
     emit "$_q_out"

+ 1 - 0
src/cli/Confirm.vala

@@ -37,4 +37,5 @@ namespace Usm.Cli {
         }
         return builder.str;
     }
+
 }

+ 249 - 1
src/cli/Manifest.vala

@@ -191,6 +191,229 @@ private bool phase_dependencies_satisfied(Usm.DependencyPhase? phase, string lab
 }
 
 
+/**
+ * Offers to install {@link target}'s missing dependencies through the
+ * shared resolver path — `Usm.Resolver.resolve_manifest`, the same
+ * local → system-package → USM-repository precedence `usm install`
+ * uses — instead of failing `manifest build`/`manifest install`
+ * outright. The system package manager is built exactly like
+ * `usm install` builds it (from the configuration reached through
+ * `Usm.SystemState`); an unmanaged system yields no manager, so only
+ * USM packages can be offered. Local presence is judged through the
+ * host-rooted {@link resfinder} because the offer installs into the
+ * real system, the same view `usm install` resolves against.
+ *
+ * Resolution covers every phase, so `manifest build` may offer runtime
+ * dependencies too; the offer output says "including runtime
+ * dependencies" so that is never surprising. The resolved package set
+ * includes the root manifest itself — the thing being built, never a
+ * dependency — which is excluded here by identity and name.
+ *
+ * Returns true when the user accepted and the installs were attempted
+ * (any install failure is reported here); the caller then re-runs its
+ * phase checks, which print what is still missing, and proceeds only
+ * when they now pass. A decline or an unsatisfiable resolution (whose
+ * itemised report is printed verbatim) returns false, keeping the
+ * caller's existing missing-dependencies exit (252).
+ *
+ * Install mechanisms (a documented choice): the system set runs
+ * through `Usm.SystemPackageManager.install` awaited with the same
+ * MainLoop begin/end pattern `Usm.Transaction.install_system_packages`
+ * uses — this CLI is otherwise fully synchronous and that pattern is
+ * the established way to drive the async SPM install. The USM closure
+ * is installed by spawning `/proc/self/exe install -y <names>` with
+ * INHERIT_FDS (the re-entry precedent `usm deploy` sets): that
+ * subprocess resolves and installs its own system dependencies, while
+ * the system set installed here covers the manifest's own missing
+ * refs.
+ */
+private bool offer_missing_dependencies(Usm.Manifest target) {
+    Usm.SystemState state = null;
+    try {
+        state = new Usm.SystemState(paths);
+    }
+    catch(Error e) {
+        // Unmanaged system: no repositories, cache or system package
+        // manager — only locally present resources can satisfy anything
+    }
+
+    Usm.SystemPackageManager? spm = null;
+    if(state != null) {
+        var manager = new Usm.SystemPackageManager(state.config);
+        spm = manager.enabled ? manager : null;
+    }
+
+    var resolver = new Usm.Resolver(resfinder);
+    if(state != null) {
+        try {
+            foreach(var repo in state.get_repositories()) {
+                try {
+                    state.refresh_list(repo);
+                }
+                catch(Error e) {
+                    printerr(@"Warning: could not refresh repository \"$(repo.name)\": $(e.message)\n");
+                }
+                var listing = state.get_latest_list(repo);
+                if(listing != null) {
+                    resolver.load_listing(repo, listing);
+                }
+            }
+            resolver.load_cache(paths);
+        }
+        catch(Error e) {
+            printerr(@"Warning: could not load USM repositories or the package cache: $(e.message)\n");
+        }
+    }
+
+    Usm.ResolutionResult result = null;
+    try {
+        result = resolver.resolve_manifest(target, spm);
+    }
+    catch(Usm.ResolverError e) {
+        printerr("%s\n".printf(e.message));
+        return false;
+    }
+    catch(Error e) {
+        printerr(@"Could not resolve dependencies: $(e.message)\n");
+        return false;
+    }
+
+    var usm_names = new Invercargill.DataStructures.Vector<string>();
+    foreach(var package in result.packages) {
+        if(package.manifest != target && package.manifest.name != target.name) {
+            usm_names.add(package.manifest.name);
+        }
+    }
+    usm_names = usm_names.sort((a, b) => a.collate(b)).to_vector();
+    var spm_names = new Invercargill.DataStructures.Vector<string>();
+    foreach(var system_package in result.system_packages) {
+        spm_names.add(system_package.name);
+    }
+
+    if(usm_names.length == 0 && spm_names.length == 0) {
+        // The resolver satisfied nothing installable either; the missing
+        // listing already printed by the caller's phase checks stands
+        return false;
+    }
+
+    print("Missing dependencies can be installed (including runtime dependencies):\n");
+    if(usm_names.length > 0) {
+        var usm_listing = usm_names.to_string(n => n, ", ");
+        print(@"  USM packages: $usm_listing\n");
+    }
+    if(spm_names.length > 0) {
+        var planned = spm_names;
+        if(spm != null) {
+            planned = ((!)spm).plan_install(spm_names);
+        }
+        print("  System packages (native install plan):\n");
+        foreach(var name in planned) {
+            print(@"    $name\n");
+        }
+    }
+
+    if(!offer_confirm()) {
+        printerr("Missing dependencies remain unresolved\n");
+        return false;
+    }
+
+    // Install failures do not short-circuit: the caller's re-run phase
+    // checks print exactly what is still missing and keep exit 252
+    if(spm != null && spm_names.length > 0) {
+        offer_install_system_packages(((!)spm), spm_names);
+    }
+
+    if(usm_names.length > 0) {
+        var argv = new Invercargill.DataStructures.Vector<string>();
+        argv.add_all(Invercargill.Wrap.array(new string[] { "/proc/self/exe", "install", "-y" }));
+        argv.add_all(usm_names);
+        try {
+            var proc = new Subprocess.newv(argv.to_array(), SubprocessFlags.INHERIT_FDS);
+            proc.wait_check();
+        }
+        catch(Error e) {
+            printerr(@"Could not install USM dependencies: $(e.message)\n");
+        }
+    }
+
+    return true;
+}
+
+/**
+ * Installs {@link names} as one system package manager transaction,
+ * driving the async `Usm.SystemPackageManager.install` with the same
+ * MainLoop begin/end pattern `Usm.Transaction.install_system_packages`
+ * uses (this CLI is otherwise fully synchronous). Prints one line per
+ * completed package and reports the transaction's failure; whether
+ * anything is still missing is left to the caller's re-run phase
+ * checks.
+ */
+private void offer_install_system_packages(Usm.SystemPackageManager manager, Invercargill.DataStructures.Vector<string> names) {
+    printerr("Installing system packages...\n");
+    string failure = "";
+    var loop = new MainLoop();
+    manager.install.begin(names, event => {
+        switch(event.event_type) {
+            case Usm.SystemInstallEventType.PACKAGE_COMPLETE:
+                printerr(@"Installed system package \"$(event.name)\"\n");
+                break;
+            case Usm.SystemInstallEventType.ERROR:
+                failure = event.message ?? "unknown error";
+                break;
+            default:
+                break;
+        }
+    }, (obj, res) => {
+        try {
+            if(!manager.install.end(res)) {
+                failure = failure.length > 0 ? failure : "the system package manager reported a failure";
+            }
+        }
+        catch(Error e) {
+            failure = e.message;
+        }
+        loop.quit();
+    });
+    loop.run();
+    if(failure.length > 0) {
+        printerr(@"System package installation failed: $failure\n");
+    }
+}
+
+/**
+ * The missing-dependency offer's prompt, following the confirmation
+ * conventions of `Usm.Cli.confirm_transaction` (`usm install`'s flow):
+ * y/yes accepts; anything else, including EOF, declines. The manifest
+ * build/install verbs take no -y/--yes flag today, so the prompt is
+ * always interactive.
+ */
+private bool offer_confirm() {
+    stdout.printf("Install missing dependencies? [y/N] ");
+    stdout.flush();
+
+    var reply = offer_confirm_read_line();
+    if(reply == null) {
+        return false;
+    }
+    var answer = reply.strip().down();
+    return answer == "y" || answer == "yes";
+}
+
+/** One line from stdin, or null at EOF; mirrors Usm.Cli's confirm reader. */
+private string? offer_confirm_read_line() {
+    var builder = new StringBuilder();
+    var character = stdin.getc();
+    while(character != -1 && character != '\n') {
+        builder.append_c((char)character);
+        character = stdin.getc();
+    }
+    if(character == -1 && builder.len == 0) {
+        return null;
+    }
+    return builder.str;
+}
+
+
 private int build() {
     if(build_path == null) {
         manifest_usage();
@@ -201,6 +424,14 @@ private int build() {
     var sane = phase_dependencies_satisfied(manifest.dependencies.manage, "management", resfinder) &
         phase_dependencies_satisfied(manifest.dependencies.build, "build", resfinder);
 
+    // Missing dependencies may be installable from the system package
+    // manager or USM repositories; on a completed offer the checks
+    // re-run so only a genuinely satisfied build proceeds
+    if(!sane && offer_missing_dependencies(manifest)) {
+        sane = phase_dependencies_satisfied(manifest.dependencies.manage, "management", resfinder) &
+            phase_dependencies_satisfied(manifest.dependencies.build, "build", resfinder);
+    }
+
     if(!sane) {
         printerr("Could not build manifest, missing dependencies\n");
         return 252;
@@ -242,6 +473,15 @@ private int install() {
         phase_dependencies_satisfied(manifest.dependencies.build, "build", resfinder) &
         phase_dependencies_satisfied(manifest.dependencies.runtime, "runtime", destresfinder);
 
+    // Missing dependencies may be installable from the system package
+    // manager or USM repositories; on a completed offer the checks
+    // re-run so only a genuinely satisfied install proceeds
+    if(!sane && offer_missing_dependencies(manifest)) {
+        sane = phase_dependencies_satisfied(manifest.dependencies.manage, "management", resfinder) &
+            phase_dependencies_satisfied(manifest.dependencies.build, "build", resfinder) &
+            phase_dependencies_satisfied(manifest.dependencies.runtime, "runtime", destresfinder);
+    }
+
     if(!sane) {
         printerr("Could not install manifest, missing dependencies\n");
         return 252;
@@ -311,7 +551,15 @@ private int install() {
  * tree to its suggested path, with no build or install staging involved.
  */
 private int install_data_package() {
-    if(!phase_dependencies_satisfied(manifest.dependencies.runtime, "runtime", destresfinder)) {
+    var sane = phase_dependencies_satisfied(manifest.dependencies.runtime, "runtime", destresfinder);
+
+    // The same offer path the regular install uses: runtime deps may be
+    // installable from the system package manager or USM repositories
+    if(!sane && offer_missing_dependencies(manifest)) {
+        sane = phase_dependencies_satisfied(manifest.dependencies.runtime, "runtime", destresfinder);
+    }
+
+    if(!sane) {
         printerr("Could not install manifest, missing dependencies\n");
         return 252;
     }

+ 47 - 0
src/lib/Interrupt.vala

@@ -0,0 +1,47 @@
+namespace Usm {
+
+    /**
+     * SIGINT capture for running transactions: a raw POSIX handler sets a
+     * static flag the transaction loop polls at safe checkpoints — before
+     * each per-package operation and whenever an operation fails. The
+     * terminal's Ctrl+C reaches the whole foreground process group, so the
+     * usual outcome is the running child (build, test) dying with the
+     * signal, the operation erroring, and the checkpoint rolling the
+     * journaled transaction straight back — narrated by the journal's
+     * rollback description, no prompt.
+     *
+     * The handler is installed only for the duration of a transaction —
+     * {@link Transaction.run} and {@link Transaction.rebuild_package} call
+     * {@link install} on entry and {@link restore} on exit — so outside a
+     * transaction SIGINT keeps its default disposition.
+     */
+    public class TransactionInterrupt : Object {
+
+        /**
+         * Set by the handler; polled by the transaction checkpoints.
+         * (Vala has no volatile fields — a plain bool store/load is
+         * atomic in practice, and the handler only ever sets it true.)
+         */
+        public static bool requested = false;
+
+        [CCode (has_target = false)]
+        private static void handle_sigint(int signal_number) {
+            requested = true;
+        }
+
+        /**
+         * Arms the handler and clears any stale request. Signal-safe:
+         * the handler only assigns the flag.
+         */
+        public static void install() {
+            Posix.signal(Posix.Signal.INT, (Posix.sighandler_t) handle_sigint);
+            requested = false;
+        }
+
+        /** Restores SIGINT's default disposition and clears the flag. */
+        public static void restore() {
+            Posix.signal(Posix.Signal.INT, Posix.SIG_DFL);
+            requested = false;
+        }
+    }
+}

+ 203 - 0
src/lib/ProviderSelection.vala

@@ -0,0 +1,203 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Usm {
+
+    /**
+     * Planner delegate over an SPM's plan mode: the MATERIAL install set
+     * for `names` — every native dependency of a dependency included,
+     * already-installed packages excluded — or null when the SPM cannot
+     * co-install that set (conflicts, unsatisfiable world). Production
+     * callers wrap {@link SystemPackageManager.plan_install}; tests inject
+     * fakes.
+     */
+    public delegate Vector<string>? SystemProviderPlan(Vector<string> names);
+
+    /**
+     * The outcome of {@link ProviderSelection.select}: the chosen provider
+     * names plus the refs no candidate could satisfy, so the caller can
+     * route them to USM repositories or report them.
+     */
+    public class ProviderSelection : Object {
+
+        /** Chosen package names in pick order (deterministic). */
+        public Vector<string> chosen { get; set; }
+
+        /** Refs still unsatisfied: no candidate provided them, or every covering candidate conflicted with the chosen set or failed the planner. */
+        public Vector<ResourceRef> unresolved { get; set; }
+
+        /**
+         * Whether ANY planner call succeeded during selection. A planner
+         * that fails EVERY set is not reporting conflicts — it is a
+         * helper without a working plan mode — and callers must not read
+         * {@link unresolved} as solver verdicts in that case.
+         */
+        public bool planner_succeeded { get; set; default = false; }
+
+        /**
+         * Chooses one installable provider set for `missing` from a query
+         * {@link Usm.SystemQueryResult}: helpers list EVERY package
+         * providing a ref (dropbear AND openssh both answer bin:ssh), and
+         * that union is routinely not installable — alternative providers
+         * of one ref conflict with each other while each alone satisfies
+         * it. So: one provider per ref, never two, and never a pair the
+         * metadata or the planner rejects.
+         *
+         * Deterministic greedy over refs in collated order:
+         *
+         * 1. A ref already satisfied by a chosen candidate costs nothing
+         *    (multi-ref providers get reused).
+         * 2. Candidates conflicting with the chosen set — EITHER
+         *    direction of the reported `conflicts` metadata, since
+         *    asymmetric declarations are normal (dropbear declares
+         *    `!openssh-client`; openssh declares nothing) — are
+         *    ineligible. Two providers of the same ref can never both be
+         *    chosen, so the structural world-pinning conflict needs no
+         *    metadata at all.
+         * 3. With a {@link SystemProviderPlan}: among eligible
+         *    candidates, the one whose cumulative plan (chosen set plus
+         *    the candidate) is smallest wins; a candidate whose plan
+         *    FAILS is skipped — the SPM's own solver is the final
+         *    arbiter, catching conflicts the metadata missed. Because
+         *    every step plans the whole cumulative set, the finished set
+         *    needs no separate verification pass. Ties break on fewest
+         *    missing dependencies, then name.
+         * 4. Without a planner: fewest missing dependencies
+         *    (dependency_count − installed_dependency_count), then name —
+         *    metadata only, zero solver round-trips.
+         *
+         * A ref whose every candidate is ineligible or unplannable lands
+         * in {@link unresolved} rather than aborting the selection: the
+         * caller routes it onward (USM repositories, or an itemised
+         * failure) exactly as if no SPM candidate had covered it.
+         */
+        public static ProviderSelection select(Vector<ResourceRef> missing,
+                SystemQueryResult result, SystemProviderPlan? planner) {
+            var selection = new ProviderSelection();
+            selection.chosen = new Vector<string>();
+            selection.unresolved = new Vector<ResourceRef>();
+
+            var chosen_candidates = new Vector<SystemPackageCandidate>();
+            var ordered_refs = missing.sort((a, b) => a.to_string().collate(b.to_string())).to_vector();
+            foreach(var reference in ordered_refs) {
+                var pool = new Vector<SystemPackageCandidate>();
+                foreach(var candidate in result.packages) {
+                    if(candidate_provides(candidate, reference)) {
+                        pool.add(candidate);
+                    }
+                }
+                if(pool.length == 0) {
+                    selection.unresolved.add(reference);
+                    continue;
+                }
+
+                // A candidate already chosen for another ref satisfies
+                // this one for free.
+                var already_covered = false;
+                foreach(var incumbent in chosen_candidates) {
+                    if(candidate_provides(incumbent, reference)) {
+                        already_covered = true;
+                        break;
+                    }
+                }
+                if(already_covered) {
+                    continue;
+                }
+
+                var ordered_pool = pool.sort((a, b) => {
+                    var by_cost = missing_dependencies(a) - missing_dependencies(b);
+                    if(by_cost != 0) {
+                        return by_cost;
+                    }
+                    return a.name.collate(b.name);
+                }).to_vector();
+
+                SystemPackageCandidate? best = null;
+                Vector<string>? best_plan = null;
+                foreach(var candidate in ordered_pool) {
+                    if(conflicts_with_chosen(candidate, chosen_candidates)) {
+                        continue;
+                    }
+                    if(planner != null) {
+                        var cumulative = new Vector<string>();
+                        cumulative.add_all(selection.chosen);
+                        if(!cumulative.any(name => name == candidate.name)) {
+                            cumulative.add(candidate.name);
+                        }
+                        var planned = planner(cumulative);
+                        if(planned == null) {
+                            continue;
+                        }
+                        selection.planner_succeeded = true;
+                        if(best == null
+                                || ((!)planned).length < ((!)best_plan).length
+                                || (((!)planned).length == ((!)best_plan).length
+                                    && cheaper(candidate, (!)best))) {
+                            best = candidate;
+                            best_plan = (!)planned;
+                        }
+                    }
+                    else if(best == null || cheaper(candidate, (!)best)) {
+                        best = candidate;
+                    }
+                }
+                if(best == null) {
+                    selection.unresolved.add(reference);
+                    continue;
+                }
+                chosen_candidates.add((!)best);
+                selection.chosen.add((!)best.name);
+            }
+            return selection;
+        }
+
+        /**
+         * Whether `candidate` provides `reference`: the same matching the
+         * rad ensure-deps path and the Resolver use — satisfied_by against
+         * each declared resource, with an exact string match as fallback
+         * for constrained refs whose helper echoed the constraint back.
+         */
+        private static bool candidate_provides(SystemPackageCandidate candidate, ResourceRef reference) {
+            foreach(var resource in candidate.resources) {
+                if(reference.satisfied_by(resource) || reference.to_string() == resource.to_string()) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /** Packages a solo install of `candidate` would still pull. */
+        private static int missing_dependencies(SystemPackageCandidate candidate) {
+            return candidate.dependency_count - candidate.installed_dependency_count;
+        }
+
+        private static bool cheaper(SystemPackageCandidate candidate, SystemPackageCandidate incumbent) {
+            var by_cost = missing_dependencies(candidate) - missing_dependencies(incumbent);
+            if(by_cost != 0) {
+                return by_cost < 0;
+            }
+            return candidate.name.collate(incumbent.name) < 0;
+        }
+
+        /**
+         * Symmetric conflict test: either side's metadata naming the
+         * other, so asymmetric declarations still filter.
+         */
+        private static bool conflicts_with_chosen(SystemPackageCandidate candidate,
+                Vector<SystemPackageCandidate> chosen) {
+            foreach(var name in candidate.conflicts) {
+                if(chosen.any(c => c.name == name)) {
+                    return true;
+                }
+            }
+            foreach(var incumbent in chosen) {
+                foreach(var name in incumbent.conflicts) {
+                    if(name == candidate.name) {
+                        return true;
+                    }
+                }
+            }
+            return false;
+        }
+    }
+}

+ 158 - 6
src/lib/Resolver.vala

@@ -96,6 +96,12 @@ namespace Usm {
         public HashSet<AbstractPackage> processed = new HashSet<AbstractPackage>();
         public Dictionary<string, SystemPackageCandidate> spm_chosen = new Dictionary<string, SystemPackageCandidate>();
         public Dictionary<string, Set<ResourceRef>> refs_by_package = new Dictionary<string, Set<ResourceRef>>();
+        /**
+         * The refs the walk satisfied through SPM candidates — the repair
+         * pass ({@link Resolver.resolve}) re-selects providers for exactly
+         * these when the greedy set turns out not to co-install.
+         */
+        public Vector<ResourceRef> spm_refs = new Vector<ResourceRef>();
 
         public ResolutionState snapshot() {
             var copy = new ResolutionState();
@@ -109,6 +115,8 @@ namespace Usm {
                 refs.union_with(pair.value);
                 copy.refs_by_package.set(pair.key, refs);
             }
+            copy.spm_refs = new Vector<ResourceRef>();
+            copy.spm_refs.add_all(spm_refs);
             return copy;
         }
     }
@@ -125,6 +133,12 @@ namespace Usm {
         private Dictionary<string, Vector<SystemPackageCandidate>> spm_index = new Dictionary<string, Vector<SystemPackageCandidate>>();
         private Dictionary<string, bool> local_presence = new Dictionary<string, bool>();
         private bool spm_configured = false;
+        /**
+         * The last batched SPM query result — kept so the post-walk
+         * repair pass can re-select providers from the full candidate
+         * pool without a second helper spawn.
+         */
+        private SystemQueryResult? spm_query_result = null;
 
         /**
          * Optional progress callback fired once per resolved package as
@@ -200,16 +214,51 @@ namespace Usm {
 
         /**
          * Finds a package providing the given resource, deterministically
-         * choosing the alphabetically-first package name (then version,
-         * repository name, package path).
+         * choosing the best provider: alphabetically-first package name,
+         * then the HIGHEST version (a stale cached copy of an older
+         * release must never win over a newer one), then a repository
+         * package over a supplied/cached one so installs pull a fresh
+         * copy, then repository name and package path.
          */
         public AbstractPackage? find_resource(ResourceRef resource) {
+            AbstractPackage? match = null;
             foreach(var package in catalog()) {
-                if(package.manifest.satisfies(resource)) {
-                    return package;
+                if(!package.manifest.satisfies(resource)) {
+                    continue;
                 }
+                if(match == null) {
+                    match = package;
+                    continue;
+                }
+                if(better_provider(package, match)) {
+                    match = package;
+                }
+            }
+            return match;
+        }
+
+        /**
+         * Whether {@link candidate} beats {@link incumbent} as a provider:
+         * alphabetically-first name, then highest version, then repository
+         * over supplied/cache, then repository name and package path.
+         */
+        private static bool better_provider(AbstractPackage candidate, AbstractPackage incumbent) {
+            var by_name = candidate.manifest.name.collate(incumbent.manifest.name);
+            if(by_name != 0) {
+                return by_name < 0;
+            }
+            var by_version = candidate.manifest.version.compare(incumbent.manifest.version);
+            if(by_version != 0) {
+                return by_version > 0;
             }
-            return null;
+            if((candidate.repository == null) != (incumbent.repository == null)) {
+                return candidate.repository != null;
+            }
+            var by_repository = (candidate.repository?.name ?? "").collate(incumbent.repository?.name ?? "");
+            if(by_repository != 0) {
+                return by_repository < 0;
+            }
+            return (candidate.package_path ?? "").collate(incumbent.package_path ?? "") < 0;
         }
 
         /**
@@ -235,6 +284,7 @@ namespace Usm {
             spm_index = new Dictionary<string, Vector<SystemPackageCandidate>>();
             local_presence = new Dictionary<string, bool>();
             spm_configured = spm != null && spm.enabled;
+            spm_query_result = null;
             resolution_estimated_total = roots.length * 2;
             if(resolution_estimated_total < 1) {
                 resolution_estimated_total = 1;
@@ -265,6 +315,57 @@ namespace Usm {
             }
             system_packages = system_packages.sort((a, b) => a.name.collate(b.name)).to_vector();
 
+            // The walk chose SPM candidates greedily with conflict
+            // METADATA only. The SPM's own solver is the final arbiter:
+            // when the finished set does not co-install, re-select the
+            // whole SPM-assigned ref set through the shared
+            // provider-selection pass (planner-verified, minimal
+            // cumulative closure). Zero extra helper spawns in the
+            // common case — one plan call.
+            if(spm != null && ((SystemPackageManager)spm).enabled && system_packages.length > 0
+                    && spm_query_result != null && state.spm_refs.length > 0) {
+                var chosen_names = new Vector<string>();
+                foreach(var system_package in system_packages) {
+                    chosen_names.add(system_package.name);
+                }
+                if(((SystemPackageManager)spm).try_plan_install(chosen_names) == null) {
+                    SystemProviderPlan plan = names => ((SystemPackageManager)spm).try_plan_install(names);
+                    var selection = ProviderSelection.select(state.spm_refs, (!)spm_query_result, plan);
+                    if(selection.planner_succeeded && selection.unresolved.length > 0) {
+                        var listing = new StringBuilder();
+                        foreach(var reference in selection.unresolved) {
+                            if(listing.len > 0) {
+                                listing.append(", ");
+                            }
+                            listing.append(reference.to_string());
+                        }
+                        throw new ResolverError.UNSATISFIABLE(
+                            "The system package manager cannot co-install a provider set for: %s — the covering providers conflict with each other"
+                            .printf(listing.str));
+                    }
+                    var by_name = new Dictionary<string, SystemPackageCandidate>();
+                    foreach(var candidate in ((!)spm_query_result).packages) {
+                        if(!by_name.has(candidate.name)) {
+                            by_name.set(candidate.name, candidate);
+                        }
+                    }
+                    var repaired = new Vector<SystemPackageCandidate>();
+                    foreach(var name in selection.chosen) {
+                        SystemPackageCandidate candidate;
+                        if(by_name.try_get(name, out candidate)) {
+                            repaired.add(candidate);
+                        }
+                    }
+                    // A planner that never succeeded is a helper without
+                    // a working plan mode, not a conflict verdict: keep
+                    // the walk's greedy set rather than discarding it on
+                    // unusable evidence.
+                    if(repaired.length > 0 && (selection.planner_succeeded || repaired.length < system_packages.length)) {
+                        system_packages = repaired.sort((a, b) => a.name.collate(b.name)).to_vector();
+                    }
+                }
+            }
+
             var packages = new PackageSet();
             packages.union_with(state.chosen);
 
@@ -280,6 +381,22 @@ namespace Usm {
             };
         }
 
+        /**
+         * Resolves the dependency closure of a LOCAL manifest — a git
+         * checkout or build directory — rather than a repository package:
+         * the entry point `usm manifest install`/`usm manifest build`
+         * and usmrad's ensure-deps share, so provider selection (SPM
+         * conflict awareness, minimal closures, planner verification)
+         * behaves identically everywhere. The root contributes its
+         * dependency phases; its own provides are NOT installed (it is
+         * the thing being built, not a dependency).
+         */
+        public ResolutionResult resolve_manifest(Manifest manifest, SystemPackageManager? spm = null) throws Error {
+            var roots = new Vector<AbstractPackage>();
+            roots.add(new AbstractPackage.from_manifest(manifest));
+            return resolve(roots, spm);
+        }
+
         /**
          * Orders packages dependencies-before-dependents with Kahn's
          * algorithm and a deterministic package-name tie-break; a cycle is a
@@ -494,6 +611,7 @@ namespace Usm {
             SystemQueryResult? result = null;
             try {
                 result = spm.query_sync(missing);
+                spm_query_result = result;
             }
             catch(Error e) {
                 throw new ResolverError.SYSTEM_QUERY_FAILED(@"$(e.message)");
@@ -539,7 +657,14 @@ namespace Usm {
         /**
          * The best candidate providing the resource: one already chosen for
          * another resource (cost 0, smallest name) when possible, otherwise
-         * the cheapest by (new installs, name).
+         * the cheapest by (new installs, name). Candidates whose conflicts
+         * metadata names a chosen package — in EITHER direction, since
+         * blockers are declared asymmetrically (dropbear declares
+         * `!openssh-client`; the openssh packages declare nothing) — are
+         * skipped: alternative providers of one ref routinely cannot
+         * coexist, and the walk must never pick such a mix. The post-walk
+         * repair pass in {@link resolve} re-checks the finished set with
+         * the SPM's own solver, catching pairs the metadata missed.
          */
         private SystemPackageCandidate? best_spm_candidate(ResourceRef resource) {
             Vector<SystemPackageCandidate> candidates;
@@ -549,6 +674,9 @@ namespace Usm {
             SystemPackageCandidate? already_chosen = null;
             SystemPackageCandidate? best = null;
             foreach(var candidate in candidates) {
+                if(conflicts_with_chosen(candidate)) {
+                    continue;
+                }
                 if(state.spm_chosen.has(candidate.name)) {
                     if(already_chosen == null || candidate.name.collate(already_chosen.name) < 0) {
                         already_chosen = candidate;
@@ -561,6 +689,27 @@ namespace Usm {
             return already_chosen ?? best;
         }
 
+        /**
+         * Symmetric conflict test against the walk's chosen SPM set: the
+         * candidate's own metadata naming a chosen package, or a chosen
+         * package's metadata naming the candidate.
+         */
+        private bool conflicts_with_chosen(SystemPackageCandidate candidate) {
+            foreach(var name in candidate.conflicts) {
+                if(state.spm_chosen.has(name)) {
+                    return true;
+                }
+            }
+            foreach(var pair in state.spm_chosen) {
+                foreach(var name in pair.value.conflicts) {
+                    if(name == candidate.name) {
+                        return true;
+                    }
+                }
+            }
+            return false;
+        }
+
         /**
          * Resolves one resource ref for {@link origin} under the
          * local → system-package → USM-package precedence, mutating the
@@ -574,6 +723,9 @@ namespace Usm {
                 var candidate = best_spm_candidate(resource);
                 if(candidate != null) {
                     state.spm_chosen.set(candidate.name, candidate);
+                    if(!state.spm_refs.any(r => r.to_string() == resource.to_string())) {
+                        state.spm_refs.add(resource);
+                    }
                     return null;
                 }
             }

+ 11 - 1
src/lib/State/State.vala

@@ -75,7 +75,17 @@ namespace Usm {
         public CachedPackage resolve_installed(CachedPackage installed) throws Error {
             var info = File.new_for_path(installed.state_path).query_info(FileAttribute.STANDARD_SYMLINK_TARGET, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
             var target = info.get_symlink_target();
-            return target != null ? new CachedPackage(target) : installed;
+            if(target != null) {
+                // mark_installed records absolute targets, but a relative
+                // target (a moved state directory, a hand-made mark) must
+                // resolve against the mark's directory, never the process
+                // working directory
+                if(!Path.is_absolute(target)) {
+                    target = Path.build_filename(Path.get_dirname(installed.state_path), target);
+                }
+                return new CachedPackage(target);
+            }
+            return installed;
         }
 
         /**

+ 31 - 1
src/lib/SystemPackageManager.vala

@@ -77,6 +77,15 @@ namespace Usm {
      * of packages a solo install of this package would pull including itself,
      * and installed_dependency_count is the portion of those already present
      * on the system.
+     *
+     * `conflicts` names the packages this one cannot coexist with, as far as
+     * the helper could derive from the SPM's native metadata (apk `!`
+     * blockers, dnf repoquery --conflicts, apt Conflicts/Breaks, pacman
+     * Conflicts With). Asymmetric declarations are normal — dropbear declares
+     * `!openssh-client` while the openssh packages declare nothing — so
+     * consumers must treat conflict as symmetric. Absent (empty) when the
+     * helper's SPM cannot tell; provider selection still excludes two
+     * providers of the SAME ref structurally.
      */
     public class SystemPackageCandidate {
 
@@ -84,6 +93,8 @@ namespace Usm {
         public Vector<ResourceRef> resources { get; set; }
         public int dependency_count { get; set; }
         public int installed_dependency_count { get; set; }
+        /** See the class docblock; always populated by the mapper (empty when unknown). */
+        public Vector<string> conflicts { get; set; }
 
         public static PropertyMapper<SystemPackageCandidate> get_mapper() {
             return PropertyMapper.build_for<SystemPackageCandidate>(cfg => {
@@ -91,6 +102,8 @@ namespace Usm {
                 cfg.map_many<string>("resources", o => o.resources.select<string>(r => r.to_string()), (o, v) => o.resources = v.attempt_select<ResourceRef>(i => new ResourceRef(i)).to_vector());
                 cfg.map<int>("dependency-count", o => o.dependency_count, (o, v) => o.dependency_count = v);
                 cfg.map<int>("installed-dependency-count", o => o.installed_dependency_count, (o, v) => o.installed_dependency_count = v);
+                cfg.map_many<string>("conflicts", o => o.conflicts, (o, v) => o.conflicts = v.to_vector())
+                    .when_undefined(o => o.conflicts = new Vector<string>());
                 cfg.set_constructor(() => new SystemPackageCandidate());
             });
         }
@@ -252,6 +265,23 @@ namespace Usm {
             if(!enabled) {
                 return names;
             }
+            var planned = try_plan_install(native_names);
+            return planned ?? names;
+        }
+
+        /**
+         * The failure-sensing core behind {@link plan_install}: null when
+         * the helper's plan mode FAILS (no plan mode, unknown names, or —
+         * the case provider selection cares about — the set cannot
+         * co-install because its packages conflict), the material install
+         * set otherwise. Callers that must distinguish a rejected set from
+         * a degraded plan use this; {@link plan_install} keeps its
+         * degrade-to-input contract.
+         */
+        public Vector<string>? try_plan_install(Vector<string> native_names) {
+            if(!enabled) {
+                return null;
+            }
 
             var plan_argv = new string[query_argv.length];
             for(int index = 0; index < query_argv.length; index++) {
@@ -286,7 +316,7 @@ namespace Usm {
             }
             catch(Error e) {
             }
-            return names;
+            return null;
         }
 
         public async bool install(Vector<string> native_names, InstallProgressDelegate? progress = null) throws Error {

+ 133 - 36
src/lib/Transaction.vala

@@ -159,12 +159,22 @@ namespace Usm {
          */
         public Vector<RebuildEntry> rebuilds { get; private set; default = new Vector<RebuildEntry>(); }
 
+        /**
+         * Emitted when a Ctrl+C lands mid-transaction: a true return
+         * keeps the transaction going (the interrupted operation
+         * re-runs), false rolls the journaled transaction back. No
+         * handler is connected by the CLI — a Ctrl+C always rolls back
+         * — leaving this as the programmatic opt-out for embedders.
+         */
+        public signal bool interrupt_decided(string subject, string phase_verb);
+
         public void run() throws TransactionError {
 
             // 1. Verify the transaction is valid
             strategise();
 
             journal = new Journal(journal_backup_root());
+            TransactionInterrupt.install();
             try {
                 journal.begin();
 
@@ -216,6 +226,7 @@ namespace Usm {
                 throw new TransactionError.UNKNOWN_ERROR(@"Error running transaction: $(e.message)");
             }
             finally {
+                TransactionInterrupt.restore();
                 journal = null;
             }
         }
@@ -238,6 +249,7 @@ namespace Usm {
             current_task_type = TransactionTask.REBUILDING;
 
             journal = new Journal(journal_backup_root());
+            TransactionInterrupt.install();
             try {
                 journal.begin();
 
@@ -258,6 +270,7 @@ namespace Usm {
                 throw new TransactionError.UNKNOWN_ERROR(@"Error rebuilding package $(package.package_name): $(e.message)");
             }
             finally {
+                TransactionInterrupt.restore();
                 journal = null;
             }
         }
@@ -470,21 +483,47 @@ namespace Usm {
                     report_progress(TransactionTask.STRATEGISING, (float)strategise_current_task / (float)strategise_worst_case_task_count);
                     try {
                         var manifest = package.get_manifest();
+                        // Resources provided by packages this transaction
+                        // REMOVES are gone before the first build runs —
+                        // stale on disk at planning time, they satisfy
+                        // nothing (an update pair's replacement counts only
+                        // from the lot that installs it, so the dependency
+                        // lands in an earlier lot and the dependent builds
+                        // against the fresh artifact)
+                        PredicateDelegate<ResourceRef> removed_by_plan = d => planned_removal.any(p => {
+                            try {
+                                return p.get_manifest().satisfies(d);
+                            }
+                            catch(Error e) {
+                                return false;
+                            }
+                        });
                         // Build-phase refs must be satisfied by resources
-                        // already present or installed by an EARLIER lot: a
-                        // same-lot provide is not installed yet when this
-                        // package builds, so a build dependency on it defers
-                        // the package to the next lot
-                        PredicateDelegate<ResourceRef> buildtime_satisfied = d => resource_finder.has_resource(d)
-                            || preinstalled_resources.any(r => d.satisfied_by(r))
-                            || installed_by_earlier_lots.any(r => d.satisfied_by(r));
-                        // Manage-phase executables run at install time, when
-                        // everything this lot has already admitted (in
-                        // topological order) is installed
-                        PredicateDelegate<ResourceRef> installtime_satisfied = d => resource_finder.has_resource(d)
-                            || preinstalled_resources.any(r => d.satisfied_by(r))
-                            || installed_by_earlier_lots.any(r => d.satisfied_by(r))
-                            || installed_by_this_lot.any(r => d.satisfied_by(r));
+                        // that are in place when this package builds:
+                        // either installed by an EARLIER lot (the
+                        // replacement of an updated pair — this dominates,
+                        // the removal is already undone by then) or
+                        // present on the destination and NOT provided by
+                        // a package this transaction removes (those go
+                        // away before the first build, so the stale
+                        // on-disk state satisfies nothing). A same-lot
+                        // provide is not installed yet and defers the
+                        // package to the next lot.
+                        PredicateDelegate<ResourceRef> buildtime_satisfied = d =>
+                            installed_by_earlier_lots.any(r => d.satisfied_by(r))
+                            || (!removed_by_plan(d) && (
+                                resource_finder.has_resource(d)
+                                || preinstalled_resources.any(r => d.satisfied_by(r))));
+                        // Manage-phase executables run at install time,
+                        // when everything this lot has already admitted
+                        // (in topological order) is installed — the same
+                        // dominance and poison rules as the build phase
+                        PredicateDelegate<ResourceRef> installtime_satisfied = d =>
+                            installed_by_earlier_lots.any(r => d.satisfied_by(r))
+                            || installed_by_this_lot.any(r => d.satisfied_by(r))
+                            || (!removed_by_plan(d) && (
+                                resource_finder.has_resource(d)
+                                || preinstalled_resources.any(r => d.satisfied_by(r))));
                         if(manifest.dependencies.manage.is_satisfied(installtime_satisfied) &&
                                 manifest.dependencies.build.is_satisfied(buildtime_satisfied)) {
                             lot.add(package);
@@ -582,9 +621,24 @@ namespace Usm {
         private string describe_install_deadlock(Enumerable<CachedPackage> stuck, Set<ResourceRef> installed_by_earlier_lots, Set<CachedPackage> planned_install) {
             var stuck_sorted = stuck.sort((a, b) => a.package_name.collate(b.package_name)).to_vector();
 
-            PredicateDelegate<ResourceRef> satisfier = r => resource_finder.has_resource(r)
-                || preinstalled_resources.any(p => r.satisfied_by(p))
-                || installed_by_earlier_lots.any(p => r.satisfied_by(p));
+            // The same satisfier the scheduler used, poison included: a
+            // resource provided by a package this transaction removes is
+            // going away before the first build, so explaining a stall
+            // against the stale on-disk state would clear every blocker
+            // and produce a dead diagnosis
+            PredicateDelegate<ResourceRef> removed_by_plan = r => planned_removal.any(p => {
+                try {
+                    return p.get_manifest().satisfies(r);
+                }
+                catch(Error e) {
+                    return false;
+                }
+            });
+            PredicateDelegate<ResourceRef> satisfier = r =>
+                installed_by_earlier_lots.any(p => r.satisfied_by(p))
+                || (!removed_by_plan(r) && (
+                    resource_finder.has_resource(r)
+                    || preinstalled_resources.any(p => r.satisfied_by(p))));
 
             var builder = new StringBuilder();
             builder.append_printf("Could not build a transaction strategy: %u package(s) could not be scheduled for installation", stuck_sorted.length);
@@ -602,8 +656,8 @@ namespace Usm {
                     builder.append_printf("    (manifest could not be read: %s)\n", e.message);
                     continue;
                 }
-                append_blocking_refs(builder, manifest.dependencies.manage, "manage", satisfier, planned_install, package);
-                append_blocking_refs(builder, manifest.dependencies.build, "build", satisfier, planned_install, package);
+                append_blocking_refs(builder, manifest.dependencies.manage, "manage", satisfier, removed_by_plan, planned_install, package);
+                append_blocking_refs(builder, manifest.dependencies.build, "build", satisfier, removed_by_plan, planned_install, package);
             }
             return builder.str.chomp();
         }
@@ -613,14 +667,14 @@ namespace Usm {
          * provides. Flat phases list their refs directly; grouped phases
          * report every candidate group that is not viable.
          */
-        private void append_blocking_refs(StringBuilder builder, DependencyPhase phase, string phase_name, PredicateDelegate<ResourceRef> satisfier, Set<CachedPackage> planned_install, CachedPackage package) {
+        private void append_blocking_refs(StringBuilder builder, DependencyPhase phase, string phase_name, PredicateDelegate<ResourceRef> satisfier, PredicateDelegate<ResourceRef> removed_by_plan, Set<CachedPackage> planned_install, CachedPackage package) {
             if(phase == null) {
                 return;
             }
             if(!phase.is_grouped) {
                 foreach(var resource in phase.ordered_required()) {
                     if(!satisfier(resource)) {
-                        append_blocking_ref(builder, phase_name, resource, planned_install, package);
+                        append_blocking_ref(builder, phase_name, resource, removed_by_plan, planned_install, package);
                     }
                 }
                 return;
@@ -633,7 +687,7 @@ namespace Usm {
                 }
                 builder.append_printf("    [%s phase, candidate group %u of %u:]\n", phase_name, index + 1, groups.length);
                 foreach(var resource in blocking) {
-                    append_blocking_ref(builder, phase_name, resource, planned_install, package);
+                    append_blocking_ref(builder, phase_name, resource, removed_by_plan, planned_install, package);
                 }
             }
         }
@@ -643,7 +697,7 @@ namespace Usm {
          * transaction members provide it, their names — distinguishing
          * unmet dependencies from dependency cycles.
          */
-        private void append_blocking_ref(StringBuilder builder, string phase_name, ResourceRef resource, Set<CachedPackage> planned_install, CachedPackage package) {
+        private void append_blocking_ref(StringBuilder builder, string phase_name, ResourceRef resource, PredicateDelegate<ResourceRef> removed_by_plan, Set<CachedPackage> planned_install, CachedPackage package) {
             var providers = new Vector<string>();
             foreach(var candidate in planned_install) {
                 if(candidate == package) {
@@ -659,6 +713,13 @@ namespace Usm {
                 }
             }
             if(providers.length == 0) {
+                if(removed_by_plan(resource)) {
+                    builder.append_printf(
+                        "    %s (%s phase) is going away: a package this transaction removes currently provides it, and nothing in this transaction replaces it\n",
+                        resource.to_string(), phase_name
+                    );
+                    return;
+                }
                 builder.append_printf("    %s (%s phase) is unmet: it is not present on this system, no planned system package provides it, and no package in this transaction provides it\n", resource.to_string(), phase_name);
                 return;
             }
@@ -838,23 +899,57 @@ namespace Usm {
 
         private void do_for(Enumerable<CachedPackage> packages, PackageDelegate func, TransactionTask task_type) throws TransactionError {
             foreach (var package in packages) {
-                try {
-                    begin_action(task_type, package.package_name, current_task);
-                    report_progress(task_type, 0.0f);
-                    func(package);
-                    lock(progress_lock) {
-                        current_task++;
-                    }
-                }
-                catch (TransactionError e) {
-                    throw e;
+                // A Ctrl+C that arrived between operations resolves before
+                // this one starts: keep going (clearing the flag) or roll
+                // the transaction back
+                if(TransactionInterrupt.requested && !resolve_interrupt(package.package_name, task_type)) {
+                    throw new TransactionError.INTERRUPTED(@"Interrupted before $(task_type.get_verb()) $(package.package_name)");
                 }
-                catch(Error e) {
-                    throw new TransactionError.UNKNOWN_ERROR(@"Error $(task_type.get_verb()) package $(package.package_name): $(e.message)");
+                while(true) {
+                    try {
+                        begin_action(task_type, package.package_name, current_task);
+                        report_progress(task_type, 0.0f);
+                        func(package);
+                        lock(progress_lock) {
+                            current_task++;
+                        }
+                        break;
+                    }
+                    catch (TransactionError e) {
+                        // The operation failed with a Ctrl+C pending — the
+                        // terminal signalled the child too. Ask: roll back,
+                        // or clear the flag and re-run this operation
+                        if(TransactionInterrupt.requested && resolve_interrupt(package.package_name, task_type)) {
+                            continue;
+                        }
+                        throw e;
+                    }
+                    catch(Error e) {
+                        if(TransactionInterrupt.requested && resolve_interrupt(package.package_name, task_type)) {
+                            continue;
+                        }
+                        throw new TransactionError.UNKNOWN_ERROR(@"Error $(task_type.get_verb()) package $(package.package_name): $(e.message)");
+                    }
                 }
             }
         }
 
+        /**
+         * Resolves a pending interrupt for {@link subject} in the phase
+         * {@link verb}: emits {@link interrupt_decided}. A "keep going"
+         * answer clears the flag so the retried operation runs
+         * uninterrupted; a rollback answer (or no handler — the signal's
+         * default) leaves it set and the caller unwinds to the journaled
+         * rollback.
+         */
+        private bool resolve_interrupt(string subject, TransactionTask task_type) {
+            bool keep_going = interrupt_decided(subject, task_type.get_verb());
+            if(keep_going) {
+                TransactionInterrupt.requested = false;
+            }
+            return keep_going;
+        }
+
         private void report_progress(TransactionTask task, float progress) {
             lock(progress_lock) {
                 progress_updated(task, current_subject, current_task, task_count, progress);
@@ -1169,7 +1264,9 @@ namespace Usm {
         INVALID_TRANSACTION,
         UNKNOWN_ERROR,
         BUILD_ERROR,
-        INSTALL_ERROR
+        INSTALL_ERROR,
+        /** A Ctrl+C the interrupt decision answered with "roll back". */
+        INTERRUPTED
     }
 
 

+ 26 - 6
src/lib/Version.vala

@@ -81,15 +81,25 @@ namespace Usm {
         }
 
         public bool greater_than(Version other) {
-            var parts = int.min(other.version_nums.length, version_nums.length);
+            var mine = version_nums ?? new int[0];
+            var theirs = other.version_nums ?? new int[0];
+            var parts = int.min(theirs.length, mine.length);
             for(int i = 0; i < parts; i++) {
-                if(version_nums[i] > other.version_nums[i]) {
+                if(mine[i] > theirs[i]) {
                     return true;
                 }
-                if(version_nums[i] < other.version_nums[i]) {
+                if(mine[i] < theirs[i]) {
                     return false;
                 }
             }
+            // Common prefix equal: the version carrying extra non-zero
+            // components is greater — missing components count as zero
+            // (0.2.1 > 0.2, 0.2.0 == 0.2)
+            for(int i = parts; i < mine.length; i++) {
+                if(mine[i] > 0) {
+                    return true;
+                }
+            }
             // So far, equal
             if(release == null && other.release == null) {
                 return false;
@@ -104,15 +114,25 @@ namespace Usm {
         }
 
         public bool less_than(Version other) {
-            var parts = int.min(other.version_nums.length, version_nums.length);
+            var mine = version_nums ?? new int[0];
+            var theirs = other.version_nums ?? new int[0];
+            var parts = int.min(theirs.length, mine.length);
             for(int i = 0; i < parts; i++) {
-                if(version_nums[i] < other.version_nums[i]) {
+                if(mine[i] < theirs[i]) {
                     return true;
                 }
-                if(version_nums[i] > other.version_nums[i]) {
+                if(mine[i] > theirs[i]) {
                     return false;
                 }
             }
+            // Common prefix equal: the other version carrying extra
+            // non-zero components makes this one smaller — missing
+            // components count as zero (0.2 < 0.2.1, 0.2 == 0.2.0)
+            for(int i = parts; i < theirs.length; i++) {
+                if(theirs[i] > 0) {
+                    return true;
+                }
+            }
             // So far, equal
             if(release == null && other.release == null) {
                 return false;

+ 2 - 0
src/lib/meson.build

@@ -16,9 +16,11 @@ sources += files('VersionConstraint.vala')
 sources += files('Resolver.vala')
 sources += files('Util.vala')
 sources += files('Transaction.vala')
+sources += files('Interrupt.vala')
 sources += files('Journal.vala')
 sources += files('Configuration.vala')
 sources += files('SystemPackageManager.vala')
+sources += files('ProviderSelection.vala')
 sources += files('Ignore/UsmIgnore.vala')
 sources += files('TransactionSummary.vala')
 sources += files('Repository/Repository.vala')

+ 1 - 0
src/meson.build

@@ -18,4 +18,5 @@ install_data(spm_shims,
 
 subdir('lib')
 subdir('cli')
+subdir('rad')
 subdir('tests')

+ 100 - 0
src/rad/Deployer.vala

@@ -0,0 +1,100 @@
+namespace Usm.Rad {
+
+/**
+ * Deploys the tracked branch into an A/B slot on behalf of the check cycle:
+ * synchronises the repository, satisfies the manifest's dependencies and
+ * installs into a freshly wiped slot through the `usm` CLI with every
+ * stream inherited, so build and install output lands in the container log
+ * verbatim. A successful deployment flips the `current` symlink and records
+ * the deployed commit; a failure throws before the active slot or a
+ * running instance is disturbed.
+ */
+public class Deployer : Object {
+
+    Config config;
+    Slots slots;
+    Source source;
+    System system;
+    string usm_binary;
+
+    /**
+     * Prepares a deployer for the appdir configured in {@link config},
+     * deploying the branch tracked by {@link source} into the A/B layout
+     * modelled by {@link slots}, with dependencies satisfied through
+     * {@link system}; {@link usm_binary} is the `usm` executable spawned
+     * for the install itself.
+     */
+    public Deployer(Config config, Slots slots, Source source, System system, string usm_binary) {
+        this.config = config;
+        this.slots = slots;
+        this.source = source;
+        this.system = system;
+        this.usm_binary = usm_binary;
+    }
+
+    /**
+     * Deploys the tracked branch into {@link slot}, which the caller
+     * selects as the inactive one. Resets the repository to the remote
+     * head (a git failure propagates as {@link RadError.GIT}), satisfies
+     * the manifest's dependencies ({@link RadError.DEPENDENCY} when a
+     * resource cannot be provided), wipes and recreates the slot
+     * directory, then runs `usm manifest install <build path>` from within
+     * the repository with `USM_DESTDIR` steered at the slot ({@link
+     * RadError.DEPLOY} when the install fails). On success the `current`
+     * symlink is flipped to {@link slot} and the deployed commit recorded.
+     */
+    public async void deploy(string slot) throws RadError {
+        var slot_path = slots.slot_path(slot);
+        Log.info(@"Deploying \"$(config.branch)\" into slot \"$slot\"");
+
+        source.sync_to_remote();
+        yield system.ensure_deps(slots.repo_dir);
+
+        // The inactive slot is rebuilt from scratch so nothing from the
+        // previous deployment leaks into the new installation
+        try {
+            if (FileUtils.test(slot_path, FileTest.EXISTS)) {
+                Usm.Util.delete_tree(slot_path);
+            }
+            DirUtils.create_with_parents(slot_path, 0755);
+        }
+        catch (Error e) {
+            throw new RadError.DEPLOY(@"Could not reset the slot directory \"$slot_path\": $(e.message)");
+        }
+
+        var launcher = new SubprocessLauncher(SubprocessFlags.INHERIT_FDS);
+        launcher.set_cwd(slots.repo_dir);
+        launcher.setenv("USM_DESTDIR", slot_path, true);
+
+        Subprocess install;
+        try {
+            install = launcher.spawnv(new string[] { usm_binary, "manifest", "install", slots.build_dir });
+        }
+        catch (Error e) {
+            throw new RadError.DEPLOY(@"Could not spawn \"$(usm_binary) manifest install\": $(e.message)");
+        }
+
+        try {
+            install.wait_check();
+        }
+        catch (Error e) {
+            var failure = install.get_if_signaled()
+                ? @"was terminated by signal $(install.get_term_sig())"
+                : @"exited with code $(install.get_exit_status())";
+            throw new RadError.DEPLOY(@"usm manifest install failed ($failure)");
+        }
+
+        var deployed_commit = source.remote_head();
+        try {
+            slots.flip_current(slot);
+            slots.write_deployed_commit(deployed_commit);
+        }
+        catch (Error e) {
+            throw new RadError.DEPLOY(@"The install succeeded but slot \"$slot\" could not be activated: $(e.message)");
+        }
+
+        Log.info(@"Slot \"$slot\" is now current at commit $deployed_commit");
+    }
+}
+
+}

+ 179 - 0
src/rad/Slots.vala

@@ -0,0 +1,179 @@
+namespace Usm.Rad {
+
+/**
+ * The A/B slot layout under an application directory: two install roots,
+ * the working and build directories, the git checkout and the atomically
+ * flipped `current` symlink that marks the active slot.
+ *
+ * All state lives on disk — {@link read_current_slot} and
+ * {@link read_deployed_commit} derive it from the layout — so a restarted
+ * supervisor continues from whatever the previous one deployed.
+ */
+public class Slots : Object {
+
+    /** Directories of the layout, as ensured by {@link ensure_layout}. */
+    private const string[] LAYOUT_DIRECTORIES = { "a", "b", "pwd", "repo", "build" };
+
+    /** Slot-relative directories searched by {@link resolve_exec}, in order. */
+    private const string[] EXEC_SEARCH_DIRECTORIES = {
+        "usr/bin", "bin", "usr/sbin", "sbin", "usr/libexec"
+    };
+
+    private string appdir;
+
+    /** Path of the git checkout owned by usmrad (supervised by {@link Usm.Rad.Source}). */
+    public string repo_dir { owned get { return Path.build_filename(appdir, "repo"); } }
+
+    /** Path used as the working directory of the launched instance ({@link Slots} layout). */
+    public string pwd { owned get { return Path.build_filename(appdir, "pwd"); } }
+
+    /** Path of the persistent build tree, deliberately outside {@link repo_dir} so it survives resets. */
+    public string build_dir { owned get { return Path.build_filename(appdir, "build"); } }
+
+    /** Path of the symlink marking the active slot; flipped by {@link flip_current}. */
+    public string current_link { owned get { return Path.build_filename(appdir, "current"); } }
+
+    /** Path of the file recording the last deployed commit, read by {@link read_deployed_commit}. */
+    public string deployed_commit_path { owned get { return Path.build_filename(appdir, "deployed-commit"); } }
+
+    /**
+     * Roots the layout at {@link appdir}; nothing touches the filesystem
+     * until {@link ensure_layout} runs.
+     */
+    public Slots(string appdir) {
+        this.appdir = appdir;
+    }
+
+    /**
+     * Creates the five layout directories (see {@link LAYOUT_DIRECTORIES}),
+     * tolerating any that already exist.
+     */
+    public void ensure_layout() throws Error {
+        foreach(var name in LAYOUT_DIRECTORIES) {
+            try {
+                File.new_for_path(Path.build_filename(appdir, name)).make_directory_with_parents();
+            }
+            catch(Error e) {
+                if(!(e is IOError.EXISTS)) {
+                    throw e;
+                }
+            }
+        }
+    }
+
+    /**
+     * Resolves {@link current_link} and returns "a" or "b" when it names an
+     * existing slot, null when the link is missing, dangling or points
+     * elsewhere — the caller's cue that no previous good slot exists.
+     */
+    public string? read_current_slot() {
+        string target;
+        try {
+            target = FileUtils.read_link(current_link);
+        }
+        catch(Error e) {
+            return null;
+        }
+        var slot = Path.get_basename(target);
+        // A wiped slot directory must not count as deployable even when the
+        // link still names it
+        if(!is_valid_slot(slot) || !FileUtils.test(slot_path(slot), FileTest.IS_DIR)) {
+            return null;
+        }
+        return slot;
+    }
+
+    /**
+     * Returns the slot that {@link read_current_slot} does not name — the
+     * deployment target — or null when no slot is current.
+     */
+    public string? inactive_slot() {
+        var current = read_current_slot();
+        if(current == null) {
+            return null;
+        }
+        return current == "a" ? "b" : "a";
+    }
+
+    /**
+     * Points {@link current_link} at {@link slot} atomically: the new symlink
+     * is staged under a temporary name and moved over `current` with
+     * rename(2), so a reader always observes a complete link to one slot or
+     * the other, never a missing one.
+     */
+    public void flip_current(string slot) throws Error {
+        if(!is_valid_slot(slot)) {
+            throw new IOError.INVALID_ARGUMENT(@"Unknown slot \"$slot\" (expected \"a\" or \"b\")");
+        }
+        var staged_path = Path.build_filename(appdir, ".current.tmp");
+        try {
+            File.new_for_path(staged_path).delete();
+        }
+        catch(Error e) {
+            if(!(e is IOError.NOT_FOUND)) {
+                throw e;
+            }
+        }
+        // A relative target keeps the layout valid when the appdir moves wholesale
+        File.new_for_path(staged_path).make_symbolic_link(slot);
+        if(FileUtils.rename(staged_path, current_link) != 0) {
+            throw new FileError.FAILED(@"Could not move the staged link over \"current\": $(Posix.strerror(Posix.errno))");
+        }
+    }
+
+    /**
+     * Reads {@link deployed_commit_path}, tolerating a trailing newline;
+     * null when the file is missing or unreadable (nothing deployed yet).
+     */
+    public string? read_deployed_commit() {
+        string contents;
+        try {
+            FileUtils.get_contents(deployed_commit_path, out contents);
+        }
+        catch(Error e) {
+            return null;
+        }
+        var commit = contents.chomp();
+        return commit.length > 0 ? commit : null;
+    }
+
+    /**
+     * Records {@link sha} in {@link deployed_commit_path} as the commit of
+     * the last successful deployment.
+     */
+    public void write_deployed_commit(string sha) throws Error {
+        FileUtils.set_contents(deployed_commit_path, sha + "\n");
+    }
+
+    /**
+     * PATH-style search for {@link exec_name} within {@link slot}, trying
+     * each slot-relative bin directory in turn and returning the first
+     * candidate whose executable bit is set, null when none matches. An
+     * exec name containing "/" is rejected — usage rules allow bare names
+     * only.
+     */
+    public string? resolve_exec(string slot, string exec_name) {
+        if(exec_name.contains("/")) {
+            return null;
+        }
+        foreach(var directory in EXEC_SEARCH_DIRECTORIES) {
+            var candidate = Path.build_filename(slot_path(slot), directory, exec_name);
+            if(FileUtils.test(candidate, FileTest.IS_EXECUTABLE)) {
+                return candidate;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Absolute path of the install root for {@link slot} ("a" or "b").
+     */
+    public string slot_path(string slot) {
+        return Path.build_filename(appdir, slot);
+    }
+
+    private bool is_valid_slot(string slot) {
+        return slot == "a" || slot == "b";
+    }
+}
+}

+ 131 - 0
src/rad/Source.vala

@@ -0,0 +1,131 @@
+using Invercargill.DataStructures;
+
+namespace Usm.Rad {
+
+/**
+ * Supervision of the git checkout usmrad deploys from: an idempotent clone,
+ * a branch-tracked fetch, remote-head resolution and hard synchronisation
+ * of the working tree to the remote branch.
+ *
+ * The checkout lives at the {@link Slots.repo_dir} position of the appdir
+ * layout and is owned by usmrad — nothing else writes to it.
+ */
+public class Source : Object {
+
+    private string repo_url;
+    private string branch;
+    private string repo_dir;
+
+    /**
+     * Tracks {@link repo_url}'s {@link branch} inside {@link repo_dir},
+     * cloning on demand rather than in the constructor.
+     */
+    public Source(string repo_url, string branch, string repo_dir) {
+        this.repo_url = repo_url;
+        this.branch = branch;
+        this.repo_dir = repo_dir;
+    }
+
+    /**
+     * Clones {@link repo_url} into {@link repo_dir} on the tracked branch
+     * when the checkout does not already exist; an existing checkout is a
+     * no-op. Clone progress streams into the container log.
+     */
+    public void ensure_cloned() throws RadError {
+        if(FileUtils.test(Path.build_filename(repo_dir, ".git"), FileTest.EXISTS)) {
+            return;
+        }
+        var command = new Vector<string>();
+        command.add("git");
+        command.add("clone");
+        command.add("--branch");
+        command.add(branch);
+        command.add(repo_url);
+        command.add(repo_dir);
+        run_command(Argv.from_vector(command));
+    }
+
+    /**
+     * Fetches the tracked branch from origin, leaving the working tree for
+     * {@link sync_to_remote} to move.
+     */
+    public void fetch() throws RadError {
+        run_git({ "fetch", "origin", branch });
+    }
+
+    /**
+     * Resolves the tracked branch's head on origin, chomped; throws
+     * {@link RadError.GIT} carrying git's own stderr when the ref cannot
+     * be read.
+     */
+    public string remote_head() throws RadError {
+        var command = new Vector<string>();
+        command.add("git");
+        command.add("-C");
+        command.add(repo_dir);
+        command.add("rev-parse");
+        command.add(@"origin/$branch");
+        try {
+            // Read-only: capture both streams so the thrown message keeps
+            // git's diagnostics instead of splicing them into the log
+            var proc = new Subprocess.newv(Argv.from_vector(command),
+                SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+            string git_stdout;
+            string git_stderr;
+            proc.communicate_utf8(null, null, out git_stdout, out git_stderr);
+            if(proc.get_exit_status() != 0) {
+                var detail = git_stderr.chomp();
+                if(detail.length > 0) {
+                    throw new RadError.GIT(@"git rev-parse origin/$branch failed: $detail");
+                }
+                throw new RadError.GIT(@"git rev-parse origin/$branch failed");
+            }
+            return git_stdout.chomp();
+        }
+        catch(RadError e) {
+            throw e;
+        }
+        catch(Error e) {
+            throw new RadError.GIT(@"Could not resolve \"origin/$branch\": $(e.message)");
+        }
+    }
+
+    /**
+     * Synchronises the working tree to the tracked branch's remote head:
+     * `reset --hard` discards local edits and `clean -xfd` removes
+     * untracked files, so every build starts from the reviewed state.
+     */
+    public void sync_to_remote() throws RadError {
+        run_git({ "reset", "--hard", @"origin/$branch" });
+        run_git({ "clean", "-xfd" });
+    }
+
+    /**
+     * Runs git with {@link arguments} scoped to the checkout directory.
+     */
+    private void run_git(string[] arguments) throws RadError {
+        var command = new Vector<string>();
+        command.add("git");
+        command.add("-C");
+        command.add(repo_dir);
+        foreach(var argument in arguments) {
+            command.add(argument);
+        }
+        run_command(Argv.from_vector(command));
+    }
+
+    /**
+     * Waits for {@link command} to succeed with inherited fds, so git's
+     * progress and diagnostics land in the container log verbatim.
+     */
+    private void run_command(string[] command) throws RadError {
+        try {
+            new Subprocess.newv(command, SubprocessFlags.INHERIT_FDS).wait_check();
+        }
+        catch(Error e) {
+            var joined = string.joinv(" ", command);
+            throw new RadError.GIT(@"Could not run \"$joined\": $(e.message)");
+        }
+    }
+}
+}

+ 200 - 0
src/rad/Supervisor.vala

@@ -0,0 +1,200 @@
+namespace Usm.Rad {
+
+/**
+ * Owns the supervised application process: launches it with inherited
+ * descriptors from a fixed working directory, reports spontaneous exits,
+ * and terminates it on request — SIGTERM, a grace period, then SIGKILL —
+ * on both the slot-swap path and the SIGTERM/SIGINT shutdown path, never
+ * mistaking a deliberate termination for a spontaneous exit. Only one
+ * child is supervised at a time.
+ */
+public class Supervisor : Object {
+
+    /**
+     * Emitted when the supervised process exits without the supervisor
+     * terminating it; the status is the child's exit code, or 128 plus the
+     * signal number when it died by a signal. Deliberate terminations —
+     * {@link terminate} and the shutdown path armed by {@link run} — are
+     * suppressed and reported through {@link shutdown} instead.
+     */
+    public signal void exited(int status);
+
+    /**
+     * Emitted once a SIGTERM or SIGINT received by usmrad has been
+     * forwarded and the child is gone (grace period elapsed, SIGKILL if
+     * needed) — or immediately when no child was running.
+     */
+    public signal void shutdown(int signal_number);
+
+    string working_directory;
+    int term_grace_seconds;
+    Subprocess child = null;
+    bool suppress_exit = false;
+
+    /** Notifies a pending {@link stop_child} that the child was reaped. */
+    signal void reaped();
+
+    /**
+     * Whether a supervised child is currently alive; false until
+     * {@link launch} succeeds and once the child has been reaped.
+     */
+    public bool running {
+        get {
+            return child != null;
+        }
+    }
+
+    /**
+     * Prepares supervision of children launched in the given working
+     * directory, allowing the given number of seconds between SIGTERM and
+     * SIGKILL on both termination paths.
+     */
+    public Supervisor(string working_directory, int term_grace_seconds) {
+        this.working_directory = working_directory;
+        this.term_grace_seconds = term_grace_seconds;
+    }
+
+    /**
+     * Installs the SIGTERM and SIGINT handlers: each is forwarded to the
+     * child as an immediate SIGTERM, escalated to a SIGKILL after the
+     * grace period, and followed by {@link shutdown} once the child is
+     * gone. With no child running, {@link shutdown} is emitted
+     * immediately. Call before the main loop runs.
+     */
+    public void run() {
+        Unix.signal_add(Posix.SIGTERM, () => {
+            graceful_shutdown.begin(Posix.SIGTERM);
+            return false;
+        });
+        Unix.signal_add(Posix.SIGINT, () => {
+            graceful_shutdown.begin(Posix.SIGINT);
+            return false;
+        });
+    }
+
+    /**
+     * Launches {@link executable} with {@link args} as the supervised
+     * child, in the supervisor's working directory and with every file
+     * descriptor inherited so the application's output streams into the
+     * container log; arms the exit watch that reports spontaneous exits
+     * through {@link exited}.
+     */
+    public void launch(string executable, string[] args) throws Error {
+        if (child != null) {
+            Log.warning("Launching while the previous supervised instance is still running; it is not terminated");
+        }
+
+        var argv = new string[1 + args.length];
+        argv[0] = executable;
+        for (var index = 0; index < args.length; index++) {
+            argv[index + 1] = args[index];
+        }
+
+        var launcher = new SubprocessLauncher(SubprocessFlags.INHERIT_FDS);
+        launcher.set_cwd(working_directory);
+        child = launcher.spawnv(argv);
+        child.wait_async.begin(null, on_wait_complete);
+    }
+
+    /**
+     * Terminates the supervised child for a slot swap: SIGTERM, then
+     * SIGKILL once the grace period elapses, resolving only when the child
+     * is gone. The exit is suppressed — never reported through
+     * {@link exited}. A no-op when no child is running.
+     */
+    public async void terminate() {
+        if (child == null) {
+            return;
+        }
+        Log.info("Terminating the supervised instance (SIGTERM)");
+        yield stop_child();
+    }
+
+    /**
+     * The signal-shutdown path: forward SIGTERM to the child, wait out the
+     * grace period with a SIGKILL escalation, then emit {@link shutdown};
+     * with no child running the shutdown is emitted straight away.
+     */
+    private async void graceful_shutdown(int signal_number) {
+        var signal_name = signal_number == Posix.SIGTERM ? "SIGTERM" : "SIGINT";
+        if (child != null) {
+            Log.info(@"Received $signal_name; forwarding SIGTERM to the supervised instance");
+            yield stop_child();
+        }
+        else {
+            Log.info(@"Received $signal_name with no supervised instance running");
+        }
+        shutdown(signal_number);
+    }
+
+    /**
+     * The shared termination core: suppress the pending exit, SIGTERM the
+     * child and SIGKILL it after the grace period (immediately for a zero
+     * grace), resuming once the reaped notification fires.
+     */
+    private async void stop_child() {
+        var proc = child;
+        if (proc == null) {
+            return;
+        }
+
+        suppress_exit = true;
+        proc.send_signal(Posix.SIGTERM);
+
+        ulong handler_id = 0;
+        handler_id = reaped.connect(() => {
+            SignalHandler.disconnect(this, handler_id);
+            Idle.add(stop_child.callback);
+        });
+
+        if (term_grace_seconds > 0) {
+            Timeout.add_seconds(term_grace_seconds, () => {
+                // Only the child this stop belongs to may be killed: a
+                // newer instance launched after a completed swap must
+                // survive a stale grace timer
+                if (child == proc) {
+                    Log.info(@"The supervised instance did not exit within $(term_grace_seconds) seconds; sending SIGKILL");
+                    proc.force_exit();
+                }
+                return false;
+            });
+        }
+        else {
+            proc.force_exit();
+        }
+
+        yield;
+        suppress_exit = false;
+    }
+
+    /**
+     * Completes the exit watch armed by {@link launch}: reaps the child
+     * and either resumes a pending {@link stop_child} (the exit was
+     * suppressed) or reports a spontaneous exit through {@link exited}
+     * with the child's exit code, or 128 plus the signal number when it
+     * died by a signal. Stale watches from replaced children are ignored.
+     */
+    private void on_wait_complete(Object? source_object, AsyncResult result) {
+        var proc = (Subprocess) source_object;
+        try {
+            proc.wait_async.end(result);
+        }
+        catch (Error e) {
+            Log.warning(@"Could not reap the supervised process: $(e.message)");
+        }
+
+        if (proc != child) {
+            return;
+        }
+
+        child = null;
+        if (suppress_exit) {
+            reaped();
+            return;
+        }
+
+        exited(proc.get_if_signaled() ? 128 + proc.get_term_sig() : proc.get_exit_status());
+    }
+}
+
+}

+ 394 - 0
src/rad/System.vala

@@ -0,0 +1,394 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Usm.Rad {
+
+/**
+ * USM system integration for the rapid-deployment supervisor.
+ *
+ * The cheap queries ({@link Usm.ResourceFinder}, {@link Usm.Manifest}
+ * parsing, {@link Usm.SystemPackageManager}) run in-process against
+ * libusm, while every stateful operation (enrolment, repository wiring,
+ * updates, installs) spawns the `usm` CLI with inherited fds so its
+ * progress and errors land in the container log stream verbatim.
+ */
+public class System : Object {
+
+    // The enrolled managed-state root; src/cli/Enroll.vala keeps its copy
+    // private, so the default is replicated here for the snapshot fallback
+    private const string DEFAULT_STATE_PATH = "/var/usm";
+
+    private Config config;
+    private string usm_binary;
+
+    /**
+     * Creates an integration bound to the supervisor's {@link Config} and
+     * the resolved `usm` executable, which every spawned operation uses.
+     */
+    public System(Config config, string usm_binary) {
+        this.config = config;
+        this.usm_binary = usm_binary;
+    }
+
+    /**
+     * Ensures this system is managed by usm by spawning
+     * `usm enroll -y`. Enrolment is idempotent on the usm side (the image
+     * may already be pre-enrolled), so this is safe to run at every
+     * startup; a non-zero exit throws {@link RadError.DEPLOY} carrying the
+     * exit status.
+     */
+    public void ensure_enrolled() throws RadError {
+        Log.info("Enrolling the system with usm");
+        run_usm(new string[] { "enroll", "-y" });
+    }
+
+    /**
+     * Wires every {@link Config.usm_repos} entry into usm's repos.d by
+     * spawning `usm add-repo -y <entry>` per repository. The first failing
+     * entry throws {@link RadError.DEPLOY} naming it — whether that is
+     * fatal is the caller's decision (the supervisor warns and continues).
+     */
+    public void wire_repos() throws RadError {
+        foreach(var entry in config.usm_repos) {
+            Log.info(@"Wiring USM repository \"$entry\"");
+            try {
+                run_usm(new string[] { "add-repo", "-y", entry });
+            }
+            catch(RadError e) {
+                throw new RadError.DEPLOY(@"Could not wire USM repository \"$entry\": $(e.message)");
+            }
+        }
+    }
+
+    /**
+     * Refreshes repository listings and updates installed packages by
+     * spawning `usm update -y`, then reports whether the set of installed
+     * USM packages changed (the supervisor's trigger for redeploying
+     * behind an unchanged commit).
+     *
+     * The snapshot names and versions every installed package through
+     * {@link Usm.SystemState}; an unenrolled or unreadable state falls
+     * back to listing the managed `installed` marks directory, and an
+     * unreadable directory degrades to an empty snapshot with a warning —
+     * a failed comparison at worst triggers one redundant deployment.
+     */
+    public async bool refresh_and_update() throws RadError {
+        var before = installed_packages_snapshot();
+        Log.info("Refreshing USM repositories and updating installed packages");
+        yield run_usm_async(new string[] { "update", "-y" });
+        var after = installed_packages_snapshot();
+        return before != after;
+    }
+
+    /**
+     * Satisfies the dependencies of the manifest in {@link repo_dir}
+     * before `usm manifest install` runs (that command exits 252 on
+     * unmet dependencies). The locally-missing refs of the manifest's
+     * build, manage and runtime phases are handed to the shared
+     * {@link Usm.Resolver.resolve_manifest} — the same engine
+     * `usm install` uses — so provider selection behaves identically
+     * everywhere: one conflict-aware provider per resource (querying
+     * `conflicts` metadata and verifying the finished set with the
+     * SPM's own solver), never the union of every provider that
+     * happens to answer (dropbear AND openssh for `bin:ssh` would
+     * fight over /bin/ssh). The resolved USM packages are installed
+     * with `usm install -y` and the chosen system packages with one
+     * {@link Usm.SystemPackageManager.install} transaction. A fresh
+     * finder re-checks the originally-missing refs afterwards; refs
+     * still missing throw {@link RadError.DEPENDENCY}.
+     *
+     * The system transaction runs BEFORE the USM installs because the
+     * resolver's contract is "system packages as one transaction
+     * before any USM package": `usm install`'s own dependency check
+     * must already see the resources the native packages provide.
+     *
+     * The root manifest itself is excluded from the USM install list —
+     * the repo is the thing being deployed and built here, not a
+     * package to install.
+     */
+    public async void ensure_deps(string repo_dir) throws RadError {
+        var manifest_path = Path.build_filename(repo_dir, "MANIFEST.usm");
+        Usm.Manifest manifest;
+        try {
+            manifest = new Usm.Manifest.from_file(manifest_path);
+        }
+        catch(Error e) {
+            throw new RadError.DEPLOY(@"Could not read \"$manifest_path\": $(e.message)");
+        }
+
+        // The originally-missing ref set, recomputed exactly as the
+        // closing re-check expects: resolution may satisfy a ref with
+        // a provider whose own deps are also pulled in, but only these
+        // refs are what the deployment actually needs proven present
+        var seen = new HashSet<Usm.ResourceRef>();
+        var refs = new Vector<Usm.ResourceRef>();
+        var dependencies = manifest.dependencies ?? new Usm.Dependencies();
+        collect_phase_refs(dependencies.manage, seen, refs);
+        collect_phase_refs(dependencies.build, seen, refs);
+        collect_phase_refs(dependencies.runtime, seen, refs);
+
+        var finder = new Usm.ResourceFinder();
+        var missing = refs.where(r => !finder.has_resource(r)).to_vector();
+        if(missing.length == 0) {
+            return;
+        }
+        Log.info(@"$(missing.length) dependency ref(s) of \"$(manifest.name)\" missing");
+
+        var spm = load_system_package_manager();
+        Usm.SystemPackageManager? active_spm = null;
+        if(spm != null && spm.enabled) {
+            active_spm = spm;
+        }
+        else {
+            Log.warning("No system package manager is configured; resolving missing dependencies from USM repositories alone");
+        }
+
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        Usm.ResolutionResult resolution;
+        try {
+            resolution = resolver.resolve_manifest(manifest, active_spm);
+        }
+        catch(Error e) {
+            // ResolverError messages are already fully itemised,
+            // per-ref reports, so they are appended verbatim
+            throw new RadError.DEPLOY(@"Could not resolve the dependencies of \"$(manifest.name)\": $(e.message)");
+        }
+
+        // The resolver's package set carries the root manifest's own
+        // AbstractPackage; exclude it by manifest identity (the
+        // resolver wraps the very object parsed above) — the repo is
+        // being built, not installed
+        var usm_names = new Vector<string>();
+        foreach(var package in resolution.packages) {
+            if(package.manifest == manifest) {
+                continue;
+            }
+            if(!usm_names.contains(package.manifest.name)) {
+                usm_names.add(package.manifest.name);
+            }
+        }
+
+        // Already ONE selected provider per resource, sorted by name;
+        // installing exactly these as a single transaction is the
+        // whole point of delegating to the resolver
+        var spm_names = new Vector<string>();
+        foreach(var candidate in resolution.system_packages) {
+            spm_names.add(candidate.name);
+        }
+
+        if(spm_names.length > 0) {
+            Log.info(@"Installing system packages: $(string.joinv(" ", Argv.from_vector(spm_names)))");
+            var system_installed = false;
+            try {
+                system_installed = yield spm.install(spm_names, event => log_install_progress(event));
+            }
+            catch(Error e) {
+                throw new RadError.DEPLOY(@"Could not run the system package manager install: $(e.message)");
+            }
+            if(!system_installed) {
+                throw new RadError.DEPLOY(@"The system package manager failed to install: $(string.joinv(" ", Argv.from_vector(spm_names)))");
+            }
+        }
+
+        if(usm_names.length > 0) {
+            Log.info(@"Installing USM packages: $(string.joinv(" ", Argv.from_vector(usm_names)))");
+            var arguments = new Vector<string>();
+            arguments.add("install");
+            arguments.add("-y");
+            arguments.add_all(usm_names);
+            yield run_usm_async(Argv.from_vector(arguments));
+        }
+
+        var recheck = new Usm.ResourceFinder();
+        var unmet = missing.where(r => !recheck.has_resource(r)).to_vector();
+        if(unmet.length > 0) {
+            var listing = new StringBuilder();
+            foreach(var dependency in unmet) {
+                if(listing.len > 0) {
+                    listing.append(", ");
+                }
+                listing.append(dependency.to_string());
+            }
+            throw new RadError.DEPENDENCY(@"Dependencies not satisfied after installation: $(listing.str)");
+        }
+    }
+
+    /**
+     * Spawns `usm` with inherited fds and waits for it, throwing
+     * {@link RadError.DEPLOY} with the exit-status (or signal) detail on
+     * failure.
+     */
+    private void run_usm(string[] arguments) throws RadError {
+        var argv = usm_argv(arguments);
+        var command = string.joinv(" ", argv);
+        Subprocess process;
+        try {
+            process = new Subprocess.newv(argv, SubprocessFlags.INHERIT_FDS);
+        }
+        catch(Error e) {
+            throw new RadError.DEPLOY(@"Could not spawn \"$command\": $(e.message)");
+        }
+        process.wait();
+        check_usm_exit(command, process);
+    }
+
+    /** The asynchronous twin of {@link run_usm} for long-running updates. */
+    private async void run_usm_async(string[] arguments) throws RadError {
+        var argv = usm_argv(arguments);
+        var command = string.joinv(" ", argv);
+        Subprocess process;
+        try {
+            process = new Subprocess.newv(argv, SubprocessFlags.INHERIT_FDS);
+        }
+        catch(Error e) {
+            throw new RadError.DEPLOY(@"Could not spawn \"$command\": $(e.message)");
+        }
+        try {
+            yield process.wait_async(null);
+        }
+        catch(Error e) {
+            throw new RadError.DEPLOY(@"Could not wait for \"$command\": $(e.message)");
+        }
+        check_usm_exit(command, process);
+    }
+
+    /** Prepends the resolved usm executable to a command's arguments. */
+    private string[] usm_argv(string[] arguments) {
+        var argv = new string[arguments.length + 1];
+        argv[0] = usm_binary;
+        for(int index = 0; index < arguments.length; index++) {
+            argv[index + 1] = arguments[index];
+        }
+        return argv;
+    }
+
+    /** Throws {@link RadError.DEPLOY} naming {@link command} unless it exited cleanly. */
+    private static void check_usm_exit(string command, Subprocess process) throws RadError {
+        if(process.get_if_signaled()) {
+            throw new RadError.DEPLOY(@"\"$command\" was terminated by signal $(process.get_term_sig())");
+        }
+        if(process.get_exit_status() != 0) {
+            throw new RadError.DEPLOY(@"\"$command\" exited with status $(process.get_exit_status())");
+        }
+    }
+
+    /**
+     * Collects every ref of one dependency phase (the union across
+     * candidate groups for grouped phases) into {@link refs}, skipping
+     * refs already collected through an earlier phase.
+     */
+    private static void collect_phase_refs(Usm.DependencyPhase? phase, HashSet<Usm.ResourceRef> seen, Vector<Usm.ResourceRef> refs) {
+        if(phase == null) {
+            return;
+        }
+        foreach(var resource in phase.all_refs()) {
+            if(seen.contains(resource)) {
+                continue;
+            }
+            seen.add(resource);
+            refs.add(resource);
+        }
+    }
+
+    /**
+     * The system package manager for the usm configuration, or null when
+     * the configuration cannot be read — dependency resolution then runs
+     * against USM repositories alone.
+     */
+    private static Usm.SystemPackageManager? load_system_package_manager() {
+        try {
+            return new Usm.SystemPackageManager(new Usm.Configuration.from_paths(new Usm.Paths()));
+        }
+        catch(Error e) {
+            Log.warning(@"Could not read the usm configuration, continuing without a system package manager: $(e.message)");
+            return null;
+        }
+    }
+
+    /**
+     * One line per streamed system package install event; the per-package
+     * progress fraction events stay unlogged to keep the supervisor's
+     * output readable.
+     */
+    private static void log_install_progress(Usm.SystemInstallEvent event) {
+        switch(event.event_type) {
+            case Usm.SystemInstallEventType.BEGIN:
+                Log.info(@"System package transaction started ($(event.total) packages)");
+                break;
+            case Usm.SystemInstallEventType.PACKAGE_COMPLETE:
+                Log.info(@"Installed system package \"$(event.name ?? "")\"");
+                break;
+            case Usm.SystemInstallEventType.COMPLETE:
+                Log.info(@"System package transaction completed ($(event.installed) packages installed)");
+                break;
+            case Usm.SystemInstallEventType.ERROR:
+                Log.warning(@"System package install failed: $(event.message ?? "")");
+                break;
+            default:
+                break;
+        }
+    }
+
+    /**
+     * A deterministic fingerprint of the installed-package set: sorted
+     * `name-version` lines. An unreadable managed state degrades to
+     * {@link installed_marks_snapshot}.
+     */
+    private static string installed_packages_snapshot() {
+        var entries = new Vector<string>();
+        try {
+            var state = new Usm.SystemState(new Usm.Paths());
+            foreach(var installed in state.get_installed_packages()) {
+                try {
+                    var manifest = installed.get_manifest();
+                    entries.add(@"$(manifest.name)-$(manifest.version.to_string())");
+                }
+                catch(Error e) {
+                    Log.warning(@"Skipping unreadable installed package \"$(installed.package_name)\": $(e.message)");
+                }
+            }
+        }
+        catch(Error e) {
+            return installed_marks_snapshot();
+        }
+        return string.joinv("\n", entries.sort((a, b) => a.collate(b)).to_array());
+    }
+
+    /**
+     * The snapshot fallback for an unenrolled or unreadable state: the
+     * names of the `installed` marks directory, which are themselves
+     * `name-version`. An unreadable directory warns and yields an empty
+     * snapshot.
+     */
+    private static string installed_marks_snapshot() {
+        var entries = new Vector<string>();
+        try {
+            foreach(var mark in Iterate.directory(Path.build_filename(managed_state_path(), "installed"))) {
+                entries.add(mark);
+            }
+        }
+        catch(Error e) {
+            Log.warning(@"Could not list the usm installed-packages directory, treating the snapshot as empty: $(e.message)");
+            return "";
+        }
+        return string.joinv("\n", entries.sort((a, b) => a.collate(b)).to_array());
+    }
+
+    /**
+     * The managed state root from usm.config's managed section, or the
+     * enrolment default when no readable configuration carries one.
+     */
+    private static string managed_state_path() {
+        try {
+            var configuration = new Usm.Configuration.from_paths(new Usm.Paths());
+            if(configuration.managed_config != null && configuration.managed_config.state_path != null) {
+                return configuration.managed_config.state_path;
+            }
+        }
+        catch(Error e) {
+            // Unenrolled or unreadable: the enrolment default below
+        }
+        return DEFAULT_STATE_PATH;
+    }
+}
+}

+ 427 - 0
src/rad/Usmrad.vala

@@ -0,0 +1,427 @@
+/**
+ * usmrad — USM Rapid Application Deployment supervisor.
+ *
+ * Tracks a git branch, builds and installs the branch's manifest into an
+ * A/B slot layout under the application directory, and keeps an instance of
+ * the application's executable running from the active slot. A deployment
+ * failure never disturbs a running instance; a successful deployment
+ * terminates the old instance and launches the new one. When the supervised
+ * executable exits, usmrad exits with its exit code.
+ *
+ * Configuration is entirely environmental; see {@link Config}.
+ */
+namespace Usm.Rad {
+
+/**
+ * Failure modes of the rapid-deployment supervisor.
+ */
+public errordomain RadError {
+    /** A USMRAD_* environment variable is missing or malformed */
+    CONFIGURATION,
+    /** A git operation (clone, fetch, reset) failed */
+    GIT,
+    /** A build or install step failed */
+    DEPLOY,
+    /** A dependency resource could not be satisfied by the SPM or USM repositories */
+    DEPENDENCY
+}
+
+/**
+ * Bracketed-origin logging for the usmrad supervisor, per STYLE.md.
+ */
+public class Log {
+
+    /**
+     * Prints an informational line to stdout with the {@link Log} origin
+     * prefix.
+     */
+    public static void info(string message) {
+        print("[usmrad] %s\n", message);
+    }
+
+    /**
+     * Prints a warning line to stderr with the {@link Log} origin prefix.
+     */
+    public static void warning(string message) {
+        printerr("[usmrad] %s\n", message);
+    }
+}
+
+/**
+ * argv construction for spawned commands and joined argument strings.
+ *
+ * Invercargill's {@link Invercargill.DataStructures.Vector.to_array} returns
+ * exactly-sized arrays without the NULL sentinel GLib's spawn helpers and
+ * {@link string.joinv} require — an unterminated argv crashes inside
+ * g_subprocess_newv's g_strdupv. Every vector-built argument list therefore
+ * flows through {@link from_vector}, whose trailing zeroed slot terminates
+ * the array the same way a Vala literal or explicit `new string[n]` fill
+ * does.
+ */
+public class Argv {
+
+    /**
+     * A NULL-terminated copy of {@link command}: one slot longer than the
+     * vector, with the final slot left zeroed as the sentinel.
+     */
+    public static string[] from_vector(Invercargill.DataStructures.Vector<string> command) {
+        var size = (int)command.length;
+        var argv = new string[size + 1];
+        var index = 0;
+        foreach(var argument in command) {
+            argv[index++] = argument;
+        }
+        return argv;
+    }
+}
+
+/**
+ * usmrad configuration, parsed from the USMRAD_* environment.
+ */
+public class Config : Object {
+
+    /** Git repository URL to deploy (USMRAD_REPO, required). */
+    public string repo { get; set; }
+    /** Branch to track (USMRAD_BRANCH, default "master"). */
+    public string branch { get; set; default = "master"; }
+    /** Poll interval in minutes; 0 disables polling (USMRAD_POLL). */
+    public double poll_minutes { get; set; default = 0; }
+    /** Application directory (USMRAD_APPDIR, default "/app"). */
+    public string appdir { get; set; default = "/app"; }
+    /** USMR URIs wired into usm at startup (USMRAD_USM_REPOS, semicolon-delimited). */
+    public string[] usm_repos { get; set; default = new string[] {}; }
+    /** Seconds between SIGTERM and SIGKILL when terminating an instance (USMRAD_TERM_GRACE). */
+    public int term_grace_seconds { get; set; default = 10; }
+
+    /**
+     * Poll interval in whole seconds; 0 when polling is disabled. Fractional
+     * minutes round down, so USMRAD_POLL=0.25 polls every 15 seconds.
+     */
+    public uint poll_interval_seconds {
+        get {
+            return (uint)(poll_minutes * 60.0);
+        }
+    }
+
+    /**
+     * Reads the USMRAD_* environment applying defaults, throwing
+     * {@link RadError.CONFIGURATION} with a user-facing message when a
+     * variable is missing or malformed.
+     */
+    public static Config from_environment() throws RadError {
+        var config = new Config();
+
+        var repo = Environment.get_variable("USMRAD_REPO");
+        if(repo == null || repo.strip().length == 0) {
+            throw new RadError.CONFIGURATION("USMRAD_REPO is required (a git repository URL)");
+        }
+        config.repo = repo.strip();
+
+        var branch = Environment.get_variable("USMRAD_BRANCH");
+        if(branch != null && branch.strip().length > 0) {
+            config.branch = branch.strip();
+        }
+
+        var poll = Environment.get_variable("USMRAD_POLL");
+        if(poll != null && poll.strip().length > 0) {
+            double parsed_poll;
+            if(!double.try_parse(poll.strip(), out parsed_poll) || parsed_poll < 0) {
+                throw new RadError.CONFIGURATION(@"USMRAD_POLL must be a non-negative number of minutes, got \"$poll\"");
+            }
+            config.poll_minutes = parsed_poll;
+        }
+
+        var appdir = Environment.get_variable("USMRAD_APPDIR");
+        if(appdir != null && appdir.strip().length > 0) {
+            config.appdir = appdir.strip();
+        }
+
+        var repos = Environment.get_variable("USMRAD_USM_REPOS");
+        if(repos != null && repos.strip().length > 0) {
+            var entries = new Invercargill.DataStructures.Vector<string>();
+            foreach(var entry in repos.split(";")) {
+                if(entry.strip().length > 0) {
+                    entries.add(entry.strip());
+                }
+            }
+            config.usm_repos = entries.to_array();
+        }
+
+        var grace = Environment.get_variable("USMRAD_TERM_GRACE");
+        if(grace != null && grace.strip().length > 0) {
+            int64 parsed_grace;
+            if(!int64.try_parse(grace.strip(), out parsed_grace) || parsed_grace < 0) {
+                throw new RadError.CONFIGURATION(@"USMRAD_TERM_GRACE must be a non-negative number of seconds, got \"$grace\"");
+            }
+            config.term_grace_seconds = (int)parsed_grace;
+        }
+
+        return config;
+    }
+}
+
+Config rad_config;
+Slots rad_slots;
+Source rad_source;
+System rad_system;
+Deployer rad_deployer;
+Supervisor rad_supervisor;
+MainLoop rad_loop;
+string rad_exec_name;
+string[] rad_exec_args;
+int rad_exit_code = 0;
+bool rad_cycle_busy = false;
+bool rad_instance_running = false;
+
+// The test target links every rad source for pure-logic coverage; only the
+// real executable may own the process entry point
+#if !USMRAD_TESTS
+public static int main(string[] args) {
+    if(args.length < 2 || args[1].has_prefix("-")) {
+        usage();
+        return 255;
+    }
+    rad_exec_name = args[1];
+    rad_exec_args = args[2:args.length];
+
+    try {
+        rad_config = Config.from_environment();
+    }
+    catch(RadError e) {
+        Log.warning(e.message);
+        usage();
+        return 255;
+    }
+
+    rad_slots = new Slots(rad_config.appdir);
+    try {
+        rad_slots.ensure_layout();
+    }
+    catch(Error e) {
+        Log.warning(@"Could not create the appdir layout in \"$(rad_config.appdir)\": $(e.message)");
+        return 255;
+    }
+
+    var usm_binary = find_usm_binary();
+    if(usm_binary == null) {
+        Log.warning("Could not locate the usm executable next to usmrad or on PATH");
+        return 255;
+    }
+
+    // Every spawned usm (and its nested build scripts) streams management
+    // script output, so failures surface in the container log verbatim
+    Environment.set_variable("USM_VERBOSE", "1", true);
+
+    rad_source = new Source(rad_config.repo, rad_config.branch, rad_slots.repo_dir);
+    rad_system = new System(rad_config, usm_binary);
+    rad_deployer = new Deployer(rad_config, rad_slots, rad_source, rad_system, usm_binary);
+    rad_supervisor = new Supervisor(rad_slots.pwd, rad_config.term_grace_seconds);
+
+    rad_loop = new MainLoop();
+    rad_supervisor.exited.connect(status => {
+        rad_exit_code = status;
+        rad_loop.quit();
+    });
+    rad_supervisor.shutdown.connect(signal_number => {
+        rad_exit_code = 128 + signal_number;
+        rad_loop.quit();
+    });
+    rad_supervisor.run();
+
+    // Launch the previous good instance first so a running deployment cycle
+    // never adds downtime for an instance that already works
+    rad_instance_running = launch_from_current();
+    if(rad_instance_running) {
+        Log.info(@"Launched previous instance from slot \"$(rad_slots.read_current_slot())\"");
+    }
+
+    Idle.add(() => {
+        startup_cycle.begin();
+        return false;
+    });
+
+    if(rad_config.poll_interval_seconds > 0) {
+        Timeout.add_seconds(rad_config.poll_interval_seconds, () => {
+            run_cycle.begin();
+            return true;
+        });
+    }
+
+    rad_loop.run();
+    return rad_exit_code;
+}
+
+/**
+ * Runs the startup check cycle: enrollment, repository wiring and one
+ * deployment check. With no previous instance a failed first deployment is
+ * fatal (exit 251); with one, failures merely warn.
+ */
+private static async void startup_cycle() {
+    try {
+        rad_system.ensure_enrolled();
+    }
+    catch(RadError e) {
+        Log.warning(@"usm enroll failed: $(e.message)");
+        rad_exit_code = 245;
+        rad_loop.quit();
+        return;
+    }
+
+    try {
+        rad_system.wire_repos();
+    }
+    catch(RadError e) {
+        Log.warning(@"Could not wire USMRAD_USM_REPOS repositories: $(e.message); continuing");
+    }
+
+    var deployed = yield run_cycle();
+    if(!deployed && !rad_instance_running) {
+        Log.warning("First deployment failed and no previous instance exists");
+        rad_exit_code = 251;
+        rad_loop.quit();
+    }
+}
+
+/**
+ * Runs one check cycle, guarded against re-entry: synchronise the source
+ * repository, refresh and update USM packages, and when new commits or
+ * package updates are available, deploy into the inactive slot and swap.
+ * Returns true when a deployment went through and a new instance launched.
+ */
+private static async bool run_cycle() {
+    if(rad_cycle_busy) {
+        return false;
+    }
+    rad_cycle_busy = true;
+    var deployed = false;
+    try {
+        deployed = yield run_cycle_inner();
+    }
+    finally {
+        rad_cycle_busy = false;
+    }
+    return deployed;
+}
+
+private static async bool run_cycle_inner() {
+    try {
+        rad_source.ensure_cloned();
+        rad_source.fetch();
+    }
+    catch(RadError e) {
+        Log.warning(@"Could not synchronise \"$(rad_config.repo)\": $(e.message); skipping cycle");
+        return false;
+    }
+
+    var deployed_commit = rad_slots.read_deployed_commit();
+    string remote_head = null;
+    try {
+        remote_head = rad_source.remote_head();
+    }
+    catch(RadError e) {
+        Log.warning(@"Could not resolve the remote head: $(e.message); skipping cycle");
+        return false;
+    }
+    var new_commits = deployed_commit == null || deployed_commit != remote_head;
+
+    var deps_changed = false;
+    try {
+        deps_changed = yield rad_system.refresh_and_update();
+    }
+    catch(RadError e) {
+        Log.warning(@"usm update failed: $(e.message); continuing with the commit check");
+    }
+
+    if(!new_commits && !deps_changed) {
+        return false;
+    }
+
+    Log.info(new_commits
+        ? @"New commits on \"$(rad_config.branch)\" ($remote_head)"
+        : "Upstream USM package updates detected");
+
+    var inactive_slot = rad_slots.inactive_slot() ?? "a";
+    try {
+        yield rad_deployer.deploy(inactive_slot);
+    }
+    catch(RadError e) {
+        Log.warning(@"Deployment into slot \"$inactive_slot\" failed: $(e.message)");
+        return false;
+    }
+
+    if(rad_instance_running) {
+        Log.info("Terminating previous instance");
+        yield rad_supervisor.terminate();
+        rad_instance_running = false;
+    }
+
+    if(launch_from_current()) {
+        rad_instance_running = true;
+        Log.info(@"Launched new instance from slot \"$(rad_slots.read_current_slot())\" ($remote_head)");
+        return true;
+    }
+
+    // The deployment succeeded but nothing launchable came out of it; the
+    // container supervisor is better placed to recover than a dead poll loop
+    Log.warning(@"\"$(rad_exec_name)\" does not resolve or launch from the deployed slot");
+    rad_exit_code = 251;
+    rad_loop.quit();
+    return true;
+}
+
+/**
+ * Launches the configured exec from the current slot. Returns false without
+ * ceremony when no slot is current; warns when a current slot exists but the
+ * exec does not resolve or launch within it.
+ */
+private static bool launch_from_current() {
+    var current_slot = rad_slots.read_current_slot();
+    if(current_slot == null) {
+        return false;
+    }
+
+    var executable = rad_slots.resolve_exec(current_slot, rad_exec_name);
+    if(executable == null) {
+        Log.warning(@"\"$(rad_exec_name)\" does not resolve in slot \"$current_slot\" (PATH-style search within the slot)");
+        return false;
+    }
+
+    try {
+        rad_supervisor.launch(executable, rad_exec_args);
+    }
+    catch(Error e) {
+        Log.warning(@"Could not launch \"$executable\": $(e.message)");
+        return false;
+    }
+    return true;
+}
+
+/**
+ * Locates the usm executable: a sibling of usmrad's own executable first
+ * (both install into the same bindir), then the PATH.
+ */
+private static string? find_usm_binary() {
+    try {
+        var own_path = FileUtils.read_link("/proc/self/exe");
+        var candidate = Path.build_filename(Path.get_dirname(own_path), "usm");
+        if(FileUtils.test(candidate, FileTest.IS_EXECUTABLE)) {
+            return candidate;
+        }
+    }
+    catch(Error e) {
+        // Fall through to the PATH search
+    }
+    return Environment.find_program_in_path("usm");
+}
+
+private static void usage() {
+    printerr("USAGE:\n\tusmrad <exec> [args...]\n\nEnvironment:\n");
+    printerr("\tUSMRAD_REPO (required) git repository URL\n");
+    printerr("\tUSMRAD_BRANCH branch to track (default: master)\n");
+    printerr("\tUSMRAD_POLL poll interval in minutes, 0 disables (default: 0)\n");
+    printerr("\tUSMRAD_APPDIR application directory (default: /app)\n");
+    printerr("\tUSMRAD_USM_REPOS semicolon-delimited .usmr URIs\n");
+    printerr("\tUSMRAD_TERM_GRACE seconds between SIGTERM and SIGKILL (default: 10)\n");
+}
+#endif
+}

+ 13 - 0
src/rad/meson.build

@@ -0,0 +1,13 @@
+
+rad_sources = files('Usmrad.vala')
+rad_sources += files('Slots.vala')
+rad_sources += files('Source.vala')
+rad_sources += files('System.vala')
+rad_sources += files('Deployer.vala')
+rad_sources += files('Supervisor.vala')
+
+rad_deps = dependencies
+rad_deps += usm_dep
+rad_deps += meson.get_compiler('vala').find_library('linux')
+
+executable('usmrad', rad_sources, dependencies: rad_deps, install: true)

+ 696 - 1
src/tests/TestMain.vala

@@ -713,6 +713,20 @@ namespace Usm.Tests {
         check(rejected.count() == 0, @"malformed constraints are rejected ($(rejected.to_string(s => s, ", ")))");
     }
 
+    void test_version_ordering() throws Error {
+        check(version("0.1.1").greater_than(version("0.1")), "extra non-zero component is newer (0.1.1 > 0.1)");
+        check(version("0.1").less_than(version("0.1.1")), "fewer components is older (0.1 < 0.1.1)");
+        check(version("0.1.0").compare(version("0.1")) == 0, "trailing zero components stay equal (0.1 == 0.1.0)");
+        check(version("0.1").compare(version("0.1.0")) == 0, "trailing zero components stay equal both ways (0.1.0 == 0.1)");
+        check(!version("0.1").greater_than(version("0.1.0")), "trailing zero components do not order (0.1 !> 0.1.0)");
+        check(version("1.0.0.1").greater_than(version("1.0")), "extra component wins at any depth");
+        check(version("0.2").greater_than(version("0.1.1")), "shared-prefix difference dominates component count");
+        check(version("0.1+1").greater_than(version("0.1")), "release suffix still beats release-less");
+        check(version("0.1.1+1").greater_than(version("0.1.1")), "release breaks numeric ties");
+        check(version("0.1.1").greater_than(version("0.1+5")), "non-zero component beats any release of the shorter version");
+        check(version("0.1").compare(version("0.1")) == 0, "identical versions compare equal");
+    }
+
     void test_resource_ref_operator_parsing() throws Error {
         var constrained = new Usm.ResourceRef("lib:libcmark.so.0>=0.30.0");
         check(constrained.base_name == "libcmark.so.0", "base name drops the constraint");
@@ -926,6 +940,27 @@ print(json.dumps({"not-found": [r for r in refs if r not in provided], "packages
 PY
     ;;
   plan)
+    # Optional exclusivity pair (USM_SPM_PLAN_CONFLICT_PAIR="a b"):
+    # when BOTH names appear in the requested set, fail the way the apk
+    # solver fails a dropbear+openssh mix — exit 3 (resolve) with an
+    # "unable to select packages" diagnostic. Provider-selection
+    # fixtures use this to model conflicts the query metadata never
+    # reported.
+    if [ -n "$USM_SPM_PLAN_CONFLICT_PAIR" ]; then
+      pair_a="${USM_SPM_PLAN_CONFLICT_PAIR%% *}"
+      pair_b="${USM_SPM_PLAN_CONFLICT_PAIR##* }"
+      have_a=0
+      have_b=0
+      for name in "$@"; do
+        if [ "$name" = "$pair_a" ]; then have_a=1; fi
+        if [ "$name" = "$pair_b" ]; then have_b=1; fi
+      done
+      if [ $have_a -eq 1 ] && [ $have_b -eq 1 ]; then
+        echo "ERROR: unable to select packages:" >&2
+        echo "  $pair_a: conflicts: $pair_b" >&2
+        exit 3
+      fi
+    fi
     # Every material package: dependencies first, chosen names after
     printf '{"packages":['
     first=1
@@ -1022,6 +1057,31 @@ esac
         return archive;
     }
 
+    /**
+     * Builds a .usmc archive for pkg-<name> at an explicit version
+     * providing bin:pkg-<name> with the given runtime refs (used for
+     * provider-selection fixtures).
+     */
+    string make_versioned_package_archive(string scratch, string name, string version, string[] runtime_refs) throws Error {
+        var source = Path.build_filename(scratch, @"src-$name-$version");
+        DirUtils.create(source, 0755);
+
+        var refs = new StringBuilder();
+        foreach(var resource_ref in runtime_refs) {
+            if(refs.len > 0) {
+                refs.append(",");
+            }
+            refs.append_printf("\"%s\"", resource_ref);
+        }
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"),
+            APP_MANIFEST.printf(@"pkg-$name", @"pkg-$name", depends_with("runtime", "[" + refs.str + "]"))
+                .replace("\"version\": \"1.0.0\"", @"\"version\": \"$version\""));
+
+        var archive = Path.build_filename(scratch, @"pkg-$name-$version.usmc");
+        Usm.Util.archive(source, archive);
+        return archive;
+    }
+
     /**
      * Builds a .usmc archive for pkg-<name> providing provides_key with the
      * given BUILD-phase refs (used for lot-admission fixtures).
@@ -1091,9 +1151,34 @@ esac
         Usm.Util.delete_tree(scratch);
     }
 
-    void test_resolve_spm_before_usm() throws Error {
+    /**
+     * A dependency ref must resolve to the HIGHEST version among its
+     * providers: the catalog is name/version-ascending and the cache
+     * sorts ahead of repository listings, so a first-match scan pulled
+     * the oldest cached copy — fresh installs of high-level packages
+     * shipped stale dependencies until `usm update` ran.
+     */
+    void test_resolve_prefers_newest_provider() throws Error {
         var scratch = make_scratch();
         var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        resolver.supply_package(make_versioned_package_archive(scratch, "x", "1.0.0", {}));
+        resolver.supply_package(make_versioned_package_archive(scratch, "x", "1.1.0", {}));
+        resolver.supply_package(make_versioned_package_archive(scratch, "x", "0.9.0", {}));
+        resolver.supply_package(make_package_archive(scratch, "app", { "bin:pkg-x" }));
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(((!)resolver.find_package("pkg-app")));
+        var result = resolver.resolve(roots, null);
+
+        var chosen = result.packages.where(p => p.manifest.name == "pkg-x").to_vector();
+        check(chosen.length == 1, "exactly one provider version is resolved for the ref");
+        check(chosen[0].manifest.version.to_string() == "1.1.0", "a dependency ref resolves to the highest provider version, not the oldest cached copy");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_resolve_spm_before_usm() throws Error {
+        var scratch = make_scratch();        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
         resolver.supply_package(make_package_archive(scratch, "x", {}));
 
         var roots = new Vector<Usm.AbstractPackage>();
@@ -1457,6 +1542,85 @@ esac
         Usm.Util.delete_tree(scratch);
     }
 
+    /**
+     * An update pair chain — every package both removed (its old
+     * version) and installed (its new one) — must split at build
+     * boundaries exactly like a fresh chain: the removals run before
+     * any build, so a dependent's build-phase ref on a removed
+     * package's provide is satisfied only once the replacement
+     * installs (an earlier lot). Regression: statum/astralis/inversion
+     * updated in one transaction used to plan into one lot against
+     * the stale pre-removal disk state.
+     */
+    void test_transaction_update_pairs_split_at_build_boundaries() throws Error {
+        var scratch = make_scratch();
+        make_build_package_archive(scratch, "x", "pc:lib-upd-x.pc", {});
+        make_build_package_archive(scratch, "y", "pc:lib-upd-y.pc", { "pc:lib-upd-x.pc" });
+        make_build_package_archive(scratch, "z", "pc:lib-upd-z.pc", { "pc:lib-upd-y.pc" });
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        var to_remove = new HashSet<Usm.CachedPackage>();
+        foreach(var name in new string[] { "x", "y", "z" }) {
+            var cached = cache_package(scratch, name);
+            to_install.add(cached);
+            to_remove.add(cached);
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = to_remove
+        };
+        transaction.strategise();
+
+        check(transaction.install_lots.length == 3, "an update-pair chain splits into one lot per build level");
+        check(transaction.install_lots[0].to_string(p => p.package_name, ",") == "cache-pkg-x-1.0.0",
+            "the base of the update chain installs before its dependents build");
+        check(transaction.install_lots[1].to_string(p => p.package_name, ",") == "cache-pkg-y-1.0.0",
+            "a dependent builds against the freshly installed replacement, not the removed old version");
+        check(transaction.install_lots[2].to_string(p => p.package_name, ",") == "cache-pkg-z-1.0.0",
+            "the chain tip waits for the middle update pair");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    /**
+     * A build-phase ref whose only provider is being REMOVED without a
+     * replacement in the transaction deadlocks the strategy — and the
+     * thrown message must say so: the explainer evaluates the same
+     * poison-aware satisfier the scheduler used, never the stale
+     * on-disk state (which showed the ref as present and produced a
+     * dead, reasonless diagnosis).
+     */
+    void test_transaction_removed_provider_reason() throws Error {
+        var scratch = make_scratch();
+        make_build_package_archive(scratch, "gone", "pc:lib-removed.pc", {});
+        make_build_package_archive(scratch, "needy", "pc:lib-needy.pc", { "pc:lib-removed.pc" });
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        var to_remove = new HashSet<Usm.CachedPackage>();
+        to_install.add(cache_package(scratch, "needy"));
+        to_remove.add(cache_package(scratch, "gone"));
+
+        var transaction = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = to_remove
+        };
+        try {
+            transaction.strategise();
+            check(false, "a removed provider without replacement deadlocks the strategy");
+        }
+        catch(Usm.TransactionError e) {
+            check("going away" in e.message, "the deadlock reason names the removed provider");
+            check("pc:lib-removed.pc" in e.message, "the deadlock reason names the poisoned resource");
+        }
+
+        Usm.Util.delete_tree(scratch);
+    }
+
     void test_transaction_manage_deps_admit_same_lot() throws Error {
         var scratch = make_scratch();
         make_package_archive(scratch, "m1", {});
@@ -2759,8 +2923,496 @@ echo "Installing libmeson-pkg.so to /usr/lib"
         Usm.Util.delete_tree(scratch);
     }
 
+    /**
+     * The SIGINT flag and its install/restore cycle: the flag is what the
+     * transaction checkpoints poll, and restore clears it with the
+     * default disposition.
+     */
+    void test_interrupt_flag_cycle() {
+        Usm.TransactionInterrupt.requested = true;
+        check(Usm.TransactionInterrupt.requested, "the interrupt flag is settable");
+        Usm.TransactionInterrupt.restore();
+        check(!Usm.TransactionInterrupt.requested, "restore clears the flag");
+    }
+
+    // ---- provider selection ----------------------------------------------------
+
+    /** One SPM query candidate for the selection fixtures. */
+    private Usm.SystemPackageCandidate selection_candidate(string name, string[] resources,
+            int dependency_count, int installed_count, string[] conflicts = {}) {
+        var candidate = new Usm.SystemPackageCandidate();
+        candidate.name = name;
+        candidate.resources = new Vector<Usm.ResourceRef>();
+        foreach(var resource in resources) {
+            candidate.resources.add(new Usm.ResourceRef(resource));
+        }
+        candidate.dependency_count = dependency_count;
+        candidate.installed_dependency_count = installed_count;
+        candidate.conflicts = new Vector<string>();
+        foreach(var conflict in conflicts) {
+            candidate.conflicts.add(conflict);
+        }
+        return candidate;
+    }
+
+    private Usm.SystemQueryResult selection_query(Usm.SystemPackageCandidate[] candidates) {
+        var result = new Usm.SystemQueryResult();
+        result.not_found = new Vector<string>();
+        result.packages = new Vector<Usm.SystemPackageCandidate>();
+        foreach(var candidate in candidates) {
+            result.packages.add(candidate);
+        }
+        return result;
+    }
+
+    private Vector<Usm.ResourceRef> selection_refs(string[] refs) {
+        var missing = new Vector<Usm.ResourceRef>();
+        foreach(var reference in refs) {
+            missing.add(new Usm.ResourceRef(reference));
+        }
+        return missing;
+    }
+
+    /**
+     * Whether a provider set mixes the dropbear and openssh families —
+     * the conflicting-union shape that kills apk transactions.
+     */
+    private static bool selection_mixes_families(Vector<string> chosen) {
+        var has_dropbear = false;
+        var has_openssh = false;
+        foreach(var name in chosen) {
+            has_dropbear = has_dropbear || name.has_prefix("dropbear");
+            has_openssh = has_openssh || name.has_prefix("openssh");
+        }
+        return has_dropbear && has_openssh;
+    }
+
+    /**
+     * The Alpine dropbear/openssh fixture: bin:ssh has three providers,
+     * bin:scp two, bin:rsync one, and the two families cannot coexist.
+     * The apk world declares the conflict asymmetrically (dropbear
+     * carries `!openssh-client`) — the fixtures mirror both shapes.
+     */
+    private Usm.SystemQueryResult dropbear_openssh_query(bool with_metadata) {
+        var openssh = new string[] { "openssh-client-common", "openssh-client-default", "openssh-client-krb5" };
+        var dropbear_conflicts = with_metadata ? openssh : new string[] {};
+        return selection_query({
+            selection_candidate("dropbear-ssh", { "bin:ssh" }, 6, 4, dropbear_conflicts),
+            selection_candidate("dropbear-scp", { "bin:scp" }, 6, 4, dropbear_conflicts),
+            selection_candidate("openssh-client-common", { "bin:scp" }, 7, 6),
+            selection_candidate("openssh-client-default", { "bin:ssh" }, 9, 6),
+            selection_candidate("openssh-client-krb5", { "bin:ssh" }, 15, 7),
+            selection_candidate("rsync", { "bin:rsync" }, 8, 5)
+        });
+    }
+
+    /**
+     * A fake SPM planner with the dropbear/openssh exclusion the apk
+     * solver enforces: any set mixing the families fails (null); any
+     * consistent set plans to itself plus one shared dependency.
+     */
+    private Vector<string>? family_aware_planner(Vector<string> names) {
+        if(selection_mixes_families(names)) {
+            return null;
+        }
+        var planned = new Vector<string>();
+        planned.add("shared-dep");
+        planned.add_all(names);
+        return planned;
+    }
+
+    void test_provider_selection_conflict_mapping() throws Error {
+        var scratch = make_scratch();
+        // Walk-time metadata filter: alpha covers bin:x; beta is the
+        // cheapest bin:y provider but DECLARES a conflict on alpha, so
+        // the walk must take the costlier gamma instead — a set mixing
+        // alpha and beta would not install.
+        var candidates = string.joinv(",", {
+            spm_candidate("alpha", "bin:x", 3, 1),
+            spm_candidate("beta", "bin:y", 2, 0).replace("}", ", \"conflicts\": [\"alpha\"]}"),
+            spm_candidate("gamma", "bin:y", 9, 0)});
+        var spm = stub_manager(scratch, candidates);
+        var result = resolve_manifest(spm, APP_MANIFEST.printf("conflicted", "conflicted",
+            depends_with("runtime", "[\"bin:x\", \"bin:y\"]")));
+        check(result.system_packages.length == 2, "both refs still resolve through the SPM");
+        check(result.system_packages.any(c => c.name == "alpha") && result.system_packages.any(c => c.name == "gamma"),
+            "a declaring-conflict candidate is skipped for a coinstallable one");
+        check(!result.system_packages.any(c => c.name == "beta"),
+            "the candidate declaring the conflict never enters the set");
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_provider_selection_repairs_unplannable_mix() throws Error {
+        var scratch = make_scratch();
+        // Metadata gap: NO conflicts reported, but the SPM's own solver
+        // (the stub's plan mode with USM_SPM_PLAN_CONFLICT_PAIR)
+        // rejects any set mixing beta and alpha. The walk greedily
+        // picks alpha (bin:x) and beta (cheapest bin:y); the post-walk
+        // plan verification catches the mix and the shared selector
+        // repairs it to alpha + gamma without failing the resolve.
+        var candidates = string.joinv(",", {
+            spm_candidate("alpha", "bin:x", 3, 1),
+            spm_candidate("beta", "bin:y", 2, 0),
+            spm_candidate("gamma", "bin:y", 9, 0)});
+        var spm = stub_manager(scratch, candidates);
+        Environment.set_variable("USM_SPM_PLAN_CONFLICT_PAIR", "alpha beta", true);
+        try {
+            var result = resolve_manifest(spm, APP_MANIFEST.printf("gap", "gap",
+                depends_with("runtime", "[\"bin:x\", \"bin:y\"]")));
+            check(result.system_packages.length == 2
+                && result.system_packages.any(c => c.name == "alpha")
+                && result.system_packages.any(c => c.name == "gamma"),
+                "the unplannable greedy mix is repaired to a coinstallable set");
+            check(!result.system_packages.any(c => c.name == "beta"),
+                "the solver-rejected candidate is dropped by the repair");
+        }
+        catch(Error e) {
+            Environment.unset_variable("USM_SPM_PLAN_CONFLICT_PAIR");
+            Usm.Util.delete_tree(scratch);
+            throw e;
+        }
+        Environment.unset_variable("USM_SPM_PLAN_CONFLICT_PAIR");
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_provider_selection_avoids_conflicting_families() throws Error {
+        // With full asymmetric metadata AND a planner: exactly one
+        // provider per ref, never a family mix.
+        var selection = Usm.ProviderSelection.select(
+            selection_refs({ "bin:scp", "bin:ssh", "bin:rsync" }),
+            dropbear_openssh_query(true), family_aware_planner);
+        check(selection.chosen.length == 3, "one provider per ref is chosen (3 refs, %u chosen)".printf(selection.chosen.length));
+        check(selection.unresolved.length == 0, "every ref resolves");
+        check(!selection_mixes_families(selection.chosen), "the chosen set never mixes dropbear and openssh");
+        check(selection.chosen.any(name => name == "rsync"), "the sole provider of a ref is always chosen");
+
+        // Metadata gap — NO conflicts reported, planner only: the
+        // greedy per-ref cost heuristic alone WOULD mix families
+        // (openssh-client-common for scp, dropbear-ssh for ssh); the
+        // planner rejects the mix and a consistent set results.
+        selection = Usm.ProviderSelection.select(
+            selection_refs({ "bin:scp", "bin:ssh", "bin:rsync" }),
+            dropbear_openssh_query(false), family_aware_planner);
+        check(selection.chosen.length == 3 && selection.unresolved.length == 0,
+            "metadata-free selection with a planner still covers every ref");
+        check(!selection_mixes_families(selection.chosen),
+            "the planner catches the family mix the metadata never reported");
+
+        // No planner at all: metadata alone still prevents the mix —
+        // the asymmetric dropbear declaration filters the openssh
+        // candidates once any dropbear package is chosen, and vice
+        // versa the walk keeps whichever family it entered first.
+        selection = Usm.ProviderSelection.select(
+            selection_refs({ "bin:scp", "bin:ssh", "bin:rsync" }),
+            dropbear_openssh_query(true), null);
+        check(selection.chosen.length == 3 && selection.unresolved.length == 0,
+            "planner-free selection covers every ref via metadata");
+        check(!selection_mixes_families(selection.chosen),
+            "the conflict metadata alone keeps the families separate");
+    }
+
+    void test_provider_selection_unresolved_and_reuse() throws Error {
+        // A ref whose every candidate conflicts with the chosen set
+        // lands in unresolved instead of aborting the selection.
+        var query = selection_query({
+            selection_candidate("alpha", { "bin:a" }, 2, 0),
+            selection_candidate("beta", { "bin:b" }, 2, 0, { "alpha" }),
+            selection_candidate("gamma", { "bin:b" }, 9, 0, { "alpha" })
+        });
+        var selection = Usm.ProviderSelection.select(selection_refs({ "bin:a", "bin:b" }), query, null);
+        check(selection.chosen.length == 1 && selection.chosen[0] == "alpha",
+            "the viable ref still resolves");
+        check(selection.unresolved.length == 1 && selection.unresolved[0].to_string() == "bin:b",
+            "a ref with only conflicting candidates is reported unresolved");
+
+        // A candidate chosen for one ref is reused for another it also
+        // covers (no second package for the same provider).
+        query = selection_query({
+            selection_candidate("multi", { "bin:a", "bin:b" }, 3, 0),
+            selection_candidate("single", { "bin:b" }, 1, 0)
+        });
+        selection = Usm.ProviderSelection.select(selection_refs({ "bin:a", "bin:b" }), query, null);
+        check(selection.chosen.length == 1 && selection.chosen[0] == "multi",
+            "a multi-ref provider satisfies later refs for free");
+    }
+
+    void test_provider_selection_plans_cumulative_sets() throws Error {
+        // The planner sees the CHOSEN SET plus the candidate each
+        // round — never a bare candidate — so its verdicts are exactly
+        // the co-installability questions the SPM will face.
+        var seen_by_planner = new Vector<string>();
+        Usm.SystemProviderPlan recorder = names => {
+            seen_by_planner.add(string.joinv("+", names.to_array()));
+            return family_aware_planner(names);
+        };
+        Usm.ProviderSelection.select(
+            selection_refs({ "bin:rsync", "bin:ssh" }),
+            dropbear_openssh_query(true), recorder);
+        check(seen_by_planner.length >= 2
+            && seen_by_planner[0].contains("rsync")
+            && !seen_by_planner[0].contains("+"),
+            "the first round plans the sole candidate alone");
+        check(seen_by_planner.any(seen => seen.contains("rsync+") || seen.contains("+rsync")),
+            "later rounds plan the cumulative chosen set");
+    }
+
+    void test_system_candidate_conflicts_mapping() throws Error {
+        // The query contract's conflicts member is optional: helpers
+        // predating it (or SPMs that cannot tell) omit it, and the
+        // mapper must yield an empty vector rather than fail.
+        var absent = Usm.SystemPackageCandidate.get_mapper().materialise(
+            new JsonElement.from_string("""{"name":"pkg","resources":["bin:x"],"dependency-count":1,"installed-dependency-count":0}""")
+            .as<Invercargill.Properties>());
+        check(absent.conflicts != null && absent.conflicts.length == 0,
+            "an absent conflicts member maps to an empty vector");
+
+        var present = Usm.SystemPackageCandidate.get_mapper().materialise(
+            new JsonElement.from_string("""{"name":"pkg","resources":["bin:x"],"dependency-count":1,"installed-dependency-count":0,"conflicts":["other-pkg"]}""")
+            .as<Invercargill.Properties>());
+        check(present.conflicts.length == 1 && present.conflicts[0] == "other-pkg",
+            "a present conflicts member maps to its names");
+
+        // The real patched apk helper's answer for bin:scp + bin:ssh +
+        // bin:rsync on alpine:latest (2026-10): the dropbear `!openssh-
+        // client` blocker resolves to the openssh-client providers, the
+        // openssh split packages carry their own pairwise conflicts,
+        // and rsync none. Pinned so the contract cannot drift from the
+        // helper — and note the real gap this fixture documents:
+        // openssh-client-common declares nothing against dropbear, the
+        // pair only the SPM's own solver rejects.
+        var apk_query = Usm.SystemQueryResult.get_mapper().materialise(
+            new JsonElement.from_string("""{"not-found":[],"packages":[
+                {"name":"dropbear-scp","resources":["bin:scp"],"dependency-count":6,"installed-dependency-count":4,"conflicts":["openssh-client-default","openssh-client-krb5"]},
+                {"name":"dropbear-ssh","resources":["bin:ssh"],"dependency-count":6,"installed-dependency-count":4,"conflicts":["openssh-client-default","openssh-client-krb5"]},
+                {"name":"openssh-client-common","resources":["bin:scp"],"dependency-count":7,"installed-dependency-count":6,"conflicts":[]},
+                {"name":"openssh-client-default","resources":["bin:ssh"],"dependency-count":9,"installed-dependency-count":6,"conflicts":["openssh-client-krb5"]},
+                {"name":"openssh-client-krb5","resources":["bin:ssh"],"dependency-count":15,"installed-dependency-count":7,"conflicts":["openssh-client-default"]},
+                {"name":"rsync","resources":["bin:rsync"],"dependency-count":8,"installed-dependency-count":5,"conflicts":[]}
+                ]}""")
+            .as<Invercargill.Properties>());
+        check(apk_query.packages.length == 6, "the pinned apk query parses");
+        check(apk_query.packages.any(c => c.name == "dropbear-ssh" && c.conflicts.length == 2),
+            "the dropbear blocker maps to its two openssh conflict targets");
+
+        // Selection over the REAL apk metadata, metadata-only (no
+        // planner): every ref still resolves — but the set MIXES
+        // families (openssh-client-common wins bin:scp on cost, then
+        // dropbear-ssh wins bin:ssh; common declares nothing against
+        // dropbear). This gap is why planner verification is not
+        // optional: with the family-aware planner the mix is
+        // impossible.
+        var selection = Usm.ProviderSelection.select(
+            selection_refs({ "bin:scp", "bin:ssh", "bin:rsync" }), apk_query, null);
+        check(selection.chosen.length == 3 && selection.unresolved.length == 0,
+            "the real apk metadata selects one provider per ref");
+        check(selection_mixes_families(selection.chosen),
+            "the real apk metadata alone leaves the common/dropbear gap (the planner layer exists for exactly this)");
+
+        selection = Usm.ProviderSelection.select(
+            selection_refs({ "bin:scp", "bin:ssh", "bin:rsync" }), apk_query, family_aware_planner);
+        check(selection.chosen.length == 3 && selection.unresolved.length == 0
+            && !selection_mixes_families(selection.chosen),
+            "the planner over the real apk metadata selects a coinstallable family set");
+    }
+
+    // ---- usmrad: configuration parsing ---------------------------------------------
+
+    const string[] USMRAD_VARIABLES = {
+        "USMRAD_REPO", "USMRAD_BRANCH", "USMRAD_POLL", "USMRAD_APPDIR", "USMRAD_USM_REPOS", "USMRAD_TERM_GRACE"
+    };
+
+    string?[] saved_usmrad_values;
+
+    /** Unsets every USMRAD_* variable, stashing the prior values for {@link restore_usmrad_env}. */
+    void clear_usmrad_env() {
+        saved_usmrad_values = new string?[USMRAD_VARIABLES.length];
+        for(int index = 0; index < USMRAD_VARIABLES.length; index++) {
+            saved_usmrad_values[index] = Environment.get_variable(USMRAD_VARIABLES[index]);
+            Environment.unset_variable(USMRAD_VARIABLES[index]);
+        }
+    }
+
+    /** Restores the values {@link clear_usmrad_env} stashed; other tests share this process. */
+    void restore_usmrad_env() {
+        for(int index = 0; index < USMRAD_VARIABLES.length; index++) {
+            var value = saved_usmrad_values[index];
+            if(value != null) {
+                Environment.set_variable(USMRAD_VARIABLES[index], value, true);
+            }
+            else {
+                Environment.unset_variable(USMRAD_VARIABLES[index]);
+            }
+        }
+    }
+
+    void test_rad_config_defaults() throws Error {
+        try {
+            clear_usmrad_env();
+            Environment.set_variable("USMRAD_REPO", "https://git.example/app.git", true);
+
+            var config = Usm.Rad.Config.from_environment();
+
+            check(config.repo == "https://git.example/app.git", "USMRAD_REPO is carried through");
+            check(config.branch == "master", "branch defaults to master");
+            check(config.poll_minutes == 0, "polling defaults to disabled");
+            check(config.poll_interval_seconds == 0, "a disabled poll yields no interval");
+            check(config.appdir == "/app", "appdir defaults to /app");
+            check(config.usm_repos.length == 0, "usm_repos defaults to empty");
+            check(config.term_grace_seconds == 10, "term grace defaults to 10 seconds");
+        }
+        finally {
+            restore_usmrad_env();
+        }
+    }
+
+    void test_rad_config_errors() throws Error {
+        try {
+            clear_usmrad_env();
+
+            try {
+                Usm.Rad.Config.from_environment();
+                check(false, "missing USMRAD_REPO is a configuration error");
+            }
+            catch(Usm.Rad.RadError e) {
+                check(e is Usm.Rad.RadError.CONFIGURATION, "missing USMRAD_REPO is a configuration error");
+            }
+
+            Environment.set_variable("USMRAD_REPO", "https://git.example/app.git", true);
+
+            Environment.set_variable("USMRAD_POLL", "abc", true);
+            try {
+                Usm.Rad.Config.from_environment();
+                check(false, "non-numeric USMRAD_POLL is rejected");
+            }
+            catch(Usm.Rad.RadError e) {
+                check(e is Usm.Rad.RadError.CONFIGURATION, "non-numeric USMRAD_POLL is rejected");
+            }
+
+            Environment.set_variable("USMRAD_POLL", "-1", true);
+            try {
+                Usm.Rad.Config.from_environment();
+                check(false, "negative USMRAD_POLL is rejected");
+            }
+            catch(Usm.Rad.RadError e) {
+                check(e is Usm.Rad.RadError.CONFIGURATION, "negative USMRAD_POLL is rejected");
+            }
+
+            Environment.unset_variable("USMRAD_POLL");
+            Environment.set_variable("USMRAD_TERM_GRACE", "x", true);
+            try {
+                Usm.Rad.Config.from_environment();
+                check(false, "non-numeric USMRAD_TERM_GRACE is rejected");
+            }
+            catch(Usm.Rad.RadError e) {
+                check(e is Usm.Rad.RadError.CONFIGURATION, "non-numeric USMRAD_TERM_GRACE is rejected");
+            }
+
+            Environment.set_variable("USMRAD_TERM_GRACE", "10", true);
+            Environment.set_variable("USMRAD_POLL", "0.25", true);
+            var config = Usm.Rad.Config.from_environment();
+            check(config.poll_minutes == 0.25, "fractional USMRAD_POLL parses");
+            check(config.poll_interval_seconds == 15, "0.25 minutes converts to a 15 second interval");
+        }
+        finally {
+            restore_usmrad_env();
+        }
+    }
+
+    // ---- usmrad: slot model ---------------------------------------------------------
+
+    void test_rad_slots_fresh_reads_null() throws Error {
+        var appdir = DirUtils.make_tmp("usmrad-tests-XXXXXX");
+        var slots = new Usm.Rad.Slots(appdir);
+        try {
+            check(slots.read_current_slot() == null, "read_current_slot is null on a fresh appdir");
+            check(slots.inactive_slot() == null, "inactive_slot is null on a fresh appdir");
+            Usm.Util.delete_tree(appdir);
+        }
+        catch(Error e) {
+            Usm.Util.delete_tree(appdir);
+            throw e;
+        }
+    }
+
+    void test_rad_slots_layout_flip_and_deployed_commit() throws Error {
+        var appdir = DirUtils.make_tmp("usmrad-tests-XXXXXX");
+        var slots = new Usm.Rad.Slots(appdir);
+        try {
+            slots.ensure_layout();
+            foreach(var name in new string[] { "a", "b", "pwd", "repo", "build" }) {
+                check(FileUtils.test(Path.build_filename(appdir, name), FileTest.IS_DIR), @"ensure_layout creates \"$name\"");
+            }
+            slots.ensure_layout();
+            check(slots.read_deployed_commit() == null, "read_deployed_commit returns null before anything is deployed");
+
+            slots.flip_current("a");
+            check(slots.read_current_slot() == "a", "flip_current makes slot \"a\" current");
+            check(slots.inactive_slot() == "b", "slot \"b\" is inactive while \"a\" is current");
+
+            slots.write_deployed_commit("0123456789abcdef");
+            check(slots.read_deployed_commit() == "0123456789abcdef", "the deployed commit round-trips");
+
+            slots.flip_current("b");
+            check(slots.read_current_slot() == "b", "flipping the symlink back makes slot \"b\" current");
+            check(slots.inactive_slot() == "a", "slot \"a\" is inactive while \"b\" is current");
+            check(slots.read_deployed_commit() == "0123456789abcdef", "the deployed commit survives a slot flip");
+            Usm.Util.delete_tree(appdir);
+        }
+        catch(Error e) {
+            Usm.Util.delete_tree(appdir);
+            throw e;
+        }
+    }
+
+    void test_rad_slots_resolve_exec_order() throws Error {
+        var appdir = DirUtils.make_tmp("usmrad-tests-XXXXXX");
+        var slots = new Usm.Rad.Slots(appdir);
+        try {
+            slots.ensure_layout();
+
+            var usr_bin_prog = Path.build_filename(slots.slot_path("a"), "usr", "bin", "prog");
+            DirUtils.create_with_parents(Path.get_dirname(usr_bin_prog), 0755);
+            FileUtils.set_contents(usr_bin_prog, "#!/bin/sh\nexit 0\n");
+            Posix.chmod(usr_bin_prog, 0755);
+
+            var bin_prog = Path.build_filename(slots.slot_path("a"), "bin", "prog");
+            DirUtils.create_with_parents(Path.get_dirname(bin_prog), 0755);
+            FileUtils.set_contents(bin_prog, "#!/bin/sh\nexit 0\n");
+            Posix.chmod(bin_prog, 0755);
+
+            check(slots.resolve_exec("a", "prog") == usr_bin_prog, "usr/bin wins over bin in the exec search order");
+            check(slots.resolve_exec("a", "absent") == null, "a missing exec name resolves to null");
+            check(slots.resolve_exec("a", "sub/prog") == null, "an exec name containing \"/\" is rejected");
+            Usm.Util.delete_tree(appdir);
+        }
+        catch(Error e) {
+            Usm.Util.delete_tree(appdir);
+            throw e;
+        }
+    }
+
     int main() {
         test_default_ignore();
+        test_interrupt_flag_cycle();
+        try {
+            test_provider_selection_avoids_conflicting_families();
+            test_provider_selection_conflict_mapping();
+            test_provider_selection_repairs_unplannable_mix();
+            test_provider_selection_unresolved_and_reuse();
+            test_provider_selection_plans_cumulative_sets();
+            test_system_candidate_conflicts_mapping();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL provider selection test threw: %s\n", e.message);
+        }
+        try {
+            test_version_ordering();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL version ordering test threw: %s\n", e.message);
+        }
         try {
             test_ignore_patterns();
             test_ignore_always_included();
@@ -2823,6 +3475,7 @@ echo "Installing libmeson-pkg.so to /usr/lib"
             test_satisfied_by_constraints();
             test_manifest_satisfies_pc_fallback();
             test_resolve_diamond_usm_only();
+            test_resolve_prefers_newest_provider();
             test_resolve_spm_before_usm();
             test_resolver_constrained_lib_dep();
             test_resolver_skips_spm_for_constrained_refs();
@@ -2835,6 +3488,8 @@ echo "Installing libmeson-pkg.so to /usr/lib"
             test_system_package_manager_install_stub();
             test_transaction_accepts_orders();
             test_transaction_lots_split_at_build_boundaries();
+            test_transaction_update_pairs_split_at_build_boundaries();
+            test_transaction_removed_provider_reason();
             test_transaction_manage_deps_admit_same_lot();
             test_transaction_preinstalled_resources_satisfy_build_deps();
             test_transaction_deadlock_message_details_causes();
@@ -2929,6 +3584,46 @@ echo "Installing libmeson-pkg.so to /usr/lib"
             print("FAIL progress reporting test threw: %s\n", e.message);
         }
 
+        try {
+            test_rad_config_defaults();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rad config defaults test threw: %s\n", e.message);
+        }
+
+        try {
+            test_rad_config_errors();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rad config errors test threw: %s\n", e.message);
+        }
+
+        try {
+            test_rad_slots_fresh_reads_null();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rad fresh slot test threw: %s\n", e.message);
+        }
+
+        try {
+            test_rad_slots_layout_flip_and_deployed_commit();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rad slot layout test threw: %s\n", e.message);
+        }
+
+        try {
+            test_rad_slots_resolve_exec_order();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rad exec resolution test threw: %s\n", e.message);
+        }
+
         print("%d passed, %d failed\n", passes, failures);
         return failures == 0 ? 0 : 1;
     }

+ 2 - 1
src/tests/meson.build

@@ -3,8 +3,9 @@
 # lines and exits non-zero on failure.
 
 usm_tests = executable('usm-tests',
-    ['TestMain.vala'],
+    ['TestMain.vala'] + rad_sources,
     dependencies: [usm_dep] + dependencies,
+    vala_args: ['--define=USMRAD_TESTS'],
     install: false
 )