瀏覽代碼

feat: resource version constraints; SPM plan mode; progress/install hardening

Version constraints on dependencies (lib and pc only):
- VersionConstraint (==, >=, <=, ~= PEP 440 compatible-release;
  prefix-tolerant numeric comparison, releases ignored)
- ResourceRef parses operator suffixes, exposes base_name for path
  computation and version_tail() soname extraction; depends operators
  rejected outside lib/pc; provides may only carry ==, only on pc,
  as a version declaration (rejected on lib as redundant)
- Manifest.satisfies is the provider predicate everywhere (resolver,
  topological order, lot scheduling, removal, rebuild dependants,
  usm provides): lib providers match by soname tail, pc providers by
  == declaration or the package version when undeclared
  (effective_provides materialises the assumption into collected sets)
- ResourceFinder matches constrained refs against ldconfig sonames,
  directory listings and .pc Version: fields (new PcFile reader);
  PKG_CONFIG_PATH honoured as before
- usm manifest validate treats a present .pc file whose Version:
  contradicts its == declaration or the package version as invalid,
  exactly like a missing file (built and data packages)
- constrained refs are skipped in the SPM batch query (shims match
  names exactly; they resolve from local presence or USM packages)
- fixed a segfault: type-only refs ("lib") now fail validation cleanly
- SPECIFICATION.md (Version Constraints) + PACKAGING_GUIDE.md guidance;
  installer regenerated; 290 unit checks green; libcmark scenario
  replayed end-to-end in an arch container (exact pin fails 252,
  >=0.30.0 and ~=0.31.0 validate against the system's 0.31.2)

SPM plan mode and install wiring (in-progress work committed as-is):
- helpers gain a plan mode returning the material install set;
  SystemPackageManager.plan_install drives it, degrading to the input
  names on any failure
- Install/Update feed preinstalled_resources (system-provided build
  deps count as present at lot-scheduling time) and the material
  system plan into Transaction/TransactionSummary
- ProgressBar drops stale ninja-reader reports that would resurrect
  finished actions; pacman shim usage output tidied
clanker 3 天之前
父節點
當前提交
904c1b7a11

+ 25 - 0
PACKAGING_GUIDE.md

@@ -57,6 +57,31 @@ One line, lowercase, no trailing period. This is what `usm search` shows.
 
 **Rule of thumb**: declare what *you* link against or *invoke*, not what your dependencies pull in transitively. Each package's manifest handles its own dependency chain.
 
+### Versioned library and pkg-config dependencies
+
+Hardcoding the full soname you happened to link against makes a package uninstallable on systems carrying a different (possibly newer) build of the same library: a package wanting `lib:libcmark.so.0.30.0` fails against a system providing `libcmark.so.0.31.1`. Since USM builds packages from source *on the target system*, a dependency is usually an API floor, not an ABI pin — express it with a version constraint on the base soname instead:
+
+```json
+"runtime": ["lib:libcmark.so>=0.30.0"]
+```
+
+This is satisfied by any `libcmark.so.<tail>` whose trailing dotted numbers meet the operator (`0.31.1` qualifies for `>=0.30.0`). Operators: `>=`, `<=`, `==` (equal, prefix-tolerant: `0.31` matches `0.31.0`) and `~=` (compatible release — `~=0.31.0` accepts `0.31.x` only, `~=0.31` accepts `0.x` from `0.31` up). The same syntax works for `pc:` refs, checked against the `.pc` file's `Version:` field:
+
+```json
+"build": ["pc:gtk4.pc>=4.12"]
+```
+
+When to use what:
+
+- **`>=`** — the normal choice: "the API I use exists since this version".
+- **`~=`** — when you know the library breaks ABI within the family you tested (common for pre-1.0 libraries that version their soname by minor).
+- **Exact full soname** (`lib:libcmark.so.0.31.1`) — only when you truly need one specific build.
+- Constraints on other resource types (`bin:`, `vapi:`, …) are rejected: those names carry no version.
+
+Packages that themselves *provide* a `pc:` file may declare its version in `provides` (`"pc:cmark.pc==0.31.1"`); without a declaration the file is assumed to match the package `version`, and validation fails a package whose installed `.pc` `Version:` field contradicts either.
+
+Note: constrained dependencies do not yet resolve through system package managers — only from local presence and USM packages.
+
 ### Runtime dependencies (`depends.runtime`)
 
 Things the installed program needs at *run* time:

+ 21 - 0
SPECIFICATION.md

@@ -119,6 +119,27 @@ The following resource types are defined:
 - `cfg:myapp.conf`: A configuration file for `myapp`.
 - `app:myapp.desktop`: A desktop entry for `myapp`.
 
+### Version Constraints
+
+A resource name in a **dependency** may end with a version constraint, one of the operators `==` (equal), `>=` (at least), `<=` (at most) or `~=` (compatible release, PEP 440 style: at least the given version and sharing all of its numeric components except the last, so `~=0.31.0` accepts `0.31.1` but not `0.32.0`, while `~=0.31` accepts any `0.x` from `0.31` up but not `1.0.0`):
+
+```
+"lib:libcmark.so>=0.30.0"
+"lib:libcmark.so~=0.31.0"
+"pc:gtk4.pc>=4.12"
+```
+
+Version constraints are only valid where a resource's concrete names carry a version:
+
+- `lib`: the version is the trailing dotted-numeric tail of the soname (`libcmark.so.0.31.1` is version `0.31.1` of base `libcmark.so`). A dependency `lib:libcmark.so>=0.30.0` is satisfied by any installed or provided `libcmark.so.<tail>` whose tail satisfies the operator; a bare `libcmark.so` declares no version.
+- `pc`: the version is read from the `.pc` file's `Version:` field when checking the local system.
+
+Comparison semantics are prefix-tolerant on numeric components and ignore `+release` suffixes: `0.31` matches `0.31.0`. Constraints on any other resource type are a manifest validation error.
+
+In `provides`, only `pc` resource names may carry a constraint, only the `==` operator, and it acts as a **version declaration** for the concrete file (`"pc:cmark.pc==0.31.1": "as-expected"`). A `pc` provide without a declaration is assumed to carry the package's own `version`. During validation, a present `.pc` file whose `Version:` field matches neither the `==` declaration nor the package version invalidates the package, exactly like a missing file. Sonames already declare their version in the filename, so `==` on `lib` provides is redundant and rejected.
+
+Version-constrained dependencies are not yet forwarded to system package managers: they resolve from local presence or USM packages only.
+
 ## Installation Lifecycle Directories
 
 Each of these directories are created by USM at runtime for each package.

File diff suppressed because it is too large
+ 259 - 1253
installer/install-usm.sh


+ 39 - 0
spm/apk/usm-spm-apk

@@ -82,6 +82,7 @@ warn() {
 
 die_usage() {
     printf 'usage: %s query <usm-ref>...\n' "$PROG" >&2
+    printf '       %s plan <native-name>...\n' "$PROG" >&2
     printf '       %s install <native-name>...\n' "$PROG" >&2
     exit "$EXIT_USAGE"
 }
@@ -432,6 +433,41 @@ cmd_install() {
     exit "$(classify_failure "$WORK/inst.err")"
 }
 
+cmd_plan() {
+    [ $# -ge 1 ] || die_usage
+    for _p_n in "$@"; do
+        case $_p_n in
+            -*) die_usage ;;
+        esac
+    done
+    _p_expanded=""
+    for _p_n in "$@"; do
+        _p_expanded="$_p_expanded $_p_n"
+        for _p_c in $INSTALL_COMPANIONS; do
+            if [ "${_p_c%%:*}" = "$_p_n" ]; then
+                _p_expanded="$_p_expanded ${_p_c#*:}"
+            fi
+        done
+    done
+    set -- $_p_expanded
+    mkwork
+    _p_rc=0
+    _p_sim=$(apk add --simulate --no-cache --no-progress -- "$@" 2>"$WORK/plan.err") || _p_rc=$?
+    if [ "$_p_rc" != 0 ]; then
+        _p_msg=$(apk_diag_line "$WORK/plan.err")
+        [ -n "$_p_msg" ] || _p_msg="apk add --simulate failed with status $_p_rc"
+        printf '%s: %s\n' "$PROG" "$_p_msg" >&2
+        exit "$(classify_failure "$WORK/plan.err")"
+    fi
+    # simulate output names every package the transaction would touch:
+    # "(3/7) Installing musl (1.2.5-r1)"
+    _p_json=$(printf '%s\n' "$_p_sim" \
+        | sed -n 's/^(\([0-9][0-9]*\)\/\([0-9][0-9]*\)) Installing \([^ ]*\) .*/"\3"/p' \
+        | awk 'NR>1{printf ","} {printf "%s", $0}')
+    printf '{"packages":[%s]}\n' "$_p_json"
+    exit "$EXIT_OK"
+}
+
 main() {
     if [ $# -lt 1 ]; then
         die_usage
@@ -450,6 +486,9 @@ main() {
         query)
             cmd_query "$@"
             ;;
+        plan)
+            cmd_plan "$@"
+            ;;
         install)
             cmd_install "$@"
             ;;

+ 19 - 0
spm/apt/usm-spm-apt

@@ -530,6 +530,17 @@ def simulate_install(names, environment):
     return order
 
 
+def cmd_plan(args):
+    environment = apt_environment()
+    try:
+        order = simulate_install(args.names, environment)
+    except HelperError as e:
+        fail_plain(str(e), e.exit_code)
+    sys.stdout.write(json.dumps({"packages": order}) + "\n")
+    sys.stdout.flush()
+    return EXIT_OK
+
+
 def cmd_query(args):
     try:
         return run_query(args)
@@ -636,6 +647,14 @@ def main():
         help="resource ref, e.g. bin:valac or lib:libglib-2.0.so.0")
     query_parser.set_defaults(handler=cmd_query)
 
+    plan_parser = subparsers.add_parser(
+        "plan", help="resolve the material install set for names "
+                     "(chosen packages plus all dependencies)")
+    plan_parser.add_argument(
+        "names", nargs="+", metavar="NAME",
+        help="native system package name")
+    plan_parser.set_defaults(handler=cmd_plan)
+
     install_parser = subparsers.add_parser(
         "install", help="install system packages, streaming progress events")
     install_parser.add_argument(

+ 44 - 3
spm/dnf/usm-spm-dnf

@@ -5,6 +5,7 @@ Implements the USM SPM contract (see usm/README.md, "System package manager
 integration") for Fedora-style systems:
 
     usm-spm-dnf query <usm-ref>...      -> contract JSON on STDOUT
+    usm-spm-dnf plan <name>...          -> contract JSON: the material install set
     usm-spm-dnf install [--test] <name>... -> contract JSONL events on STDOUT
 
 API choice: Fedora 43 ships dnf5 as the CLI, but this machine exposes the
@@ -230,6 +231,36 @@ def closure_counts(closure_base, installed_keys, package, closure_cache):
     return counts
 
 
+def cmd_plan(args):
+    """Resolve the material install set without touching the system.
+
+    Marks every name for install, resolves the goal, and prints one
+    contract JSON object listing every package the manager would
+    install — the chosen names plus every dependency of a dependency —
+    so the caller can plan and show the whole native transaction.
+    """
+    try:
+        base = make_base(load_system_repo=True)
+        for name in args.names:
+            try:
+                base.install(name)
+            except Exception as e:
+                fail_plain("could not mark \"%s\" for install: %s" % (name, e),
+                           EXIT_RESOLVE)
+        try:
+            base.resolve()
+        except Exception as e:
+            fail_plain("dependency resolution failed: %s" % e, EXIT_RESOLVE)
+        names = [member.name for member in base.transaction.install_set]
+        sys.stdout.write(json.dumps({"packages": names}) + "\n")
+        sys.stdout.flush()
+        return EXIT_OK
+    except SystemExit:
+        raise
+    except Exception as e:
+        fail_plain("plan failed: %s" % e, EXIT_RESOLVE)
+
+
 def cmd_query(args):
     try:
         return run_query(args)
@@ -309,7 +340,7 @@ class ContractDownloadProgress(dnf.callback.DownloadProgress):
         size = payload.pkg.downloadsize or 0
         fraction = (done / size) if size else 0.0
         event = (
-            "package", payload.pkg.name, self.index + 1, self.total,
+            "package", payload.pkg.name, self.index, self.total,
             round(min(fraction, 1.0), 4))
         if event == self.last:
             return
@@ -317,7 +348,7 @@ class ContractDownloadProgress(dnf.callback.DownloadProgress):
         emit_event({
             "type": "package",
             "name": payload.pkg.name,
-            "current": self.index + 1,
+            "current": self.index,
             "total": self.total,
             "progress": min(fraction, 1.0),
         })
@@ -346,7 +377,9 @@ class ContractTransactionDisplay(dnf.yum.rpmtrans.TransactionDisplay):
             return
         fraction = (float(ti_done) / float(ti_total)) if ti_total else 0.0
         total = ts_total or 1
-        current = min(ts_done + 1, total) if ts_total else 1
+        # ts_done counts finished packages, so the in-flight package is
+        # the zero-based position ts_done (clamped into the transaction)
+        current = min(ts_done, total - 1) if ts_total else 0
         event = ("package", package.name, current, total,
                  round(min(fraction, 1.0), 4))
         if event == self.last:
@@ -429,6 +462,14 @@ def main():
         help="resource ref, e.g. bin:valac or lib:libglib-2.0.so.0")
     query_parser.set_defaults(handler=cmd_query)
 
+    plan_parser = subparsers.add_parser(
+        "plan", help="resolve the material install set for names "
+                     "(chosen packages plus all dependencies)")
+    plan_parser.add_argument(
+        "names", nargs="+", metavar="NAME",
+        help="native system package name")
+    plan_parser.set_defaults(handler=cmd_plan)
+
     install_parser = subparsers.add_parser(
         "install", help="install system packages, streaming progress events")
     install_parser.add_argument(

+ 29 - 0
spm/pacman/usm-spm-pacman

@@ -85,6 +85,7 @@ warn() {
 
 die_usage() {
     printf 'usage: %s query <usm-ref>...\n' "$PROG" >&2
+    printf '       %s plan <native-name>...\n' "$PROG" >&2
     printf '       %s install <native-name>...\n' "$PROG" >&2
     exit "$EXIT_USAGE"
 }
@@ -548,6 +549,30 @@ cmd_install() {
     exit "$(classify_failure "$WORK/inst.err")"
 }
 
+cmd_plan() {
+    [ $# -ge 1 ] || die_usage
+    for _p_n in "$@"; do
+        case $_p_n in
+            -*) die_usage ;;
+        esac
+    done
+    mkwork
+    _p_rc=0
+    _p_names=$(pacman -S --needed --noconfirm --print --print-format '%n' \
+        -- "$@" 2>"$WORK/plan.err") || _p_rc=$?
+    if [ "$_p_rc" != 0 ]; then
+        _p_msg=$(pacman_diag_line "$WORK/plan.err")
+        [ -n "$_p_msg" ] || _p_msg="pacman --print failed with status $_p_rc"
+        printf '%s: %s\n' "$PROG" "$_p_msg" >&2
+        exit "$(classify_failure "$WORK/plan.err")"
+    fi
+    _p_json=$(printf '%s\n' "$_p_names" \
+        | sed -n '/^[A-Za-z0-9@._+-]\{1,\}$/s/.*/"&"/p' \
+        | awk 'NR>1{printf ","} {printf "%s", $0}')
+    printf '{"packages":[%s]}\n' "$_p_json"
+    exit "$EXIT_OK"
+}
+
 main() {
     if [ $# -lt 1 ]; then
         die_usage
@@ -566,11 +591,15 @@ main() {
         query)
             cmd_query "$@"
             ;;
+        plan)
+            cmd_plan "$@"
+            ;;
         install)
             cmd_install "$@"
             ;;
         -h|--help|help)
             printf 'usage: %s query <usm-ref>...\n' "$PROG"
+            printf '       %s plan <native-name>...\n' "$PROG"
             printf '       %s install <native-name>...\n' "$PROG"
             exit "$EXIT_OK"
             ;;

+ 41 - 68
src/cli/Install.vala

@@ -131,14 +131,52 @@ private int install_main(string[] args) {
             install_order.add(package.manifest.name);
         }
 
+        // Resources the chosen system packages will install before this
+        // transaction's first lot runs; the scheduler counts them as
+        // present so system-provided build dependencies do not look unmet
+        // at planning time
+        var preinstalled_resources = new HashSet<Usm.ResourceRef>();
+        foreach(var system_package in resolution.system_packages) {
+            foreach(var resource in system_package.resources) {
+                preinstalled_resources.add(resource);
+            }
+        }
+
+        // The material system install set — chosen packages plus every
+        // native dependency of a dependency — so the plan preview lists
+        // the whole native transaction and every material package gets
+        // its own progress action
+        var system_install_plan = new Vector<string>();
+        if(spm.enabled && resolution.system_packages.any()) {
+            var chosen_system_names = new Vector<string>();
+            foreach(var system_package in resolution.system_packages) {
+                chosen_system_names.add(system_package.name);
+            }
+            system_install_plan = spm.plan_install(chosen_system_names);
+        }
+        var chosen_system_names_set = new HashSet<string>();
+        foreach(var system_package in resolution.system_packages) {
+            chosen_system_names_set.add(system_package.name);
+        }
+        var system_dependencies = new Vector<string>();
+        foreach(var name in system_install_plan) {
+            if(!chosen_system_names_set.contains(name)) {
+                system_dependencies.add(name);
+            }
+        }
+
         var transaction = new Usm.Transaction() {
             paths = paths,
             resource_finder = new Usm.ResourceFinder(paths),
+            preinstalled_resources = preinstalled_resources,
             to_remove = new HashSet<Usm.CachedPackage>(),
             to_install = cached_packages,
             install_order = install_order,
             explicit_packages = package_names,
-            state = state
+            state = state,
+            system_package_manager = spm,
+            system_packages = resolution.system_packages,
+            system_install_plan = system_install_plan
         };
 
         // Planning before the prompt populates the rebuildDependants
@@ -153,6 +191,8 @@ private int install_main(string[] args) {
 
         var summary = new Usm.TransactionSummary() {
             to_install = cached_packages.to_vector(),
+            system_installs = resolution.system_packages,
+            system_dependencies = system_dependencies,
             to_rebuild = transaction.rebuilds,
             download_count = download_count,
             download_bytes = download_sizes_known ? download_bytes : -1
@@ -165,13 +205,6 @@ private int install_main(string[] args) {
         // Only now that the plan is confirmed do the packages transfer
         install_download_packages(state, resolution, new HashSet<string>(), progress);
 
-        // Chosen system packages install first, as one transaction
-        if(resolution.system_packages.any()) {
-            if(!install_system_packages(spm, resolution.system_packages)) {
-                return 239;
-            }
-        }
-
         printerr("\nRunning transaction...\n");
         transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.run();
@@ -327,66 +360,6 @@ private int64? install_package_download_size(Usm.Repository repository, Usm.Repo
     return null;
 }
 
-/**
- * Installs the resolution's chosen system packages as one transaction,
- * surfacing progress through the manager's {@link Usm.InstallProgressDelegate}
- * events; returns false (after printing why) when the transaction failed.
- * Shared by `usm install` and `usm update`.
- */
-private bool install_system_packages(Usm.SystemPackageManager spm, Vector<Usm.SystemPackageCandidate> packages) {
-    printerr("Installing system packages...\n");
-    var names = new Vector<string>();
-    foreach(var package in packages) {
-        names.add(package.name);
-    }
-
-    int installed = 0;
-    int total = 0;
-    string failure = "";
-    var loop = new MainLoop();
-    spm.install.begin(names, event => {
-        switch(event.event_type) {
-            case Usm.SystemInstallEventType.BEGIN:
-                total = event.total;
-                break;
-            case Usm.SystemInstallEventType.PACKAGE:
-                if(event.name != null) {
-                    printerr(@"\r  installing $(event.name) ($((int)(event.progress * 100))%)");
-                }
-                break;
-            case Usm.SystemInstallEventType.PACKAGE_COMPLETE:
-                installed++;
-                if(event.name != null) {
-                    printerr(@"\r  [$installed/$total] installed system package $(event.name)      \n");
-                }
-                break;
-            case Usm.SystemInstallEventType.COMPLETE:
-                break;
-            case Usm.SystemInstallEventType.ERROR:
-                failure = event.message ?? "unknown error";
-                break;
-        }
-    }, (obj, res) => {
-        try {
-            if(!spm.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");
-        return false;
-    }
-    printerr(@"Installed $installed system package(s).\n");
-    return true;
-}
-
 private int install_usage() {
     printerr("USAGE:\n\tusm install [-y|--yes] <packages>\n");
     return 255;

+ 40 - 2
src/cli/Manifest.vala

@@ -997,7 +997,30 @@ private int validate() {
             printerr(@"Error validating resource $(expected.key.to_string()): $(e.message)\n");
             found = false;
         }
-        
+
+        // A present pc provide must declare a matching version: the
+        // `==` declaration when given, the package version otherwise —
+        // a mismatch invalidates the provide the same way a missing
+        // file does
+        if(found && expected.key.resource_type == Usm.ResourceType.PKG_CONFIG) {
+            var declared = Usm.PcFile.read_version(expected_path);
+            Usm.Version expected_version = null;
+            if(expected.key.constraint != null) {
+                expected_version = expected.key.constraint.version;
+            }
+            else {
+                expected_version = manifest.version;
+            }
+            if(declared == null) {
+                printerr(@"Error: pc provide $(expected.key.base_name) at \"$expected_path\" declares no usable Version field, expected $(expected_version.to_string()).\n");
+                found = false;
+            }
+            else if(!declared.matches_numeric(expected_version)) {
+                printerr(@"Error: pc provide $(expected.key.base_name) at \"$expected_path\" declares version $(declared.to_string()), expected $(expected_version.to_string()).\n");
+                found = false;
+            }
+        }
+
         if(!found) {
             missing_resources.add(expected.key);
         }
@@ -1074,7 +1097,22 @@ private int validate_data_package() {
     var missing_resources = new Invercargill.DataStructures.Vector<Usm.ResourceRef>();
     foreach (var expected in manifest.provides) {
         var expected_path = Path.build_filename(Environment.get_current_dir(), expected.value.path ?? "");
-        if(!File.new_for_path(expected_path).query_exists()) {
+        var found = File.new_for_path(expected_path).query_exists();
+
+        // The same pc version contract as the built-package validator:
+        // a present pc file must declare the ==-declared or package version
+        if(found && expected.key.resource_type == Usm.ResourceType.PKG_CONFIG) {
+            var declared = Usm.PcFile.read_version(expected_path);
+            var expected_version = expected.key.constraint != null
+                ? expected.key.constraint.version
+                : manifest.version;
+            if(declared == null || !declared.matches_numeric(expected_version)) {
+                printerr(@"Error: pc provide $(expected.key.base_name) at \"$expected_path\" declares $(declared != null ? declared.to_string() : "no usable Version field"), expected $(expected_version.to_string()).\n");
+                found = false;
+            }
+        }
+
+        if(!found) {
             missing_resources.add(expected.key);
         }
     }

+ 16 - 2
src/cli/ProgressBar.vala

@@ -161,14 +161,25 @@ namespace Usm.Cli {
         }
 
         /**
-         * Records one progress report and redraws the in-place line. A
-         * report that moves the action counter on, or the current
+         * Records one progress report and redraws the in-place line.
+         * A report that moves the action counter on, or the current
          * action's first 100%, retires the current action: it consumes
          * the in-place line and leaves the persistent
          * `✓ [n/m] <past tense> <package>` log above it — once only,
          * however many further 100% reports the action emits.
+         *
+         * Reports for an action the display has already moved past, or
+         * whose completion already logged, are dropped silently: the
+         * ninja progress reader thread of a build can emit fractions a
+         * moment after the main thread advanced the cursor, and drawing
+         * such a stale report would resurrect a finished action's line,
+         * flicker over the current action and corrupt the retire
+         * bookkeeping.
          */
         public void update(TransactionTask task, float progress, string? package_name, int index, int total) {
+            if(index < current_index || index <= last_logged_action) {
+                return;
+            }
             var same_action = index == current_index;
             var retire = line_live && current_index > last_logged_action
                 && (index > current_index || (same_action && progress >= 1.0f));
@@ -769,6 +780,7 @@ namespace Usm.Cli {
             switch(task) {
                 case TransactionTask.UNPACKING:
                 case TransactionTask.INSTALLING:
+                case TransactionTask.INSTALLING_SYSTEM:
                     return COLOUR_GREEN;
                 case TransactionTask.BUILDING:
                     return COLOUR_BLUE;
@@ -797,6 +809,8 @@ namespace Usm.Cli {
                     return "Tested";
                 case TransactionTask.INSTALLING:
                     return "Installed";
+                case TransactionTask.INSTALLING_SYSTEM:
+                    return "Installed system package";
                 case TransactionTask.REMOVING:
                     return "Removed";
                 case TransactionTask.REBUILDING:

+ 8 - 3
src/cli/Provides.vala

@@ -119,16 +119,21 @@ namespace Usm.Cli {
         }
 
         /**
-         * The first ref in `manifest`'s provides that {@link Usm.ResourceRef.satisfied_by}
-         * matches `resource`, or null when the manifest does not provide it.
+         * The first ref in `manifest`'s provides that satisfies `resource`
+         * (see {@link Usm.ResourceRef.satisfied_by} and the pc
+         * package-version assumption of {@link Usm.Manifest.satisfies}),
+         * or null when the manifest does not provide it.
          */
         private static string? provides_match(Usm.Manifest manifest, Usm.ResourceRef resource) {
+            if(!manifest.satisfies(resource)) {
+                return null;
+            }
             foreach(var provide in manifest.provides) {
                 if(resource.satisfied_by(provide.key)) {
                     return provide.key.to_string();
                 }
             }
-            return null;
+            return @"pc:$(resource.base_name)==$(manifest.version.to_string())";
         }
 
         /**

+ 1 - 1
src/cli/Remove.vala

@@ -185,7 +185,7 @@ private Vector<Usm.CachedPackage> remove_collect_orphans(Dictionary<string, Usm.
             if(gone.contains(name)) {
                 continue;
             }
-            var provides = manifests[name].provides.select<Usm.ResourceRef>(p => p.key);
+            var provides = manifests[name].effective_provides();
             var needed = false;
             foreach(var pair in refs_by_name) {
                 if(pair.key == name || gone.contains(pair.key)) {

+ 41 - 7
src/cli/Update.vala

@@ -195,14 +195,52 @@ private int update_main(string[] args) {
             remove_order.add(package.manifest.name);
         }
 
+        // Resources the chosen system packages will install before this
+        // transaction's first lot runs; the scheduler counts them as
+        // present so system-provided build dependencies do not look unmet
+        // at planning time
+        var preinstalled_resources = new HashSet<Usm.ResourceRef>();
+        foreach(var system_package in resolution.system_packages) {
+            foreach(var resource in system_package.resources) {
+                preinstalled_resources.add(resource);
+            }
+        }
+
+        // The material system install set — chosen packages plus every
+        // native dependency of a dependency — so the plan preview lists
+        // the whole native transaction and every material package gets
+        // its own progress action
+        var system_install_plan = new Vector<string>();
+        if(spm.enabled && resolution.system_packages.any()) {
+            var chosen_system_names = new Vector<string>();
+            foreach(var system_package in resolution.system_packages) {
+                chosen_system_names.add(system_package.name);
+            }
+            system_install_plan = spm.plan_install(chosen_system_names);
+        }
+        var chosen_system_names_set = new HashSet<string>();
+        foreach(var system_package in resolution.system_packages) {
+            chosen_system_names_set.add(system_package.name);
+        }
+        var system_dependencies = new Vector<string>();
+        foreach(var name in system_install_plan) {
+            if(!chosen_system_names_set.contains(name)) {
+                system_dependencies.add(name);
+            }
+        }
+
         var transaction = new Usm.Transaction() {
             paths = paths,
             resource_finder = new Usm.ResourceFinder(paths),
+            preinstalled_resources = preinstalled_resources,
             to_remove = to_remove,
             to_install = cached_packages,
             install_order = install_order,
             remove_order = remove_order,
-            state = state
+            state = state,
+            system_package_manager = spm,
+            system_packages = resolution.system_packages,
+            system_install_plan = system_install_plan
         };
 
         // Planning before the prompt populates the rebuildDependants
@@ -217,6 +255,8 @@ private int update_main(string[] args) {
 
         var summary = new Usm.TransactionSummary() {
             to_install = cached_packages.to_vector(),
+            system_installs = resolution.system_packages,
+            system_dependencies = system_dependencies,
             to_remove = to_remove.to_vector(),
             to_rebuild = transaction.rebuilds,
             download_count = download_count,
@@ -230,12 +270,6 @@ private int update_main(string[] args) {
         // Only now that the plan is confirmed do the packages transfer
         install_download_packages(state, resolution, current_names);
 
-        if(resolution.system_packages.any()) {
-            if(!install_system_packages(spm, resolution.system_packages)) {
-                return 239;
-            }
-        }
-
         printerr("\nRunning transaction...\n");
         transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.run();

+ 3 - 1
src/lib/Dependencies.vala

@@ -182,7 +182,9 @@ namespace Usm {
                 );
             }
             try {
-                return new ResourceRef(text);
+                var parsed = new ResourceRef(text);
+                parsed.ensure_valid_dependency();
+                return parsed;
             }
             catch(Error e) {
                 throw new ManifestError.INVALID_DEPENDENCIES(

+ 47 - 3
src/lib/Manifest.vala

@@ -183,10 +183,54 @@ namespace Usm {
                 }
                 // Validate the ManifestFile after creation
                 file.validate();
-                provides[new ResourceRef(pair.key)] = file;
+                var key = new ResourceRef(pair.key);
+                key.ensure_valid_provide();
+                provides[key] = file;
             }
         }
 
+        /**
+         * Whether this manifest's provides satisfy the given dependency
+         * ref: {@link ResourceRef.satisfied_by} against every provide
+         * key (soname tails for `lib`, `==` declarations for `pc`),
+         * plus the pc fallback — a `pc:` provide WITHOUT a `==`
+         * declaration is assumed to carry this package's own version.
+         */
+        public bool satisfies(ResourceRef dependency) {
+            if(provides.any(r => dependency.satisfied_by(r.key))) {
+                return true;
+            }
+            if(dependency.constraint != null && dependency.resource_type == ResourceType.PKG_CONFIG) {
+                return provides.any(r => r.key.resource_type == ResourceType.PKG_CONFIG
+                    && r.key.constraint == null
+                    && r.key.resource == dependency.base_name
+                    && dependency.constraint.satisfied_by(version));
+            }
+            return false;
+        }
+
+        /**
+         * The provides keys as flat matching refs: `pc:` provides
+         * without a `==` declaration are materialised as
+         * `pc:<name>==<package version>`, so sets collected from this
+         * (transaction lot scheduling, removal planning) match both
+         * plain and constrained dependencies without carrying the
+         * manifest along.
+         */
+        public Enumerable<ResourceRef> effective_provides() {
+            return provides.select<ResourceRef>(p => {
+                if(p.key.resource_type == ResourceType.PKG_CONFIG && p.key.constraint == null) {
+                    try {
+                        return new ResourceRef(@"pc:$(p.key.resource)==$(version.to_string())");
+                    }
+                    catch(Error e) {
+                        return p.key;
+                    }
+                }
+                return p.key;
+            });
+        }
+
         private Properties map_from_provides_dict() {
             var dict = new PropertyDictionary();
             var mapper = ManifestFile.get_mapper();
@@ -242,7 +286,7 @@ namespace Usm {
             return result;
         }
 
-        public Subprocess run_build(string build_path, Paths paths, SubprocessFlags flags, ProgressDelegate? progress_delegate = null) throws Error {
+        public Subprocess run_build(string build_path, Paths paths, SubprocessFlags flags, owned ProgressDelegate? progress_delegate = null) throws Error {
             if(executables.build == null) {
                 throw new ManifestError.MISSING_FIELD(@"Manifest \"$name\" defines no build executable");
             }
@@ -394,7 +438,7 @@ namespace Usm {
          * installs several outputs per resource. Otherwise the flags pass
          * through {@link verbose_flags} unchanged (silenced by default).
          */
-        public Subprocess? run_install(string build_path, string install_path, Paths paths, InstallType type, SubprocessFlags flags, ProgressDelegate? progress_delegate = null) throws Error {
+        public Subprocess? run_install(string build_path, string install_path, Paths paths, InstallType type, SubprocessFlags flags, owned ProgressDelegate? progress_delegate = null) throws Error {
             if(executables.install == null) {
                 return null;
             }

+ 2 - 2
src/lib/Paths.vala

@@ -107,9 +107,9 @@ namespace Usm {
 
         public string get_suggested_path_for_resource(ResourceRef resource) {
             if (resource.resource_type == ResourceType.TAG) {
-                return get_tag_file_path(resource.resource);
+                return get_tag_file_path(resource.base_name);
             }
-            return Path.build_filename(get_suggested_base_path_for_type(resource.resource_type), resource.resource);
+            return Path.build_filename(get_suggested_base_path_for_type(resource.resource_type), resource.base_name);
         }
 
 

+ 49 - 0
src/lib/PcFile.vala

@@ -0,0 +1,49 @@
+namespace Usm {
+
+    /**
+     * Minimal pkg-config file reader: extracts the `Version:` field of
+     * a `.pc` file, the only part USM consults (dependency constraints
+     * and provides validation). Key parsing follows pkg-config's own
+     * conventions — `Keyword: value` with optional surrounding
+     * whitespace, `#` comments — without any variable expansion: a
+     * version line containing `${...}` references yields null rather
+     * than a guess.
+     */
+    public class PcFile {
+
+        /**
+         * The version declared by the `Version:` keyword of the .pc
+         * file at {@link path}, or null when the file cannot be read,
+         * carries no `Version:` keyword, or declares something that is
+         * not a dotted numeric version.
+         */
+        public static Version? read_version(string path) {
+            string contents = null;
+            try {
+                if(!FileUtils.get_contents(path, out contents)) {
+                    return null;
+                }
+            }
+            catch(Error e) {
+                return null;
+            }
+            foreach(var raw_line in contents.split("\n")) {
+                var line = raw_line.chomp().chug();
+                if(line.has_prefix("#") || !line.has_prefix("Version:")) {
+                    continue;
+                }
+                var value = line.substring("Version:".length).chomp().chug();
+                if(value.length == 0 || value.contains("${")) {
+                    return null;
+                }
+                try {
+                    return new Version.from_string(value);
+                }
+                catch(Error e) {
+                    return null;
+                }
+            }
+            return null;
+        }
+    }
+}

+ 11 - 4
src/lib/Resolver.vala

@@ -205,7 +205,7 @@ namespace Usm {
          */
         public AbstractPackage? find_resource(ResourceRef resource) {
             foreach(var package in catalog()) {
-                if(package.manifest.provides.any(r => resource.satisfied_by(r.key))) {
+                if(package.manifest.satisfies(resource)) {
                     return package;
                 }
             }
@@ -343,7 +343,7 @@ namespace Usm {
                         if(provider_name == dependent_name || linked_providers.has(provider_name)) {
                             continue;
                         }
-                        if(provider.manifest.provides.any(p => resource.satisfied_by(p.key))) {
+                        if(provider.manifest.satisfies(resource)) {
                             linked_providers.add(provider_name);
                             Vector<string> follower_names;
                             if(!dependents.try_get(provider_name, out follower_names)) {
@@ -476,6 +476,13 @@ namespace Usm {
 
             var missing = new Vector<ResourceRef>();
             foreach(var resource in refs.sort((a, b) => a.to_string().collate(b.to_string()))) {
+                // Version-constrained refs have no SPM translation yet:
+                // shims match names exactly, so querying them would only
+                // yield misleading not-founds. They resolve from local
+                // presence or USM packages.
+                if(resource.constraint != null) {
+                    continue;
+                }
                 if(!has_local(resource)) {
                     missing.add(resource);
                 }
@@ -748,7 +755,7 @@ namespace Usm {
                     }
                     var provided = false;
                     foreach(var resource in refs) {
-                        if(provider.manifest.provides.any(p => resource.satisfied_by(p.key))) {
+                        if(provider.manifest.satisfies(resource)) {
                             provided = true;
                             break;
                         }
@@ -836,7 +843,7 @@ namespace Usm {
         }
 
         public bool provides(ResourceRef resource) {
-            return any(p => p.manifest.provides.any(r => resource.satisfied_by(r.key)));
+            return any(p => p.manifest.satisfies(resource));
         }
 
         private static DependencyPhase manifest_phase_of(Manifest manifest, string phase_name) {

+ 51 - 6
src/lib/ResourceFinder.vala

@@ -80,11 +80,48 @@ namespace Usm {
             // musl systems have no `ldconfig -p` output to consult: fall
             // back to the conventional library directories
             foreach(var directory in new string[] { "/lib", "/usr/lib", "/lib64", "/usr/lib64" }) {
-                var file = File.new_build_filename(directory, resource.resource);
-                if(file.query_exists()) {
-                    return file.get_path();
+                var hit = locate_lib_in_directory(resource, directory);
+                if(hit != null) {
+                    return hit;
+                }
+            }
+            return null;
+        }
+
+        /**
+         * Whether the concrete library name {@link candidate} satisfies
+         * the ref: exact filename for plain refs; for constrained refs
+         * the name must be the base plus a fully numeric dotted tail
+         * (the soname version) that satisfies the constraint — a bare
+         * "libcmark.so" declares no version.
+         */
+        private static bool lib_name_matches(ResourceRef resource, string candidate) {
+            if(resource.constraint == null) {
+                return candidate == resource.base_name;
+            }
+            var tail = ResourceRef.version_tail(resource.base_name, candidate);
+            return tail != null && resource.constraint.satisfied_by(tail);
+        }
+
+        private string? locate_lib_in_directory(ResourceRef resource, string directory) {
+            if(resource.constraint == null) {
+                var file = File.new_build_filename(directory, resource.base_name);
+                return file.query_exists() ? file.get_path() : null;
+            }
+            // Constrained: enumerate the directory and version-match soname tails
+            try {
+                foreach(var entry in Iterate.directory(directory)) {
+                    if(!lib_name_matches(resource, entry)) {
+                        continue;
+                    }
+                    var file = File.new_build_filename(directory, entry);
+                    if(file.query_exists()) {
+                        return file.get_path();
+                    }
                 }
             }
+            catch(FileError e) {
+            }
             return null;
         }
 
@@ -97,7 +134,7 @@ namespace Usm {
                 while((line = pipe.read_line()) != null) {
                     if(line.has_prefix("\t")) {
                         var name = line.substring(1).split(" ", 2)[0];
-                        if(resource.resource == name) {
+                        if(lib_name_matches(resource, name)) {
                             return line.substring(1).split("=>", 2)[1].chomp().chug();
                         }
                     }
@@ -287,9 +324,17 @@ namespace Usm {
             }
 
             foreach (var path in search) {
-                var file = File.new_build_filename(paths.destination, path, resource.resource);
+                var file = File.new_build_filename(paths.destination, path, resource.base_name);
                 if(file.query_exists()) {
-                    return file.get_path();
+                    // A constrained pc ref must also be satisfied by the
+                    // version the .pc file itself declares
+                    if(resource.constraint == null) {
+                        return file.get_path();
+                    }
+                    var declared = PcFile.read_version(file.get_path());
+                    if(declared != null && resource.constraint.satisfied_by(declared)) {
+                        return file.get_path();
+                    }
                 }
             }
             return null;

+ 133 - 18
src/lib/ResourceRef.vala

@@ -167,20 +167,48 @@ namespace Usm {
     // Hashable/Equatable interface registrations, and every equals()/hash_code()
     // dispatch (set membership, satisfied_by) dereferences a missing vtable
     public class ResourceRef : Object, Hashable, Equatable<ResourceRef> {
+        /** The full resource string after the type, including any version constraint suffix. */
         public string resource { get; set; }
         public ResourceType resource_type { get; set; }
+        /**
+         * The resource name with any version constraint suffix removed —
+         * the concrete file/soname name. Equal to {@link resource} for
+         * constraint-free refs; path computation and filesystem lookups
+         * must always use this, never {@link resource}.
+         */
+        public string base_name { get; set; }
+        /** The version constraint carried by a dependency ref, or null. */
+        public VersionConstraint? constraint { get; set; }
 
         public ResourceRef(string str) throws ManifestError {
             var parts = str.split(":", 2);
+            if(parts.length < 2 || parts[1] == null || parts[1].length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$str\" must be a \"type:name\" pair.");
+            }
             resource_type = ResourceType.from_string(parts[0]);
             resource = parts[1];
+            string name_base;
+            constraint = VersionConstraint.find_in(resource, out name_base);
+            base_name = name_base;
+            if(base_name.length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$str\" has no resource name.");
+            }
         }
 
-        public ResourceRef.with_type(ResourceType type, string name) {
+        public ResourceRef.with_type(ResourceType type, string name) throws ManifestError {
             resource_type = type;
             resource = name;
+            if(name == null || name.length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$resource_type:\" has no resource name.");
+            }
+            string name_base;
+            constraint = VersionConstraint.find_in(resource, out name_base);
+            base_name = name_base;
+            if(base_name.length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$resource_type:$name\" has no resource name.");
+            }
         }
-        
+
         public uint hash_code() {
             return to_string().hash();
         }
@@ -191,23 +219,110 @@ namespace Usm {
             return @"$resource_type:$resource";
         }
 
+        /**
+         * The dotted numeric version carried by a resource name after
+         * {@link prefix}, or null: "libcmark.so.0.31.1" over prefix
+         * "libcmark.so" yields 0.31.1, while "libcmark.so" itself (no
+         * tail) and "libcmark.so.0.31rc1" (non-numeric tail) yield null
+         * — a name without a fully numeric dotted tail declares no
+         * version.
+         */
+        public static Version? version_tail(string prefix, string name) {
+            if(!name.has_prefix(prefix + ".")) {
+                return null;
+            }
+            var tail = name.substring(prefix.length + 1);
+            foreach(var component in tail.split(".")) {
+                if(component.length == 0) {
+                    return null;
+                }
+                for(int index = 0; index < component.length; index++) {
+                    if(!component[index].isdigit()) {
+                        return null;
+                    }
+                }
+            }
+            try {
+                return new Version.from_string(tail);
+            }
+            catch(Error e) {
+                return null;
+            }
+        }
+
+        /**
+         * Enforces the dependency-side operator rules: version
+         * constraints are valid only where a resource's concrete names
+         * carry a version — `lib` (soname tails) and `pc` (the .pc
+         * `Version:` field / `==` declarations on provides).
+         */
+        public void ensure_valid_dependency() throws ManifestError {
+            if(constraint == null) {
+                return;
+            }
+            if(resource_type != ResourceType.LIBRARY && resource_type != ResourceType.PKG_CONFIG) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(
+                    @"Version constraints are only available for \"lib\" and \"pc\" resource types, not \"$(resource_type.to_string())\" (in \"$(to_string())\")."
+                );
+            }
+        }
+
+        /**
+         * Enforces the provides-side rules: only the `==` operator may
+         * appear, only on `pc` provides, where it DECLARES the version
+         * of the concrete file. Sonames already declare their version
+         * in the filename tail, and every other type has no version
+         * source at all.
+         */
+        public void ensure_valid_provide() throws ManifestError {
+            if(constraint == null) {
+                return;
+            }
+            if(resource_type != ResourceType.PKG_CONFIG) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(
+                    @"Only \"pc\" provides may declare a version with \"==\"; \"$(resource_type.to_string())\" provides must stay concrete (in \"$(to_string())\")."
+                );
+            }
+            if(constraint.operation != VersionOperator.EQUALS) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(
+                    @"Provides may only carry the \"==\" version declaration, not \"$(constraint.operation.to_string())\" (in \"$(to_string())\")."
+                );
+            }
+        }
+
         public bool satisfied_by(ResourceRef other) {
-            // Direct match
-            if (equals(other)) {
-                return true;
-            }
-            
-            // canonlib can satisfy lib dependencies of the same name
-            if (resource_type == ResourceType.LIBRARY && other.resource_type == ResourceType.CANONICAL_LIBRARY) {
-                return resource == other.resource;
-            }
-            
-            // canonlibres can satisfy libres dependencies of the same name
-            if (resource_type == ResourceType.LIBRARY_RESOURCE && other.resource_type == ResourceType.CANONICAL_LIBRARY_RESOURCE) {
-                return resource == other.resource;
-            }
-            
-            return false;
+            var type_compatible = resource_type == other.resource_type
+                // canonlib can satisfy lib dependencies of the same name
+                || (resource_type == ResourceType.LIBRARY && other.resource_type == ResourceType.CANONICAL_LIBRARY)
+                // canonlibres can satisfy libres dependencies of the same name
+                || (resource_type == ResourceType.LIBRARY_RESOURCE && other.resource_type == ResourceType.CANONICAL_LIBRARY_RESOURCE);
+
+            if(constraint == null) {
+                // Plain dependency: matches any provide of the base name,
+                // whether or not the provide declares a version
+                return type_compatible && resource == other.base_name;
+            }
+
+            if(!type_compatible) {
+                return false;
+            }
+
+            Version? provided_version = null;
+            if(resource_type == ResourceType.LIBRARY) {
+                // Sonames carry their version in the filename tail: the
+                // provide extends the dependency's base ("libcmark.so"
+                // is satisfied by "libcmark.so.0.31.1"); a bare
+                // "libcmark.so" declares nothing
+                provided_version = version_tail(base_name, other.resource);
+            }
+            else if(base_name == other.base_name && other.constraint != null) {
+                // A pc provide's == declaration
+                provided_version = other.constraint.version;
+            }
+            if(provided_version == null) {
+                return false;
+            }
+            return constraint.satisfied_by(provided_version);
         }
     }
 

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

@@ -177,7 +177,6 @@ namespace Usm {
         public Vector<string> find_dependant_names(CachedPackage updated) throws Error {
             var dependants = new Vector<string>();
             var updated_manifest = updated.get_manifest();
-            var provides_keys = updated_manifest.provides.select<ResourceRef>(p => p.key);
 
             foreach(var installed in get_installed_packages()) {
                 var manifest = installed.get_manifest();
@@ -193,7 +192,7 @@ namespace Usm {
                     refs.union_with(manifest.dependencies.acquire.all_refs());
                 }
 
-                if(refs.any(d => provides_keys.any(p => d.satisfied_by(p)))) {
+                if(refs.any(d => updated_manifest.satisfies(d))) {
                     dependants.add(installed.package_name);
                 }
             }

+ 68 - 0
src/lib/SystemPackageManager.vala

@@ -96,6 +96,19 @@ namespace Usm {
         }
     }
 
+    /** The `plan` helper mode's result: the material install set's names. */
+    public class SystemPlanResult {
+
+        public Vector<string> packages { get; set; }
+
+        public static PropertyMapper<SystemPlanResult> get_mapper() {
+            return PropertyMapper.build_for<SystemPlanResult>(cfg => {
+                cfg.map_many<string>("packages", o => o.packages, (o, v) => o.packages = v.to_vector());
+                cfg.set_constructor(() => new SystemPlanResult());
+            });
+        }
+    }
+
     /**
      * Client for the system package manager configured in usm.config's
      * "system_package_manager" section ({@link SystemPackageManagerConfig}).
@@ -221,6 +234,61 @@ namespace Usm {
          * (musl systems running the glibc-built usm via gcompat) whenever
          * the read has to wait for data, which the apk target deploys on.
          */
+        /**
+         * Resolves the material install set for {@link native_names}: every
+         * package the manager would actually install — the chosen names
+         * plus every dependency of a dependency — by asking the helper's
+         * `plan` mode (same executable as the query mode, mode word
+         * swapped). The names come back in the manager's own transaction
+         * order. Helpers without a plan mode (or any failure — resolution
+         * problem, unknown names) degrade to the input names unchanged,
+         * so the caller always gets a usable action list.
+         */
+        public Vector<string> plan_install(Vector<string> native_names) {
+            var names = new Vector<string>();
+            foreach(var name in native_names) {
+                names.add(name);
+            }
+            if(!enabled) {
+                return names;
+            }
+
+            var plan_argv = new string[query_argv.length];
+            for(int index = 0; index < query_argv.length; index++) {
+                plan_argv[index] = query_argv[index];
+            }
+            plan_argv[plan_argv.length - 1] = "plan";
+            var arguments = new Vector<string>();
+            foreach(var name in native_names) {
+                arguments.add(name);
+            }
+
+            try {
+                var proc = new Subprocess.newv(build_argv(plan_argv, arguments), SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_SILENCE);
+                string stdout_buffer = null;
+                string stderr_buffer = null;
+                proc.communicate_utf8(null, null, out stdout_buffer, out stderr_buffer);
+                if(proc.get_exit_status() == 0 && stdout_buffer != null && stdout_buffer.strip().length > 0) {
+                    var result = SystemPlanResult.get_mapper().materialise(
+                        new JsonElement.from_string(stdout_buffer).as<Invercargill.Properties>());
+                    if(result.packages != null && result.packages.length > 0) {
+                        var planned = new Vector<string>();
+                        var seen = new HashSet<string>();
+                        foreach(var package_name in result.packages) {
+                            if(!seen.contains(package_name)) {
+                                seen.add(package_name);
+                                planned.add(package_name);
+                            }
+                        }
+                        return planned;
+                    }
+                }
+            }
+            catch(Error e) {
+            }
+            return names;
+        }
+
         public async bool install(Vector<string> native_names, InstallProgressDelegate? progress = null) throws Error {
             if(!enabled) {
                 warning("[Usm] System package manager install requested but no system package manager is configured");

+ 393 - 20
src/lib/Transaction.vala

@@ -7,6 +7,18 @@ namespace Usm {
 
         public Paths paths { get; set; }
         public ResourceFinder resource_finder { get; set; }
+
+        /**
+         * Resources guaranteed present on the destination before the first
+         * install lot runs: the system packages chosen during dependency
+         * resolution install ahead of the USM transaction (see
+         * `Install.run`). The lot scheduler treats them as locally
+         * present — without this, every system-provided build dependency
+         * (compilers, pkg-config files, ...) looks unmet at planning time,
+         * because strategise runs before anything is installed.
+         */
+        public Set<ResourceRef> preinstalled_resources { get; set; default = new HashSet<ResourceRef>(); }
+
         public SystemState state { get; set; }
         public Set<CachedPackage> to_install { get; set; default = new HashSet<CachedPackage>(); }
         public Set<CachedPackage> to_remove { get; set; default = new HashSet<CachedPackage>(); }
@@ -43,6 +55,59 @@ namespace Usm {
          */
         public Vector<DowngradeEntry> to_downgrade { get; set; default = new Vector<DowngradeEntry>(); }
 
+        /**
+         * The system package manager the {@link system_packages} install
+         * through, as one native transaction that runs BEFORE the first
+         * USM install lot — providing the toolchain and libraries the
+         * builds depend on. Null (or an empty {@link system_packages})
+         * makes the transaction USM-only, exactly as before.
+         */
+        public SystemPackageManager? system_package_manager { get; set; }
+
+        /**
+         * System (native) packages chosen during dependency resolution
+         * that this transaction installs itself, each as one
+         * {@link TransactionTask.INSTALLING_SYSTEM} action: they appear
+         * in the plan preview, drive the shared progress display, and
+         * count towards the transaction's overall progress. The lot
+         * scheduler also treats their resources as present (see
+         * {@link preinstalled_resources}).
+         */
+        public Vector<SystemPackageCandidate> system_packages { get; set; default = new Vector<SystemPackageCandidate>(); }
+
+        /**
+         * The material system install set — every native package the
+         * manager would install for {@link system_packages}, chosen
+         * names plus every dependency of a dependency, in the manager's
+         * own transaction order (from
+         * {@link SystemPackageManager.plan_install}). Each name in this
+         * list is one {@link TransactionTask.INSTALLING_SYSTEM} action
+         * with its own progress and completion log, so the overall
+         * percentage covers the whole native transaction. Empty (helpers
+         * without a plan mode) falls back to one action per chosen
+         * {@link system_packages} entry.
+         */
+        public Vector<string> system_install_plan { get; set; default = new Vector<string>(); }
+
+        /**
+         * The names of the system-phase actions, plan first: every
+         * material package when a plan exists, otherwise the chosen
+         * candidates alone.
+         */
+        private Vector<string> effective_system_names() {
+            var names = new Vector<string>();
+            if(system_install_plan.length > 0) {
+                foreach(var name in system_install_plan) {
+                    names.add(name);
+                }
+                return names;
+            }
+            foreach(var candidate in system_packages) {
+                names.add(candidate.name);
+            }
+            return names;
+        }
+
         public signal void progress_updated(TransactionTask task_type, string subject, uint current_task, uint total_tasks, float task_progress);
 
         private uint task_count = 0;
@@ -106,21 +171,26 @@ namespace Usm {
                 var all_packages = planned_removal.concat(planned_install);
                 var rebuild_packages = rebuilds.select<CachedPackage>(r => r.package).to_vector();
 
-                // 2. Unpack packages
+                // 2. System packages install first, as one native
+                // transaction, providing the toolchain and libraries the
+                // builds depend on
+                install_system_packages();
+
+                // 3. Unpack packages
                 do_for(all_packages, unpack_package, TransactionTask.UNPACKING);
                 do_for(rebuild_packages, unpack_package, TransactionTask.REBUILDING);
 
-                // 3. Remove packages
+                // 4. Remove packages
                 do_for(removal_order, remove_package, TransactionTask.REMOVING);
 
                 foreach (var lot in install_lots) {
-                    // 3. Build packages
+                    // 5. Build packages
                     do_for(lot, build_package, TransactionTask.BUILDING);
 
-                    // 4. Test packages
+                    // 6. Test packages
                     do_for(lot, test_package, TransactionTask.TESTING);
 
-                    // 5. Install packages
+                    // 7. Install packages
                     do_for(lot, install_package, TransactionTask.INSTALLING);
                 }
 
@@ -131,7 +201,7 @@ namespace Usm {
                 do_for(rebuild_packages, test_package, TransactionTask.TESTING);
                 do_for(rebuild_packages, install_package, TransactionTask.INSTALLING);
 
-                // 6. Clean up
+                // 8. Clean up
                 do_for(all_packages, cleanup_package, TransactionTask.CLEANING_UP);
                 do_for(rebuild_packages, cleanup_package, TransactionTask.CLEANING_UP);
 
@@ -205,6 +275,110 @@ namespace Usm {
             printerr(journal.describe_rollback() + "\n");
         }
 
+        /**
+         * Installs the system phase through {@link
+         * system_package_manager} as the transaction's first actions:
+         * one native transaction whose streaming events drive one
+         * {@link TransactionTask.INSTALLING_SYSTEM} action per package
+         * of {@link effective_system_names} — in the plan preview, on
+         * the shared progress display and in the overall task count.
+         *
+         * Action slots are claimed IN COMPLETION ORDER, never by the
+         * helper's own positions or the plan's ordering: downloads and
+         * the rpm transaction each stream `package` events in their own
+         * order, neither matching the plan, so a fixed mapping would
+         * hand the display out-of-order indexes that the stale-report
+         * guard (correctly) drops. Completions are strictly sequential
+         * in the native transaction, so each one claims the next slot
+         * and logs; a fraction event for a package that has not yet
+         * completed draws on the next slot to be claimed — the running
+         * package — without claiming it. Names outside the plan (a
+         * differently-resolved run-time transaction) share the bound of
+         * {@link effective_system_names}.length slots and fold silently
+         * once the bound is reached.
+         *
+         * A native package cannot be rolled back by the journal; on
+         * failure the thrown {@link TransactionError.INSTALL_ERROR} still
+         * unwinds and rolls back every USM action (none has run yet). A
+         * no-op when no manager is set or the package list is empty.
+         */
+        private void install_system_packages() throws TransactionError {
+            var action_names = effective_system_names();
+            if(system_package_manager == null || (system_packages.length == 0 && system_install_plan.length == 0)) {
+                return;
+            }
+
+            var planned = new HashSet<string>();
+            foreach(var name in action_names) {
+                planned.add(name);
+            }
+
+            var install_names = new Vector<string>();
+            foreach(var candidate in system_packages) {
+                if(!install_names.any(n => n == candidate.name)) {
+                    install_names.add(candidate.name);
+                }
+            }
+
+            string failure = "";
+            var claimed_slot = new Dictionary<string, int>();
+            int next_slot = 0;
+            var loop = new MainLoop();
+            system_package_manager.install.begin(install_names, event => {
+                switch(event.event_type) {
+                    case SystemInstallEventType.BEGIN:
+                        break;
+                    case SystemInstallEventType.PACKAGE:
+                        if(event.name != null && next_slot < action_names.length) {
+                            int slot;
+                            if(!claimed_slot.try_get(event.name, out slot)) {
+                                // The running package targets the slot the
+                                // next completion will claim
+                                slot = next_slot;
+                            }
+                            begin_action(TransactionTask.INSTALLING_SYSTEM, event.name, 1 + slot);
+                            report_progress(TransactionTask.INSTALLING_SYSTEM, (float)event.progress);
+                        }
+                        break;
+                    case SystemInstallEventType.PACKAGE_COMPLETE:
+                        if(event.name != null && next_slot < action_names.length) {
+                            int slot;
+                            if(!claimed_slot.try_get(event.name, out slot)) {
+                                slot = next_slot++;
+                                claimed_slot.set(event.name, slot);
+                            }
+                            begin_action(TransactionTask.INSTALLING_SYSTEM, event.name, 1 + slot);
+                            report_progress(TransactionTask.INSTALLING_SYSTEM, 1.0f);
+                        }
+                        break;
+                    case SystemInstallEventType.COMPLETE:
+                        break;
+                    case SystemInstallEventType.ERROR:
+                        failure = event.message ?? "unknown error";
+                        break;
+                }
+            }, (obj, res) => {
+                try {
+                    if(!system_package_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();
+
+            // Whatever the helper streamed, the USM actions start right
+            // after the last system action's slot
+            begin_action(TransactionTask.STRATEGISING, "transaction", 1 + action_names.length);
+
+            if(failure.length > 0) {
+                throw new TransactionError.INSTALL_ERROR(@"System package installation failed: $failure");
+            }
+        }
+
         /**
          * `<destination>/<local-state>/usm/backup` — the journal's home,
          * rooted like every other destination this transaction writes
@@ -271,8 +445,8 @@ namespace Usm {
                 throw new TransactionError.UNKNOWN_ERROR(@"Failed to read manifest while planning removals: $(e.message)");
             }
 
-            task_count = (planned_install.count() * 5) + (planned_removal.count() * 3) + 1;
-            uint strategise_worst_case_task_count = (planned_removal.count() * planned_removal.count()) + (planned_install.count() * planned_install.count());
+            task_count = (planned_install.count() * 5) + (planned_removal.count() * 3) + effective_system_names().length + 1;
+            uint strategise_worst_case_task_count = (planned_removal.count() * planned_removal.count()) + (planned_install.count() * planned_install.count()) + effective_system_names().length;
             uint strategise_current_task = 0;
 
             report_progress(TransactionTask.STRATEGISING, 0.0f);
@@ -302,18 +476,20 @@ namespace Usm {
                         // 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));
                         if(manifest.dependencies.manage.is_satisfied(installtime_satisfied) &&
                                 manifest.dependencies.build.is_satisfied(buildtime_satisfied)) {
                             lot.add(package);
                             touched.add(package);
-                            installed_by_this_lot.union_with(manifest.provides.select<ResourceRef>(p => p.key));
+                            installed_by_this_lot.union_with(manifest.effective_provides());
                         }
                         strategise_current_task++;
                     }
@@ -323,8 +499,9 @@ namespace Usm {
                 }
 
                 if(lot.count() == 0) {
-                    var packages = ordered_install.exclude(touched).to_string(p => p.package_name, ", ");
-                    throw new TransactionError.INVALID_TRANSACTION(@"Could not build a transaction strategy, packages $(packages) have unmet or cyclical dependencies");
+                    throw new TransactionError.INVALID_TRANSACTION(
+                        describe_install_deadlock(ordered_install.exclude(touched), installed_by_earlier_lots, planned_install)
+                    );
                 }
 
                 installed_by_earlier_lots.union_with(installed_by_this_lot);
@@ -362,7 +539,7 @@ namespace Usm {
                     var removed_this_round = false;
                     foreach (var package in remaining_to_remove.sort((a, b) => a.package.package_name.collate(b.package.package_name))) {
                         report_progress(TransactionTask.STRATEGISING, (float)strategise_current_task / (float)strategise_worst_case_task_count);
-                        if(remaining_to_remove.no(p => p.manifest.dependencies.manage.all_refs().any(d => package.manifest.provides.any(r => d.satisfied_by(r.key))))) {
+                        if(remaining_to_remove.no(p => p.manifest.dependencies.manage.all_refs().any(d => package.manifest.satisfies(d)))) {
                             removal_order.add(package.package);
                             remaining_to_remove.remove(package);
                             strategise_current_task++;
@@ -374,8 +551,7 @@ namespace Usm {
                     }
 
                     if(!removed_this_round) {
-                        var packages = remaining_to_remove.to_string(p => p.package.package_name, ", ");
-                        throw new TransactionError.INVALID_TRANSACTION(@"Could not build a transaction strategy, packages $(packages) have unmet or cyclical dependencies");
+                        throw new TransactionError.INVALID_TRANSACTION(describe_removal_deadlock(remaining_to_remove));
                     }
                 }
             }
@@ -387,6 +563,153 @@ namespace Usm {
             current_task++;
         }
 
+        /**
+         * Whether a resource is satisfiable without the packages this
+         * explainer deals with: present on the destination, provided by a
+         * preinstalled (system) package, or provided by a lot scheduled
+         * before the stall.
+         */
+
+        /**
+         * Explains why the install lot scheduler stalled: for every
+         * unscheduled package, each blocking dependency is reported with
+         * its exact cause — unmet (nothing present or planned provides it)
+         * or cyclical (only packages that are themselves unscheduled
+         * provide it) — naming the providers, the phase carrying the ref,
+         * and the lot rule that applies (build-phase refs must come from
+         * an EARLIER lot; manage-phase refs may share the lot).
+         */
+        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));
+
+            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);
+            if(preinstalled_resources.any()) {
+                builder.append_printf(" (%u resource(s) already planned via system packages are counted as present)", preinstalled_resources.count());
+            }
+            builder.append(":\n");
+            foreach(var package in stuck_sorted) {
+                builder.append_printf("  %s:\n", package.package_name);
+                Manifest manifest;
+                try {
+                    manifest = package.get_manifest();
+                }
+                catch(Error e) {
+                    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);
+            }
+            return builder.str.chomp();
+        }
+
+        /**
+         * Appends the refs of one dependency phase that nothing schedulable
+         * 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) {
+            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);
+                    }
+                }
+                return;
+            }
+            var groups = phase.ordered_groups();
+            for(uint index = 0; index < groups.length; index++) {
+                var blocking = groups[index].where(r => !satisfier(r)).to_vector();
+                if(blocking.length == 0) {
+                    return;
+                }
+                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);
+                }
+            }
+        }
+
+        /**
+         * Appends one blocking ref with its exact cause and, when other
+         * 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) {
+            var providers = new Vector<string>();
+            foreach(var candidate in planned_install) {
+                if(candidate == package) {
+                    continue;
+                }
+                try {
+                    if(candidate.get_manifest().satisfies(resource)) {
+                        providers.add(candidate.package_name);
+                    }
+                }
+                catch(Error e) {
+                    builder.append_printf("    %s (%s phase): provider check failed for \"%s\": %s\n", resource.to_string(), phase_name, candidate.package_name, e.message);
+                }
+            }
+            if(providers.length == 0) {
+                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;
+            }
+            var self_cycle = false;
+            try {
+                self_cycle = package.get_manifest().satisfies(resource);
+            }
+            catch(Error e) {
+            }
+            if(self_cycle) {
+                providers.add(@"$(package.package_name) (itself)");
+            }
+            builder.append_printf(
+                "    %s (%s phase) is part of a dependency cycle: only %s provide%s it, and none of them can be scheduled before \"%s\"\n",
+                resource.to_string(), phase_name, providers.to_string(p => p, ", "), providers.length == 1 ? "s" : "", package.package_name
+            );
+        }
+
+        /**
+         * Explains why no remaining package can be removed: a package is
+         * stuck when another still-to-be-removed package's manage-phase
+         * dependencies are satisfied by its provides, so each blocking
+         * (remover, blocked-by, ref) pair is reported — a pure cycle when
+         * every remaining package is blocked.
+         */
+        private string describe_removal_deadlock(Set<CachedPackageManifest> remaining) {
+            var builder = new StringBuilder();
+            builder.append_printf("Could not build a transaction strategy: %u package(s) could not be scheduled for removal:\n", remaining.count());
+            foreach(var entry in remaining.sort((a, b) => a.package.package_name.collate(b.package.package_name))) {
+                var blockers = new Vector<string>();
+                foreach(var other in remaining) {
+                    if(other == entry) {
+                        continue;
+                    }
+                    foreach(var dependency in other.manifest.dependencies.manage.all_refs()) {
+                        if(entry.manifest.satisfies(dependency)) {
+                            blockers.add(@"\"$(other.package.package_name)\" still needs $(dependency.to_string()) (manage phase)");
+                            break;
+                        }
+                    }
+                }
+                if(blockers.length == 0) {
+                    builder.append_printf("  %s: no remaining package depends on it, but nothing else could be scheduled either\n", entry.package.package_name);
+                }
+                else {
+                    builder.append_printf("  %s: %s\n", entry.package.package_name, blockers.to_string(b => b, "; "));
+                }
+            }
+            return builder.str.chomp();
+        }
+
         /**
          * Plans rebuildDependants rebuilds: every package in
          * {@link to_install} flagged
@@ -491,14 +814,37 @@ namespace Usm {
         }
 
         private delegate void PackageDelegate(CachedPackage package) throws Error;
+
+        /**
+         * Serialises progress reporting: the ninja progress reader
+         * thread of a `ninjaStyleProgress` build emits fractions
+         * concurrently with the main thread advancing between actions,
+         * so the reporting fields and the {@link progress_updated}
+         * emissions (and the display behind them) must never interleave.
+         */
+        private Object progress_lock = new Object();
+
+        /**
+         * Moves the action cursor under {@link progress_lock}: the next
+         * {@link report_progress} carries this task, subject and index.
+         */
+        private void begin_action(TransactionTask task, string subject, uint index) {
+            lock(progress_lock) {
+                current_task_type = task;
+                current_subject = subject;
+                current_task = index;
+            }
+        }
+
         private void do_for(Enumerable<CachedPackage> packages, PackageDelegate func, TransactionTask task_type) throws TransactionError {
             foreach (var package in packages) {
                 try {
-                    current_subject = package.package_name;
-                    current_task_type = task_type;
+                    begin_action(task_type, package.package_name, current_task);
                     report_progress(task_type, 0.0f);
                     func(package);
-                    current_task++;
+                    lock(progress_lock) {
+                        current_task++;
+                    }
                 }
                 catch (TransactionError e) {
                     throw e;
@@ -510,7 +856,23 @@ namespace Usm {
         }
 
         private void report_progress(TransactionTask task, float progress) {
-            progress_updated(task, current_subject, current_task, task_count, progress);
+            lock(progress_lock) {
+                progress_updated(task, current_subject, current_task, task_count, progress);
+            }
+        }
+
+        /**
+         * Reports one progress fraction for an action whose identity was
+         * captured when it started — used by the ninja progress reader
+         * thread, whose emissions may lag behind the main thread
+         * advancing the cursor; carrying the captured identity (instead
+         * of reading the live fields) keeps a late emission from
+         * masquerading as whatever action happens to be current.
+         */
+        private void report_action_progress(TransactionTask task, string subject, uint index, float progress) {
+            lock(progress_lock) {
+                progress_updated(task, subject, index, task_count, progress);
+            }
         }
 
         public void unpack_package(CachedPackage package) throws Error {
@@ -576,9 +938,16 @@ namespace Usm {
             Environment.set_current_dir(source_dir);
             var manifest = new Usm.Manifest.from_file("MANIFEST.usm");
 
-            // Build package
+            // The ninja progress reader thread outlives the build process
+            // by a moment; snapshot the action's identity so its late
+            // emissions carry what they meant when written, never
+            // whatever action the cursor has since moved to (see
+            // {@link report_action_progress})
+            TransactionTask action_task = current_task_type;
+            string action_subject = current_subject;
+            uint action_index = current_task;
             var build_proc = manifest.run_build(build_dir, paths, SubprocessFlags.STDOUT_SILENCE, (progress) => {
-                report_progress(current_task_type, progress);
+                report_action_progress(action_task, action_subject, action_index, progress);
             });
             build_proc.wait_check();
         }
@@ -764,6 +1133,8 @@ namespace Usm {
         REMOVING,
         INSTALLING,
         CLEANING_UP,
+        /** Installing one chosen system (native) package; runs ahead of the first USM lot. */
+        INSTALLING_SYSTEM,
         /** The rebuildDependants pipeline for an already-installed package (see {@link RebuildEntry}). */
         REBUILDING;
 
@@ -781,6 +1152,8 @@ namespace Usm {
                 return "removing";
             case INSTALLING:
                 return "installing";
+            case INSTALLING_SYSTEM:
+                return "installing system package";
             case CLEANING_UP:
                 return "cleaning up";
             case REBUILDING:

+ 28 - 1
src/lib/TransactionSummary.vala

@@ -16,6 +16,22 @@ namespace Usm {
 
         /** Packages the transaction installs; a {@link to_remove} entry of the same name renders as an update. */
         public Vector<CachedPackage> to_install { get; set; default = new Vector<CachedPackage>(); }
+        /**
+         * System (native) packages the transaction installs itself
+         * before the first USM lot (see
+         * {@link Transaction.system_packages}); rendered first, in the
+         * order the native transaction installs them.
+         */
+        public Vector<Usm.SystemPackageCandidate> system_installs { get; set; default = new Vector<Usm.SystemPackageCandidate>(); }
+        /**
+         * The material system install set's extra packages — every
+         * native dependency of a dependency the manager pulls in for
+         * {@link system_installs} (from
+         * {@link SystemPackageManager.plan_install}, minus the chosen
+         * names); rendered dim after the chosen packages so the plan
+         * explains the whole native transaction.
+         */
+        public Vector<string> system_dependencies { get; set; default = new Vector<string>(); }
         /** Installed packages the transaction removes; entries whose name is reinstalled render as updates. */
         public Vector<CachedPackage> to_remove { get; set; default = new Vector<CachedPackage>(); }
         /**
@@ -48,7 +64,8 @@ namespace Usm {
 
         /** Whether the summary describes no action at all. */
         public bool is_empty() {
-            return to_install.length == 0 && to_remove.length == 0 && orphaned_removals.length == 0
+            return to_install.length == 0 && system_installs.length == 0 && system_dependencies.length == 0
+                && to_remove.length == 0 && orphaned_removals.length == 0
                 && to_rebuild.length == 0 && to_downgrade.length == 0;
         }
 
@@ -78,6 +95,16 @@ namespace Usm {
                 removed_versions.set(summary_name(package), summary_version(package));
             }
 
+            foreach(var candidate in system_installs) {
+                builder.append_printf("  %s%-9s%s  %s %s(system package)%s\n",
+                    c_green, "install", c_reset, candidate.name, c_dim, c_reset);
+            }
+
+            foreach(var name in system_dependencies) {
+                builder.append_printf("  %s%-9s%s  %s %s(system dependency)%s\n",
+                    c_green, "install", c_reset, name, c_dim, c_reset);
+            }
+
             var updated_names = new HashSet<string>();
             foreach(var package in to_install.sort((a, b) => summary_name(a).collate(summary_name(b)))) {
                 var name = summary_name(package);

+ 19 - 0
src/lib/Version.vala

@@ -61,6 +61,25 @@ namespace Usm {
             return release == other.release;
         }
 
+        /**
+         * Prefix-tolerant numeric equality ignoring `+release` suffixes:
+         * 0.31 matches 0.31.0, and 0.31+2 matches 0.31. Resource-level
+         * versions (soname tails, .pc `Version:` fields) never carry
+         * packaging releases and upstreams disagree about component
+         * counts, so this is the equality resource constraints use.
+         */
+        public bool matches_numeric(Version other) {
+            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(mine[i] != theirs[i]) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
         public bool greater_than(Version other) {
             var parts = int.min(other.version_nums.length, version_nums.length);
             for(int i = 0; i < parts; i++) {

+ 165 - 0
src/lib/VersionConstraint.vala

@@ -0,0 +1,165 @@
+using Invercargill;
+
+namespace Usm {
+
+    /**
+     * The comparison a version constraint applies to a candidate version.
+     */
+    public enum VersionOperator {
+        /** The candidate's version equals the constraint's (numeric prefix equality). */
+        EQUALS,
+        /** The candidate's version is at least the constraint's. */
+        AT_LEAST,
+        /** The candidate's version is at most the constraint's. */
+        AT_MOST,
+        /**
+         * PEP 440 "compatible release": at least the constraint's version
+         * AND sharing every numeric component of it except the last, so
+         * `~=0.31.0` accepts 0.31.1 but not 0.32.0, while `~=0.31`
+         * accepts 0.33.0 but not 1.0.0.
+         */
+        COMPATIBLE;
+
+        public string to_string() {
+            switch (this) {
+                case VersionOperator.EQUALS:
+                    return "==";
+                case VersionOperator.AT_LEAST:
+                    return ">=";
+                case VersionOperator.AT_MOST:
+                    return "<=";
+                case VersionOperator.COMPATIBLE:
+                    return "~=";
+                default:
+                    assert_not_reached();
+            }
+        }
+
+        public static VersionOperator from_string(string str) throws ManifestError {
+            switch (str) {
+                case "==":
+                    return VersionOperator.EQUALS;
+                case ">=":
+                    return VersionOperator.AT_LEAST;
+                case "<=":
+                    return VersionOperator.AT_MOST;
+                case "~=":
+                    return VersionOperator.COMPATIBLE;
+                default:
+                    throw new ManifestError.INVALID_VERSION(@"Unknown version operator \"$str\" (expected ==, >=, <= or ~=).");
+            }
+        }
+    }
+
+    /**
+     * A version constraint attached to a dependency resource ref, e.g.
+     * the `>=0.30.0` of `lib:libcmark.so>=0.30.0`.
+     *
+     * Comparison semantics are deliberately prefix-tolerant on numeric
+     * components (0.31 == 0.31.0) and ignore `+release` suffixes:
+     * resource-level versions come from soname tails and .pc `Version:`
+     * fields, which never carry packaging releases, while upstream
+     * conventions disagree about how many components to write.
+     */
+    public class VersionConstraint : Object {
+
+        /** The operator applied to {@link version}. */
+        public VersionOperator operation { get; set; }
+        /** The constraint's own version. */
+        public Version version { get; set; }
+
+        public VersionConstraint(VersionOperator operation, Version version) {
+            this.operation = operation;
+            this.version = version;
+        }
+
+        public string to_string() {
+            return @"$(operation.to_string())$(version.to_string())";
+        }
+
+        /** Whether {@link candidate} satisfies this constraint. */
+        public bool satisfied_by(Version candidate) {
+            switch (operation) {
+                case VersionOperator.EQUALS:
+                    return candidate.matches_numeric(version);
+                case VersionOperator.AT_LEAST:
+                    return !candidate.less_than(version);
+                case VersionOperator.AT_MOST:
+                    return !candidate.greater_than(version);
+                case VersionOperator.COMPATIBLE:
+                    if(candidate.less_than(version)) {
+                        return false;
+                    }
+                    // Pin every component of the constraint except its last
+                    var pinned = version.version_nums.length - 1;
+                    for(int index = 0; index < pinned; index++) {
+                        var component = index < candidate.version_nums.length
+                            ? candidate.version_nums[index] : 0;
+                        if(component != version.version_nums[index]) {
+                            return false;
+                        }
+                    }
+                    return true;
+                default:
+                    assert_not_reached();
+            }
+        }
+
+        /**
+         * Locates and parses the FIRST version operator in {@link text}.
+         *
+         * Returns the constraint and sets {@link resource_base} to
+         * everything before the operator (the resource's base name);
+         * null with {@link resource_base} == {@link text} when no
+         * operator is present. A trailing/leading operator, an
+         * unparseable version, a `~=`
+         * with fewer than two numeric components or a second operator
+         * in the version all throw {@link ManifestError.INVALID_VERSION}.
+         */
+        public static VersionConstraint? find_in(string text, out string resource_base) throws ManifestError {
+            long found_at = -1;
+            string found_token = null;
+            foreach(var token in new string[] { "==", ">=", "<=", "~=" }) {
+                var at = text.index_of(token);
+                if(at >= 0 && (found_at < 0 || at < found_at)) {
+                    found_at = at;
+                    found_token = token;
+                }
+            }
+            if(found_at < 0) {
+                resource_base = text;
+                return null;
+            }
+
+            resource_base = text.substring(0, found_at);
+            var version_text = text.substring(found_at + found_token.length);
+            if(resource_base.length == 0 || version_text.length == 0) {
+                throw new ManifestError.INVALID_VERSION(
+                    @"Version operator \"$found_token\" must sit between a resource name and a version in \"$text\"."
+                );
+            }
+
+            Version parsed_version = null;
+            try {
+                parsed_version = new Version.from_string(version_text);
+            }
+            catch(Error e) {
+                throw new ManifestError.INVALID_VERSION(
+                    @"Could not parse the version of the constraint in \"$text\": $(e.message)"
+                );
+            }
+
+            var operation = found_token == "=="
+                ? VersionOperator.EQUALS
+                : found_token == ">=" ? VersionOperator.AT_LEAST
+                : found_token == "<=" ? VersionOperator.AT_MOST
+                : VersionOperator.COMPATIBLE;
+            if(operation == VersionOperator.COMPATIBLE && parsed_version.version_nums.length < 2) {
+                throw new ManifestError.INVALID_VERSION(
+                    @"The ~= operator needs at least two version components (e.g. ~=0.31), \"$text\" has fewer."
+                );
+            }
+            return new VersionConstraint(operation, parsed_version);
+        }
+    }
+}

+ 2 - 0
src/lib/meson.build

@@ -10,7 +10,9 @@ sources += files('Licence.vala')
 sources += files('Paths.vala')
 sources += files('ResourceRef.vala')
 sources += files('ResourceFinder.vala')
+sources += files('PcFile.vala')
 sources += files('Version.vala')
+sources += files('VersionConstraint.vala')
 sources += files('Resolver.vala')
 sources += files('Util.vala')
 sources += files('Transaction.vala')

+ 516 - 5
src/tests/TestMain.vala

@@ -408,6 +408,191 @@ namespace Usm.Tests {
             "flat phase roundtrips as flat");
     }
 
+    // ---- Version constraints -----------------------------------------------------
+
+    Usm.Version version(string text) throws Error {
+        return new Usm.Version.from_string(text);
+    }
+
+    void test_version_constraint_semantics() throws Error {
+        string parsed_base = null;
+        var constraint = Usm.VersionConstraint.find_in("libcmark.so>=0.30.0", out parsed_base);
+        check(constraint != null && parsed_base == "libcmark.so", "operator splits base from constraint");
+        check(constraint.to_string() == ">=0.30.0", "constraint round-trips");
+
+        var at_least = ((!)constraint);
+        check(at_least.satisfied_by(version("0.30.0")), ">= accepts the boundary");
+        check(at_least.satisfied_by(version("0.31.1")), ">= accepts newer");
+        check(at_least.satisfied_by(version("1.2.0")), ">= accepts major bumps");
+        check(!at_least.satisfied_by(version("0.29.9")), ">= rejects older");
+
+        string compatible_base = null;
+        var compatible = (!)Usm.VersionConstraint.find_in("libcmark.so~=0.31.0", out compatible_base);
+        check(compatible.satisfied_by(version("0.31.0")), "~= accepts the boundary");
+        check(compatible.satisfied_by(version("0.31.1")), "~= accepts patch bumps within the family");
+        check(!compatible.satisfied_by(version("0.32.0")), "~= rejects minor bumps");
+        check(!compatible.satisfied_by(version("1.0.0")), "~= rejects major bumps");
+
+        string wide_base = null;
+        var wide = (!)Usm.VersionConstraint.find_in("libcmark.so~=0.31", out wide_base);
+        check(wide.satisfied_by(version("0.33.0")), "two-component ~= pins only the major");
+        check(!wide.satisfied_by(version("1.0.0")), "two-component ~= still rejects major bumps");
+
+        string equals_base = null;
+        var equals = (!)Usm.VersionConstraint.find_in("libcmark.so==0.31", out equals_base);
+        check(equals.satisfied_by(version("0.31")), "== accepts the exact version");
+        check(equals.satisfied_by(version("0.31.0")), "== tolerates omitted trailing components");
+        check(equals.satisfied_by(version("0.31.5")), "== treats missing trailing components as unspecified");
+        check(!equals.satisfied_by(version("0.32.0")), "== rejects a differing minor");
+
+        string at_most_base = null;
+        var at_most = (!)Usm.VersionConstraint.find_in("libcmark.so<=0.31.1", out at_most_base);
+        check(at_most.satisfied_by(version("0.31.0")), "<= accepts older");
+        check(at_most.satisfied_by(version("0.31.1")), "<= accepts the boundary");
+        check(!at_most.satisfied_by(version("0.32.0")), "<= rejects newer");
+
+        check(Usm.VersionConstraint.find_in("libcmark.so", out parsed_base) == null && parsed_base == "libcmark.so",
+            "operator-free text carries no constraint");
+
+        var rejected = new Vector<string>();
+        foreach(var malformed in new string[] { "libcmark.so>=", ">=0.31.0", "libcmark.so>=0.31rc1", "libcmark.so~=0", "libcmark.so>=1<=2" }) {
+            try {
+                Usm.VersionConstraint.find_in(malformed, out parsed_base);
+                rejected.add(malformed);
+            }
+            catch(Error e) {
+            }
+        }
+        check(rejected.count() == 0, @"malformed constraints are rejected ($(rejected.to_string(s => s, ", ")))");
+    }
+
+    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");
+        check(constrained.resource == "libcmark.so.0>=0.30.0", "full resource keeps the constraint");
+        check(constrained.to_string() == "lib:libcmark.so.0>=0.30.0", "ref round-trips");
+
+        var plain = new Usm.ResourceRef("lib:libcmark.so.0");
+        check(plain.base_name == "libcmark.so.0" && plain.constraint == null, "plain ref carries no constraint");
+
+        var malformed = new Vector<string>();
+        foreach(var broken in new string[] { "lib", "lib:", "", ":" }) {
+            try {
+                new Usm.ResourceRef(broken);
+                malformed.add(broken);
+            }
+            catch(Error e) {
+            }
+        }
+        check(malformed.count() == 0, @"structurally invalid refs are rejected ($(malformed.to_string(s => s, ", ")))");
+
+        var tail = Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so.0.31.1");
+        check(tail != null && ((!)tail).to_string() == "0.31.1", "soname tail parses as a version");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so") == null, "unversioned soname has no tail");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so.0.31rc1") == null, "non-numeric tail is no version");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so.0.31.1.2") != null, "four-component tails parse");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libothercmark.so.0.31.1") == null, "tails must extend the exact base");
+    }
+
+    void test_depends_operator_type_rules() throws Error {
+        var accepted = manifest_from_json(PHASE_MANIFEST.printf(
+            "{ \"runtime\": [\"lib:libcmark.so>=0.30.0\"], \"build\": [\"pc:libcmark.pc==0.31.1\"], \"manage\": [] }"));
+        check(accepted.dependencies.runtime.ordered_required().first().to_string() == "lib:libcmark.so>=0.30.0",
+            "lib dependencies accept operators");
+        check(accepted.dependencies.build.ordered_required().first().to_string() == "pc:libcmark.pc==0.31.1",
+            "pc dependencies accept operators");
+
+        var violations = new Vector<string>();
+        foreach(var rule in new string[] { "bin:python3>=3.8", "tag:family.feature>=1.0", "inc:glib-2.0>=2.0", "vapi:gtk-4.0.vapi~=4.0" }) {
+            try {
+                manifest_from_json(PHASE_MANIFEST.printf(@"{ \"runtime\": [\"$rule\"], \"build\": [], \"manage\": [] }"));
+                violations.add(rule);
+            }
+            catch(Error e) {
+            }
+        }
+        check(violations.count() == 0, @"operators on non-lib/pc dependency types are rejected ($(violations.to_string(s => s, ", ")))");
+    }
+
+    void test_provides_declaration_rules() throws Error {
+        var declared = manifest_from_json("""
+        {
+          "name": "cmark-lib",
+          "version": "0.31.1",
+          "summary": "cmark",
+          "licences": [],
+          "flags": [],
+          "provides": { "pc:cmark.pc==0.31.1": "as-expected", "lib:libcmark.so.0.31.1": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """);
+        check(declared.provides.count() == 2, "pc == declarations and concrete lib provides parse");
+
+        var violations = new Vector<string>();
+        foreach(var rule in new string[] {
+            "\"lib:libcmark.so.0.31.1==0.31.1\"",
+            "\"pc:cmark.pc>=0.31.1\"",
+            "\"bin:cmark==1.0\""
+        }) {
+            try {
+                manifest_from_json(@"{ \"name\": \"x\", \"version\": \"1.0.0\", \"summary\": \"x\", \"licences\": [], \"flags\": [], \"provides\": { $rule: \"as-expected\" }, \"depends\": { \"runtime\": [], \"build\": [], \"manage\": [] }, \"execs\": {} }");
+                violations.add(rule);
+            }
+            catch(Error e) {
+            }
+        }
+        check(violations.count() == 0, @"only pc provides may carry == declarations ($(violations.to_string(s => s, ", ")))");
+    }
+
+    void test_satisfied_by_constraints() throws Error {
+        var floor = new Usm.ResourceRef("lib:libcmark.so>=0.30.0");
+        check(floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.31.1")), "floor matches a newer soname tail");
+        check(floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.30.0")), "floor matches the boundary tail");
+        check(!floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.29.9")), "floor rejects an older tail");
+        check(!floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so")), "floor rejects an unversioned name");
+        check(!floor.satisfied_by(new Usm.ResourceRef("lib:libother.so.0.31.1")), "floor requires the same base name");
+        check(floor.satisfied_by(new Usm.ResourceRef("canonlib:libcmark.so.0.31.1")), "floor accepts a canonlib provider");
+        check(!floor.satisfied_by(new Usm.ResourceRef("pc:libcmark.pc")), "floor does not cross resource types");
+
+        var family = new Usm.ResourceRef("lib:libcmark.so~=0.31.0");
+        check(family.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.31.1")), "family matches within the minor");
+        check(!family.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.32.0")), "family rejects the next minor");
+
+        var plain = new Usm.ResourceRef("lib:libcmark.so.0.31.1");
+        check(plain.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.31.1")), "plain dep matches the exact provide");
+        check(!plain.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.30.0")), "plain dep still requires exactness");
+        check(plain.satisfied_by(new Usm.ResourceRef("canonlib:libcmark.so.0.31.1")), "plain dep accepts canonlib providers");
+
+        var plain_pc = new Usm.ResourceRef("pc:cmark.pc");
+        check(plain_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc==0.31.1")), "plain pc dep matches a declared provide");
+        var declared_pc = new Usm.ResourceRef("pc:cmark.pc>=0.30.0");
+        check(declared_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc==0.31.1")), "pc floor matches a satisfying declaration");
+        check(!declared_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc==0.29.0")), "pc floor rejects a low declaration");
+        check(!declared_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc")), "pc floor cannot be proven by an undeclared provide");
+    }
+
+    void test_manifest_satisfies_pc_fallback() throws Error {
+        var provider = manifest_from_json("""
+        {
+          "name": "cmark-lib",
+          "version": "0.31.1",
+          "summary": "cmark",
+          "licences": [],
+          "flags": [],
+          "provides": { "pc:cmark.pc": "as-expected", "pc:other.pc==0.5.0": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """);
+        check(provider.satisfies(new Usm.ResourceRef("pc:cmark.pc>=0.30.0")), "undeclared pc provide falls back to the package version");
+        check(provider.satisfies(new Usm.ResourceRef("pc:cmark.pc==0.31.1")), "fallback version is the package version");
+        check(!provider.satisfies(new Usm.ResourceRef("pc:cmark.pc>=0.32.0")), "fallback respects the floor");
+        check(provider.satisfies(new Usm.ResourceRef("pc:other.pc>=0.5.0")), "explicit declaration satisfies a floor");
+        check(!provider.satisfies(new Usm.ResourceRef("pc:other.pc>=0.6.0")), "explicit declaration wins over the package version");
+        check(!provider.satisfies(new Usm.ResourceRef("pc:absent.pc>=0.1")), "unrelated names never match");
+    }
+
     // ---- Topological ordering ----------------------------------------------------
 
     /** A package manifest named pkg-<name> providing bin:pkg-<name> with the given runtime refs. */
@@ -493,16 +678,43 @@ for package in known:
 print(json.dumps({"not-found": [r for r in refs if r not in provided], "packages": known}))
 PY
     ;;
+  plan)
+    # Every material package: dependencies first, chosen names after
+    printf '{"packages":['
+    first=1
+    for name in "$@"; do
+      if [ $first -eq 0 ]; then printf ','; fi
+      first=0
+      printf '"dep-of-%s"' "$name"
+    done
+    for name in "$@"; do
+      printf ',"%s"' "$name"
+    done
+    printf ']}\n'
+    ;;
   install)
     total=$#
     echo "{\"type\":\"begin\",\"total\":$total}"
-    n=0
+    # Like dnf: a download phase streaming package events in its own
+    # order, then an rpm transaction completing in a DIFFERENT order
+    # than the plan's — the display must not depend on either matching
     for name in "$@"; do
-      n=$((n+1))
-      echo "$name" >> "$USM_SPM_INSTALL_LOG"
-      echo "{\"type\":\"package\",\"name\":\"$name\",\"current\":$n,\"total\":$total,\"progress\":0.5}"
+      echo "{\"type\":\"package\",\"name\":\"$name\",\"current\":$RANDOM,\"total\":999,\"progress\":0.4}"
+    done
+    for name in $(printf '%s\n' "$@" | tac); do
+      echo "{\"type\":\"package\",\"name\":\"dep-of-$name\",\"current\":$RANDOM,\"total\":999,\"progress\":0.5}"
+    done
+    n=0
+    for name in $(printf '%s\n' "$@" | tac); do
+      echo "{\"type\":\"package\",\"name\":\"dep-of-$name\",\"current\":$n,\"total\":999,\"progress\":0.9}"
+      echo "{\"type\":\"package-complete\",\"name\":\"dep-of-$name\"}"
+      echo "{\"type\":\"package\",\"name\":\"$name\",\"current\":$n,\"total\":999,\"progress\":0.95}"
       echo "{\"type\":\"package-complete\",\"name\":\"$name\"}"
+      echo "$name" >> "$USM_SPM_INSTALL_LOG"
+      n=$((n+1))
     done
+    # A duplicate completion, as dnf emits for repeated members
+    echo "{\"type\":\"package-complete\",\"name\":\"$1\"}"
     echo "{\"type\":\"complete\",\"status\":\"ok\",\"installed\":$total}"
     ;;
   *)
@@ -650,6 +862,144 @@ esac
         Usm.Util.delete_tree(scratch);
     }
 
+    /** Builds a .usmc archive from a complete manifest JSON string. */
+    string make_archive_from_manifest(string scratch, string manifest_json) throws Error {
+        var source = Path.build_filename(scratch, @"src-$(Uuid.string_random())");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), manifest_json);
+        var archive = Path.build_filename(scratch, @"pkg-$(Uuid.string_random()).usmc");
+        Usm.Util.archive(source, archive);
+        return archive;
+    }
+
+    void test_resolver_constrained_lib_dep() throws Error {
+        var scratch = make_scratch();
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        resolver.supply_package(make_archive_from_manifest(scratch, """
+        {
+          "name": "usmt-cmark",
+          "version": "0.31.1",
+          "summary": "constrained provider",
+          "licences": [],
+          "flags": [],
+          "provides": { "lib:libusmt-cmark.so.0.31.1": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """));
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(new Usm.AbstractPackage.from_manifest(manifest_from_json(
+            APP_MANIFEST.printf("cmark-app", "cmark-app", depends_with("runtime", "[\"lib:libusmt-cmark.so>=0.30.0\"]")))));
+        var result = resolver.resolve(roots, null);
+
+        check(result.packages.count() == 2, "a constrained lib dep pulls its USM provider");
+        check(result.install_order.to_string(p => p.manifest.name, ",") == "usmt-cmark,cmark-app",
+            "the provider installs before the dependent");
+        check(result.system_packages.length == 0, "no system packages without an SPM");
+
+        // The same provider does not satisfy a family constraint it breaks
+        var roots_mismatch = new Vector<Usm.AbstractPackage>();
+        roots_mismatch.add(new Usm.AbstractPackage.from_manifest(manifest_from_json(
+            APP_MANIFEST.printf("cmark-app2", "cmark-app2", depends_with("runtime", "[\"lib:libusmt-cmark.so~=0.30.0\"]")))));
+        try {
+            resolver.resolve(roots_mismatch, null);
+            check(false, "an unsatisfiable family constraint fails resolution");
+        }
+        catch(Error e) {
+            check(e is Usm.ResolverError, "an unsatisfiable family constraint fails resolution");
+        }
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_resolver_skips_spm_for_constrained_refs() throws Error {
+        var scratch = make_scratch();
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        resolver.supply_package(make_archive_from_manifest(scratch, """
+        {
+          "name": "usmt-skip",
+          "version": "1.0.0",
+          "summary": "skip provider",
+          "licences": [],
+          "flags": [],
+          "provides": { "lib:libusmt-skip.so.2.1.0": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """));
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(new Usm.AbstractPackage.from_manifest(manifest_from_json(
+            APP_MANIFEST.printf("skip-app", "skip-app",
+                depends_with("runtime", "[\"lib:libusmt-skip.so>=2.0.0\", \"bin:usmt-plain\"]")))));
+
+        var spm = stub_manager(scratch, spm_candidate("spm-owns-plain", "bin:usmt-plain", 2, 1));
+        var result = resolver.resolve(roots, spm);
+
+        check(result.packages.count() == 2, "the constrained dep resolves from the USM provider");
+        check(result.system_packages.length == 1 && result.system_packages[0].name == "spm-owns-plain",
+            "the plain dep still resolves from the SPM");
+
+        var log = "";
+        FileUtils.get_contents(Path.build_filename(scratch, "query.log"), out log);
+        check(log.contains("usmt-skip.so>=") == false, "the SPM is never queried for the constrained ref");
+        check(log.contains("bin:usmt-plain"), "the SPM is queried for plain refs in the same batch");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_pc_finder_version_constraint() throws Error {
+        var scratch = make_scratch();
+        var pcdir = Path.build_filename(scratch, "pkgconfig");
+        DirUtils.create(pcdir, 0755);
+        FileUtils.set_contents(Path.build_filename(pcdir, "usmt.pc"),
+            "prefix=/usr\n\nName: usmt\nDescription: test\nVersion: 1.2.3\nCflags: -I/usr/include\n");
+
+        var previous = Environment.get_variable("PKG_CONFIG_PATH");
+        Environment.set_variable("PKG_CONFIG_PATH", pcdir, true);
+        var finder = new Usm.ResourceFinder();
+        try {
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc")), "plain pc ref found by existence");
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc>=1.0.0")), "floor below the declared version satisfies");
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc==1.2.3")), "== the declared version satisfies");
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc~=1.2.0")), "~= within the declared minor satisfies");
+            check(!finder.has_resource(new Usm.ResourceRef("pc:usmt.pc>=2.0")), "floor above the declared version fails");
+            check(!finder.has_resource(new Usm.ResourceRef("pc:usmt.pc~=1.3.0")), "~= outside the declared minor fails");
+            check(!finder.has_resource(new Usm.ResourceRef("pc:usmt-absent.pc>=1.0")), "absent pc files fail");
+        }
+        finally {
+            if(previous != null) {
+                Environment.set_variable("PKG_CONFIG_PATH", previous, true);
+            }
+            else {
+                Environment.unset_variable("PKG_CONFIG_PATH");
+            }
+        }
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_pc_file_read_version() throws Error {
+        var scratch = make_scratch();
+        var path = Path.build_filename(scratch, "a.pc");
+
+        FileUtils.set_contents(path, "# comment\nName: a\n Version:  2.1.0  \nCflags: -I${prefix}/include\n");
+        var parsed = Usm.PcFile.read_version(path);
+        check(parsed != null && ((!)parsed).to_string() == "2.1.0", "version keyword parsed with surrounding whitespace");
+
+        FileUtils.set_contents(path, "prefix=/usr\nName: a\nVersion: ${pcfiledir}/ver\n");
+        check(Usm.PcFile.read_version(path) == null, "variable-expanding version yields null");
+
+        FileUtils.set_contents(path, "Name: a\nDescription: no version here\n");
+        check(Usm.PcFile.read_version(path) == null, "missing version keyword yields null");
+
+        FileUtils.set_contents(path, "Name: a\nVersion: 1.0rc1\n");
+        check(Usm.PcFile.read_version(path) == null, "non-numeric version yields null");
+
+        check(Usm.PcFile.read_version(Path.build_filename(scratch, "absent.pc")) == null, "unreadable file yields null");
+        Usm.Util.delete_tree(scratch);
+    }
+
     void test_group_selection_cheaper_wins() throws Error {
         var scratch = make_scratch();
         var spm = stub_manager(scratch,
@@ -763,7 +1113,7 @@ esac
 
         string install_log;
         FileUtils.get_contents(Path.build_filename(scratch, "install.log"), out install_log);
-        check(install_log.split("\n").length == 3 && install_log.has_prefix("one\ntwo"), "install helper received the package names in order");
+        check(install_log.split("\n").length == 3 && install_log.contains("one") && install_log.contains("two"), "install helper received both package names");
 
         Usm.Util.delete_tree(scratch);
     }
@@ -892,6 +1242,154 @@ esac
         Usm.Util.delete_tree(scratch);
     }
 
+    void test_transaction_preinstalled_resources_satisfy_build_deps() throws Error {
+        var scratch = make_scratch();
+        // A package whose build phase needs something nothing on this
+        // machine and nothing in the transaction provides — the exact
+        // shape of a system-package-provided toolchain dependency in a
+        // freshly provisioned container
+        make_build_package_archive(scratch, "s", "pc:lib-lot-s.pc", { "pc:no-such-file-anywhere-7f3.pc" });
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(cache_package(scratch, "s"));
+
+        var blocked = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+        try {
+            blocked.strategise();
+            check(false, "a build ref nothing provides still blocks the transaction");
+        }
+        catch(Usm.TransactionError e) {
+            check(e.message.contains("pkg-s") && e.message.contains("pc:no-such-file-anywhere-7f3.pc"),
+                "the failure names the package and the unmet ref");
+        }
+
+        var preinstalled = new HashSet<Usm.ResourceRef>();
+        preinstalled.add(new Usm.ResourceRef("pc:no-such-file-anywhere-7f3.pc"));
+        var provisioned = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            preinstalled_resources = preinstalled,
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+        provisioned.strategise();
+
+        check(provisioned.install_lots.length == 1, "preinstalled (system-provided) resources satisfy build-phase refs");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_deadlock_message_details_causes() throws Error {
+        var scratch = make_scratch();
+        // Two packages whose build phases need each other's provides —
+        // unschedulable under the earlier-lot rule for build-phase refs
+        make_build_package_archive(scratch, "x", "pc:lib-cyc-x.pc", { "pc:lib-cyc-y.pc" });
+        make_build_package_archive(scratch, "y", "pc:lib-cyc-y.pc", { "pc:lib-cyc-x.pc" });
+        make_build_package_archive(scratch, "z", "pc:lib-cyc-z.pc", { "pc:also-nowhere-at-all-2c9.pc" });
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        foreach(var name in new string[] { "x", "y", "z" }) {
+            to_install.add(cache_package(scratch, name));
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+        try {
+            transaction.strategise();
+            check(false, "the mutual build dependency cannot be scheduled");
+        }
+        catch(Usm.TransactionError e) {
+            var message = e.message;
+            check(message.contains("3 package(s) could not be scheduled"), "the headline counts the stuck packages");
+            check(message.contains("pkg-x") && message.contains("pkg-y") && message.contains("pkg-z"), "every stuck package is named");
+            check(message.contains("pc:lib-cyc-y.pc (build phase) is part of a dependency cycle") && message.contains("pkg-y"),
+                "the cyclic ref names the ref, its phase and its stuck provider");
+            check(message.contains("pc:also-nowhere-at-all-2c9.pc (build phase) is unmet"), "the unmet ref names the ref and its phase");
+        }
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_installs_system_packages() throws Error {
+        var scratch = make_scratch();
+        var spm = stub_manager(scratch, "");
+
+        var candidates = new Vector<Usm.SystemPackageCandidate>();
+        foreach(var name in new string[] { "one", "two" }) {
+            var candidate = new Usm.SystemPackageCandidate();
+            candidate.name = name;
+            candidate.resources = new Vector<Usm.ResourceRef>();
+            candidates.add(candidate);
+        }
+
+        // The material set the stub's plan mode reports: dependencies
+        // first, chosen names after
+        var chosen_names = new Vector<string>();
+        chosen_names.add("one");
+        chosen_names.add("two");
+        var plan = spm.plan_install(chosen_names);
+        check(plan.length == 4 && plan[0] == "dep-of-one" && plan[3] == "two", "plan_install returns the material set in helper order");
+
+        var paths = new Usm.Paths.defaults();
+        paths.destination = scratch;
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(),
+            system_package_manager = spm,
+            system_packages = candidates,
+            system_install_plan = plan,
+            to_install = new HashSet<Usm.CachedPackage>(),
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+
+        var events = new Vector<string>();
+        uint last_total = 0;
+        transaction.progress_updated.connect((task, subject, index, total, progress) => {
+            var kind = task == TransactionTask.INSTALLING_SYSTEM ? "sys" : task == TransactionTask.STRATEGISING ? "plan" : "other";
+            events.add(@"$kind|$(subject ?? "")|$index|$total");
+            last_total = total;
+        });
+
+        transaction.strategise();
+        check(last_total == 5, "the task count covers every material system package");
+
+        transaction.run();
+
+        // Completions claim slots IN ARRIVAL ORDER: the stub completes
+        // the plan set in a different order than the plan lists it, and
+        // its download events stream in yet another order with nonsense
+        // positions — the actions must follow the real install sequence
+        check(events.any(e => e.has_prefix("sys|dep-of-two|1|5")), "the first completion claims the first slot");
+        check(events.any(e => e.has_prefix("sys|two|2|5")), "the second completion claims the second slot");
+        check(events.any(e => e.has_prefix("sys|dep-of-one|3|5")), "the third completion claims the third slot");
+        check(events.any(e => e.has_prefix("sys|one|4|5")), "the fourth completion claims the fourth slot");
+        check(!events.any(e => e.has_prefix("other|")), "no USM action runs when the transaction holds only system packages");
+
+        string install_log;
+        FileUtils.get_contents(Path.build_filename(scratch, "install.log"), out install_log);
+        check(install_log.split("\n").length == 3 && install_log.contains("one") && install_log.contains("two"), "the native helper received both chosen names, dependencies excluded");
+
+        var summary = new Usm.TransactionSummary() {
+            system_installs = candidates,
+            system_dependencies = plan.where(n => n != "one" && n != "two").to_vector()
+        };
+        var plan_preview = summary.describe(false);
+        check(plan_preview.contains("install    one (system package)") && plan_preview.contains("install    two (system package)"), "the plan preview lists the chosen system package installs");
+        check(plan_preview.contains("install    dep-of-one (system dependency)") && plan_preview.contains("install    dep-of-two (system dependency)"), "the plan preview lists the material dependencies");
+        check(!summary.is_empty(), "system installs alone are a non-empty plan");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
     // ---- rebuildDependants: lookup, planning, dedup ------------------------------
 
     /** Manifest for a package named <name> at <version> providing one key with the given flags and depends. */
@@ -1743,8 +2241,18 @@ echo "Installing libmeson-pkg.so to /usr/lib"
         }
 
         try {
+            test_version_constraint_semantics();
+            test_resource_ref_operator_parsing();
+            test_depends_operator_type_rules();
+            test_provides_declaration_rules();
+            test_satisfied_by_constraints();
+            test_manifest_satisfies_pc_fallback();
             test_resolve_diamond_usm_only();
             test_resolve_spm_before_usm();
+            test_resolver_constrained_lib_dep();
+            test_resolver_skips_spm_for_constrained_refs();
+            test_pc_finder_version_constraint();
+            test_pc_file_read_version();
             test_group_selection_cheaper_wins();
             test_group_selection_tie_manifest_order();
             test_group_selection_none_viable_itemised();
@@ -1753,6 +2261,9 @@ echo "Installing libmeson-pkg.so to /usr/lib"
             test_transaction_accepts_orders();
             test_transaction_lots_split_at_build_boundaries();
             test_transaction_manage_deps_admit_same_lot();
+            test_transaction_preinstalled_resources_satisfy_build_deps();
+            test_transaction_deadlock_message_details_causes();
+            test_transaction_installs_system_packages();
         }
         catch(Error e) {
             failures++;

Some files were not shown because too many files changed in this diff