소스 검색

Conflict-aware SPM provider selection, shared by usm, manifest verbs and usmrad

Alternative providers of one resource ref routinely cannot coexist
(dropbear and openssh both answer bin:ssh; apk rejects the union), and
every consumer picked providers blind: usmrad installed the raw query
union, usm's resolver greedily mixed families the metadata never
connected, and manifest build/install never consulted the SPM at all.

One selection path now serves every caller:

- The SPM query contract gains an optional per-package conflicts member,
  derived from native metadata by all five helpers (apk !blockers
  resolved through provides, dnf sack conflicts, apt Conflicts/Breaks,
  pacman %CONFLICTS% stanzas, emerge documented-empty).
- ProviderSelection (lib) picks one provider per ref, never two, never
  a metadata-conflicting pair, minimising the planner-verified
  cumulative closure; the SPM's own solver is the final arbiter, so
  pairs the metadata misses (openssh-client-common + dropbear-ssh on
  real alpine:latest) are still rejected. Helpers without a working
  plan mode degrade to the metadata-only heuristic.
- Resolver.resolve_manifest(manifest, spm) resolves a local checkout's
  dependency closure with the same system-package-before-USM
  precedence usm install uses; the walk filters conflicts metadata
  during resolution and a post-walk plan pass repairs an unplannable
  greedy set or fails with an itemised report.
- usmrad's ensure_deps delegates to resolve_manifest (the hand-rolled
  query/union/spawn machinery is deleted), and usm manifest
  build/install offer to install missing dependencies through the same
  path — SPM set with a native plan preview plus the USM closure —
  keeping exit 252 on decline or still-missing.
- Every helper's failure diagnostics now carry the full native error
  (apk's conflict report was previously truncated to its headline),
  so log lines name the actual conflict.

458 tests (was 433): selector unit suites, the real patched apk query
pinned as a fixture (including the metadata gap that motivates the
planner), resolver walk-filter and repair arcs, mapper tolerance for
helpers predating the conflicts member.
clanker 2 주 전
부모
커밋
bb30b1d8bd
12개의 변경된 파일과 1246개의 추가작업 그리고 145개의 파일을 삭제
  1. 65 2
      spm/apk/usm-spm-apk
  2. 82 3
      spm/apt/usm-spm-apt
  3. 23 0
      spm/dnf/usm-spm-dnf
  4. 13 0
      spm/emerge/usm-spm-emerge
  5. 86 10
      spm/pacman/usm-spm-pacman
  6. 249 1
      src/cli/Manifest.vala
  7. 203 0
      src/lib/ProviderSelection.vala
  8. 118 1
      src/lib/Resolver.vala
  9. 31 1
      src/lib/SystemPackageManager.vala
  10. 1 0
      src/lib/meson.build
  11. 62 127
      src/rad/System.vala
  12. 313 0
      src/tests/TestMain.vala

+ 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"

+ 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;
     }

+ 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;
+        }
+    }
+}

+ 118 - 1
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
@@ -270,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;
@@ -300,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);
 
@@ -315,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
@@ -529,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)");
@@ -574,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;
@@ -584,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;
@@ -596,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
@@ -609,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;
                 }
             }

