The packages fact namespaces installed software by source, each a list of records
Facts adds a packages fact reporting installed software — a Facts-native extension (Ruby Facter ships no installed-packages fact, so there is no upstream shape to match; ADR-0011 canonical spelling governs the names). It is namespaced by source, where a source is an authoritative installed-software inventory on the host — usually an installation database, or a structured filesystem inventory where the OS keeps no database (macOS .app bundles). The source key is the database or inventory, never an apt-style frontend and never an artifact format: apt/aptitude/dpkg → dpkg (not apt, not deb), dnf/yum/zypper → rpm. The v1 source set is dpkg (Debian/Ubuntu), rpm (RHEL family; SUSE), pacman (Arch), apk (Alpine), the cross-distro Linux sources snap (/var/lib/snapd) and flatpak (/var/lib/flatpak) wherever present, pkg (FreeBSD and DragonFly — the same pkgng SQLite database), openbsd_pkg (OpenBSD), pkgsrc (NetBSD; an illumos/DragonFly secondary), ips (illumos/Solaris — keyed ips, not pkg: IPS’s pkg(1) CLI only coincidentally shares the FreeBSD tool name, and keying on the tool would clobber two different databases), nix (the installed profile set — /nix/var/nix/profiles/default and the NixOS system profile — not the whole /nix/store, which holds every build dependency and GC-rooted version and would massively over-report), macOS receipts (the installer(8)/PackageKit .pkg database — the dpkg/rpm analogue for MDM-managed servers), macOS apps (the .app bundle inventory), Windows registry (the Add/Remove-Programs uninstall hive), and Windows appx (Store/MSIX apps, which the uninstall hive omits). An optional homebrew source is auto-detected where a Cellar prefix exists (absent on clean servers); MacPorts and Chocolatey are deferred and would join the same auto-detected-secondary tier. Plan 9 emits nothing — it has no package database, so the source is absent, not empty.
Each packages.<source> is a list of package records, each a map of {name, version, …} in which name is a field, not a map key. The obvious shape — a name-keyed map, packages.dpkg.bash = "5.1" — is unsound, because a package name is not a unique identifier on most sources: dpkg multiarch (libc6:amd64 and libc6:i386 coexist), rpm multilib and install-only multiversion kernels (several kernel-core with identical name and arch), openbsd_pkg parallel installs (autoconf-2.69p3 and 2.71p3), homebrew formula-vs-cask (docker is both), the Windows uninstall hive (x86 and x64 builds share a DisplayName and DisplayVersion), and Nix (multiple versions of one name coexist by design). A name-keyed map silently drops the colliding sibling. Independently, package names contain dots (python3.11, libdb5.3, libstdc++6), which collide with the engine’s dot-path addressing if a name is a path segment. A list keeps every record, is uniform across all sources, is dot-safe, and makes “is X installed?” a filter that can honestly return a set.
Identity inside the list is completed by per-source fields: architecture (dpkg, rpm, apk), type (formula|cask) and tap (homebrew, so two taps’ same-name/version formulae stay distinct), store_path (nix), branch and architecture (flatpak, whose app-id alone collides across branches and installations — snap by contrast keeps one active revision per name and needs no extra key), bundle_id and path (the macOS apps inventory, since two .app of the same name and version can sit at different paths), and for Windows registry the uninstall subkey / MSI ProductCode plus an architecture distinguishing the native hive from WOW6432Node — without which a 32-bit and 64-bit build sharing DisplayName and DisplayVersion would be byte-identical records. version is the package manager’s verbatim native string; where a manager renders no epoch by default the reader asks for it explicitly (the rpm reader queries %{EPOCH}:%{VERSION}-%{RELEASE}, because a bare rpm -qa omits the epoch that can separate otherwise-identical install-only kernels), and version is not decomposed into sub-fields in v1. A per-source install timestamp is omitted in v1; if added it is spelled install_date (the _date suffix, per the existing dmi.bios.release_date), and only where the source records a true install time.
The scope is system package databases only. Language and dev managers (pip, npm, gem, cargo) are out: they are per-user/per-project, not host facts, and double-list distro-shipped libraries; the source-namespaced shape lets them slot in later as packages.pip with no restructuring. System-scope snap and flatpak are root-owned host-global databases that meet the scope bar and are included in v1: omitting them would make packages wrong on the flagship Linux targets, where stock Ubuntu 22.04+ ships Firefox and Chromium only as snaps (no dpkg entry) and Fedora Workstation ships Flatpaks. Flatpak also keeps per-user installations, which fall under the execution-context boundary below. Records are never merged or deduped across sources: a macOS .pkg receipt and its .app bundle are two records under two keys, not one.
The fact reports system-global databases plus whatever the collector’s own execution context can read; it does not drop privileges or walk every user’s home to enumerate other users’ per-user installs. That boundary leaves several sources context-bounded rather than truly host-complete — homebrew (the owning user’s Cellar), nix per-user profiles, per-user flatpak installations, the Windows per-user HKCU uninstall entries, and Windows appx (per-user-registered: the reader takes the collector context plus the system-provisioned set via Get-AppxProvisionedPackage, so other users’ private Store installs are under-reported). The under-report is documented, and a user field is the additive path to all-users enumeration later.
Collection honors one cheap read per source — parse the on-disk database directly where it is world-readable (/var/lib/dpkg/status, /var/lib/pacman/local/*/desc, the pkgng local.sqlite, /var/db/receipts/*.plist, the nix profile manifest), or one batch query (rpm -qa --qf …, dpkg-query -W, pkg query, Get-AppxProvisionedPackage) — never a process per package, never the network. The readers land as an ADR-0010 per-category module (packages.go) with non-GOOS-suffixed, cross-platform-testable parse functions (the on-disk-parse vs batch-query split is the impure-probe + pure-parse seam); the concrete file layout, the schema entries under ADR-0011 canonical spelling, and per-target validation land via a dedicated OpenSpec change that this ADR does not pre-empt — it fixes only the externally observable shape.
packages is on by default, like every other fact (ADR-0015): the fact model is all-on with subtractive disabling, so a host that does not want package collection on every discovery disables it — --disable packages, FACTS_DISABLE=packages, or the facts.conf disable key. packages registers as a fact group so a disable names it as one unit, and because the disabled set is resolution-gated, disabling skips package collection entirely rather than merely hiding output. Adding a Facts-native packages group diverges the Facter-mirrored --list-block-groups/--list-cache-groups output from Ruby Facter — a deliberate parity divergence, since packages is Facts-native.
Considered Options
- Name-keyed map (
packages.dpkg.bash = "5.1") — rejected: lossy on six sources (drops multiarch siblings, install-only kernels, the cask, the colliding tap, all-but-one Nix version), and package names contain dots that break dot-path addressing. - Name-keyed map of lists (
packages.dpkg.libc6 = [ … ]) — rejected: keeps the dotted-name hazard and forces a list value on the ~99% single-instance case for the benefit of a key lookup that is unsound anyway. - Qualified-key map (
packages.rpm."<NEVRA>" = { … }) — rejected: heterogeneous key spelling per source, and NEVRA/store-path keys are themselves full of dots, so dot-path queries still mis-split. - List of records (chosen) — uniform across every source, collision-proof once per-source identity fields are carried, and dot-safe; “is X installed?” becomes a filter, which is correct because the honest answer can be a set (a multilib pair, three kernels) rather than a single value.