+ 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 {

+ 1 - 0
src/lib/meson.build

@@ -20,6 +20,7 @@ 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')

+ 62 - 127
src/rad/System.vala

@@ -83,23 +83,28 @@ public class System : Object {
     /**
      * Satisfies the dependencies of the manifest in {@link repo_dir}
      * before `usm manifest install` runs (that command exits 252 on
-     * unmet dependencies). Every ref from the manifest's build, manage
-     * and runtime phases is checked with one {@link Usm.ResourceFinder};
-     * missing refs are resolved through one batched
-     * {@link Usm.SystemPackageManager.query_sync} against the configured
-     * helper, then through `usm provides` per remaining ref, and the
-     * collected packages installed with `usm install -y` (USM names) and
-     * {@link Usm.SystemPackageManager.install} (native names). A fresh
-     * finder re-checks afterwards; refs still missing throw
-     * {@link RadError.DEPENDENCY}.
+     * 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}.
      *
-     * Grouped candidate phases contribute every alternative ref, so an
-     * unchosen alternative is installed rather than treated as optional.
+     * 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.
      *
-     * Limitation: version-constrained refs (for example `lib:foo>=2`)
-     * are stripped to their names for provider lookup — SPM helpers and
-     * `usm provides` match names exactly — so the constraint holds only
-     * insofar as `usm install`'s own resolution enforces it.
+     * 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");
@@ -111,6 +116,10 @@ public class System : Object {
             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();
@@ -126,48 +135,45 @@ public class System : Object {
         Log.info(@"$(missing.length) dependency ref(s) of \"$(manifest.name)\" missing");
 
         var spm = load_system_package_manager();
-        var spm_names = new Vector<string>();
-        var unresolved = missing;
+        Usm.SystemPackageManager? active_spm = null;
         if(spm != null && spm.enabled) {
-            try {
-                var result = spm.query_sync(missing);
-                if(result != null) {
-                    var remaining = new Vector<Usm.ResourceRef>();
-                    foreach(var dependency in missing) {
-                        if(!covered_by_system_packages(dependency, result)) {
-                            remaining.add(dependency);
-                        }
-                    }
-                    foreach(var candidate in result.packages) {
-                        if(!spm_names.contains(candidate.name)) {
-                            spm_names.add(candidate.name);
-                        }
-                    }
-                    unresolved = remaining;
-                }
-            }
-            catch(Error e) {
-                Log.warning(@"System package manager query failed: $(e.message); continuing with USM repositories");
-            }
+            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>();
-        if(unresolved.length > 0) {
-            foreach(var dependency in unresolved) {
-                usm_names.add_all(usm_providers(@"$(dependency.resource_type.to_string()):$(dependency.base_name)"));
+        foreach(var package in resolution.packages) {
+            if(package.manifest == manifest) {
+                continue;
+            }
+            if(!usm_names.contains(package.manifest.name)) {
+                usm_names.add(package.manifest.name);
             }
         }
 
-        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));
+        // 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) {
@@ -184,6 +190,15 @@ public class System : Object {
             }
         }
 
+        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) {
@@ -290,86 +305,6 @@ public class System : Object {
         }
     }
 
-    /**
-     * Whether any package of a query {@link result} provides
-     * {@link dependency}: {@link Usm.ResourceRef.satisfied_by} against
-     * each candidate's resources, with an exact string match as fallback
-     * for constrained refs whose helper echoed the constraint back.
-     */
-    private static bool covered_by_system_packages(Usm.ResourceRef dependency, Usm.SystemQueryResult result) {
-        foreach(var candidate in result.packages) {
-            foreach(var resource in candidate.resources) {
-                if(dependency.satisfied_by(resource) || dependency.to_string() == resource.to_string()) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-
-    /**
-     * The package names providing {@link resource_ref}, collected from
-     * `usm provides` stdout lines of the form
-     * `name-version (origin) provides ref` (see src/cli/Provides.vala).
-     * Exit status 1 (nothing provides the ref) is an expected miss; other
-     * failures warn and return what was read — the closing re-check
-     * turns silent gaps into a {@link RadError.DEPENDENCY}.
-     */
-    private Vector<string> usm_providers(string resource_ref) {
-        var names = new Vector<string>();
-        var argv = usm_argv(new string[] { "provides", resource_ref });
-        var command = string.joinv(" ", argv);
-        Subprocess process;
-        try {
-            // Stdout is captured for parsing; stderr inherits by default,
-            // so repository refresh chatter reaches the container log
-            process = new Subprocess.newv(argv, SubprocessFlags.STDOUT_PIPE);
-        }
-        catch(Error e) {
-            Log.warning(@"Could not spawn \"$command\": $(e.message)");
-            return names;
-        }
-        try {
-            var lines = new DataInputStream(process.get_stdout_pipe());
-            string line = null;
-            while((line = lines.read_line()) != null) {
-                var name = provides_package_name(line);
-                if(name != null && !names.contains(name)) {
-                    names.add(name);
-                }
-            }
-        }
-        catch(Error e) {
-            Log.warning(@"Could not read the output of \"$command\": $(e.message)");
-        }
-        process.wait();
-        var status = process.get_exit_status();
-        if(status != 0 && status != 1) {
-            Log.warning(@"\"$command\" exited with status $status");
-        }
-        return names;
-    }
-
-    /**
-     * The package name of one `usm provides` result line, or null for
-     * lines that do not match the `name-version (origin) provides ref`
-     * shape. Package names may contain dashes; usm versions never do, so
-     * the name ends at the final dash before the origin parenthesis.
-     */
-    private static string? provides_package_name(string line) {
-        var origin = line.index_of(" (");
-        var provides = line.index_of(") provides ");
-        if(origin < 1 || provides < origin) {
-            return null;
-        }
-        var name_and_version = line.substring(0, origin);
-        var separator = name_and_version.last_index_of("-");
-        if(separator < 1) {
-            return null;
-        }
-        return name_and_version.substring(0, separator);
-    }
-
     /**
      * One line per streamed system package install event; the per-package
      * progress fraction events stay unlogged to keep the supervisor's

+ 313 - 0
src/tests/TestMain.vala

@@ -940,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
@@ -2914,6 +2935,286 @@ echo "Installing libmeson-pkg.so to /usr/lib"
         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 = {
@@ -3093,6 +3394,18 @@ echo "Installing libmeson-pkg.so to /usr/lib"
     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();
         }