FreeBSD hardware registry + IOKit userland — porting plan

Replace devd's text-parsing autoload + monolithic event fanout with a structured, queryable, Mach-RPC-driven hardware registry (hwregd) inspired by macOS IORegistry — and port Apple's IOKit userland framework as a thin facade on top. Status: draft; synthesized from five parallel research agents on 2026-05-16.

TL;DR

Contents

  1. Motivation — why touch this at all
  2. Strategy: three converging tracks
  3. Hardware registry daemon (hwregd) design
  4. launchd integration: the HardwareMatch key
  5. IOKit userland port — phased
  6. Kext + DriverKit verdicts
  7. FreeBSD-native DriverKit-shape framework
  8. Coexistence with devd, devmatch, newbus
  9. Sequencing relative to configd / IPConfiguration
  10. Effort estimates
  11. Open questions
  12. Decision matrix
  13. Source citations

1. Motivation — why touch this at all

FreeBSD ships two device-management components:

devmatch is fine — it's already structured-data-driven, MIT-class licensed, and easy to lift. devd is the weak link:

Apple's IOKit/IORegistry solves these by exposing a structured in-memory tree of device objects, each with a typed property bag, with a Mach-RPC API for query (IOServiceGetMatchingServices) and watch (IOServiceAddMatchingNotification). Multiple clients subscribe independently. Predicates are dictionary-shaped, not regex-shaped. Events deliver as Mach messages with payload, not as text lines to be re-parsed.

Crucially, none of this requires the IOKit kernel. The structure can be synthesized in userland from FreeBSD's existing kernel facilities (libdevinfo for tree, /dev/devctl for events, /dev/pci for enrichment, linker.hints for module matching). What's missing is the userland daemon that maintains the structure and serves the queries — that's hwregd.

2. Strategy: three converging tracks

Track 1: hwregd (native daemon)

A new FreeBSD daemon. IOKit-shaped Mach-RPC API, but the implementation talks to libdevinfo / /dev/devctl / linker.hints / kldload(2) directly. No Apple-source code in the implementation.

Effort: ~9 weeks v1.

Track 2: IOKitUser facade

Port Apple's IOKitUser (~32k LoC, already at /Users/jmaloney/Documents/launchd/IOKitUser/) as a thin CF wrapper over hwregd's Mach-RPC. IOServiceMatching calls translate to hwreg_lookup. The 27 io_* MIG ops re-bind to our daemon, not to a kernel-resident IOService graph.

Effort: 6-8 weeks for minimum-useful subset.

Track 3: launchd HardwareMatch

Add a HardwareMatch plist key. launchd subscribes to hwregd; on device arrival matching the criteria, fires the job. Replaces devd's text-rule actions for the cases that matter (dhclient on link-up, etc.).

Effort: ~1.5 weeks.

Convergence

Track 1 is the load-bearing piece. Track 2 makes it Apple-API-compatible. Track 3 gives it teeth via launchd. Together they replace devd's role in our stack with a queryable structured registry.

3. Hardware registry daemon (hwregd)

3.1 Inputs

SourcePurposeFreeBSD mechanism
libdevinfoInitial newbus snapshot + per-device pnpinfo, driver name, location, flagsdevinfo_init(), devinfo_foreach_device_child(); backed by sysctl hw.bus.info + hw.bus.devices.<idx>
/dev/devctlHot-plug events (+attach, -detach, ?nomatch, !notify)Single-reader cdev exposed by sys/kern/kern_devctl.c; line-format !system=X subsystem=Y type=Z key=value\n
/boot/kernel/linker.hintsPCI/USB/ACPI ID → module mapping for auto-loadBinary file written by kldxref(8); record types MDT_MODULE, MDT_VERSION, MDT_PNP_INFO per sys/sys/module.h
/dev/pci + PCIOCGETCONFPCI enrichment (BARs, class code, subvendor)struct pci_conf per sys/sys/pciio.h; same path pciconf -lv uses
USB ioctlsUSB port/speed enrichmentUSB_DEVICEINFO, libusb20
sysctl hw.acpi.thermal.*, dev.cpu.N.*, hw.acpi.battery.*Power/thermal property enrichmentPoll @ 5s, push CHANGED events to watchers

3.2 Data model

Tree of HWNode records with parent/child edges. Each node carries a typed property bag implemented as xpc_object_t (we already ship libxpc; this keeps the daemon CF-free — an IOKitUser facade adds the CF flavor at the API boundary).

struct hw_node {
    uint64_t       id;              /* registry-unique, monotonic */
    uint64_t       parent_id;       /* 0 = root */
    char           path[128];       /* e.g. "/pci0/pci0:0:25:0/em0" */
    char           class_name[64];  /* "PCIDevice", "USBDevice", "CPU", "Disk" */
    char           bsd_name[32];    /* "em0", "da0", "ugen0.3" or "" */
    xpc_object_t   props;           /* typed property bag */
    uint32_t       state;           /* PROBED|ATTACHED|DETACHED|FAILED */
    uint32_t       refcnt;          /* for stale-handle detection */
};

Example records (props shown as JSON for brevity):

// Intel Gigabit NIC
{ "class":"PCIDevice", "path":"/pci0/pci0:0:25:0/em0",
  "vendor_id":0x8086, "device_id":0x153a, "subsystem":"0x20708086",
  "class_code":0x020000, "driver":"em", "bsd_name":"em0",
  "irq":20, "bars":[{"type":"mem","base":0xf7c00000,"size":0x20000}],
  "link_state":"up", "mac":"a4:5e:60:..." }

// USB mass-storage stick
{ "class":"USBDevice", "path":"/pci0/.../uhub0/umass0",
  "vendor_id":0x0951, "product_id":0x1666, "speed":"high",
  "vendor_str":"Kingston", "product_str":"DataTraveler",
  "driver":"umass", "bsd_name":"da1", "serial":"..." }

// CPU
{ "class":"CPU", "path":"/cpus/cpu0",
  "vendor":"GenuineIntel", "model":"Core i7-8650U", "core_id":0,
  "freq_mhz":1900, "thermal_c":48.5, "p_state":"P0" }

3.3 Mach-RPC API

MIG-generated. Subset mirrors IOKitLib's surface so the Track 2 facade is mechanical:

kern_return_t hwreg_get_root(mach_port_t srv, uint64_t *root_id);

kern_return_t hwreg_lookup(mach_port_t srv,
    xpc_object_t criteria,              /* {"vendor_id":0x8086,...} or {"class":"USBDevice"} */
    uint64_t **ids, mach_msg_type_number_t *cnt);

kern_return_t hwreg_get_properties(mach_port_t srv,
    uint64_t id, xpc_object_t *props_out);

kern_return_t hwreg_get_children(mach_port_t srv,
    uint64_t id, uint64_t **ids, mach_msg_type_number_t *cnt);

kern_return_t hwreg_watch(mach_port_t srv,
    xpc_object_t criteria,
    uint32_t event_mask,                /* ARRIVED|DEPARTED|CHANGED */
    mach_port_t notify_port,            /* client-supplied send right */
    uint64_t *watcher_id);

kern_return_t hwreg_unwatch(mach_port_t srv, uint64_t watcher_id);

kern_return_t hwreg_load_driver(mach_port_t srv,
    uint64_t id,
    char loaded_module[64],
    int32_t *error_code);

kern_return_t hwreg_retain(mach_port_t srv, uint64_t id);
kern_return_t hwreg_release(mach_port_t srv, uint64_t id);

Watch notifications arrive as Mach messages of:

struct hwreg_event {
    mach_msg_header_t  header;
    uint64_t           watcher_id;
    uint64_t           node_id;
    uint32_t           event;          /* ARRIVED|DEPARTED|CHANGED */
};

Clients run a libdispatch dispatch_source_create(DISPATCH_SOURCE_TYPE_MACH_RECV, ...) on the notify port — our Phase E libdispatch Mach RECV backend already supports this.

3.4 Direct mapping to IOKit's API

IOKitLib callhwregd equivalent
IOServiceMatching("IOPCIDevice")xpc_dict {"class":"PCIDevice"}
IOServiceGetMatchingServices()hwreg_lookup()
IOServiceAddMatchingNotification()hwreg_watch(criteria, ARRIVED, port)
IORegistryEntryCreateCFProperties()hwreg_get_properties()
IOObjectRetain/Release()hwreg_retain/release()
IOIteratorNext()Iterate returned id array client-side
IORegistryGetRootEntry()hwreg_get_root()
IORegistryEntryGetChildIterator()hwreg_get_children()

3.5 Module auto-load flow

                       +-----------------------------+
   boot / hot-plug      | hwregd                      |
   ----------------     |                             |
   1. /dev/devctl       |                             |
      arrival event --> | 2. parse event, build node  |
                        |    state=PROBED             |
                        |                             |
                        | 3. props.driver == ""?      |
                        |    yes -> lookup            |
                        |    (vendor:device) in       |
                        |    linker.hints cache       |
                        |                             |
                        | 4. kldload(<module>)   ----+--> kernel
                        |                             |
                        | 5. kernel attaches driver,  |
   ATTACH event <------ |    emits ATTACH on devctl   |
                        |                             |
                        | 6. enrich node from         |
                        |    libdevinfo + sysctls;    |
                        |    state=ATTACHED           |
                        |                             |
                        | 7. fire matching watchers   |
                        |    (incl. launchd's         |
                        |    HardwareMatch jobs)      |
                        +-----------------------------+

The matching logic in step 3 is lifted from sbin/devmatch/devmatch.c's search_hints() (~200 LoC), with the printf("kldload %s\n", mod) shellout replaced by a direct kldload(2) syscall. Dependency closure happens kernel-side at linker_load_dependencies() (sys/kern/kern_linker.c:2316-2389), so the daemon emits one syscall per device, not N.

3.6 Failure modes

4. launchd integration — the HardwareMatch plist key

New plist keys for jobs that should fire on hardware events:

<key>HardwareMatch</key>
<array>
  <dict>
    <key>class</key><string>NetworkInterface</string>
    <key>media_type</key><string>ethernet</string>
  </dict>
</array>
<key>HardwareMatchAction</key>
<string>StartOnArrival</string>  <!-- or KeepAlive, OneShot -->

At boot, launchd opens a Mach connection to hwregd's bootstrap-registered port (com.apple.hwregd), registers hwreg_watch for the union of all HardwareMatch criteria across its plist set. On ARRIVED, launchd treats the event like an OtherJobActive keepalive trigger and spawns the job, exposing HWREG_NODE_ID, HWREG_BSD_NAME, HWREG_DRIVER in the job's environment. On DEPARTED with HardwareMatchAction=KeepAlive, launchd SIGTERMs the job.

Example replacement plist for the devd dhclient.conf rule (notify 0 on IFNET LINK_UP):

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>Label</key><string>org.freebsd.dhclient.ethernet</string>
    <key>ProgramArguments</key>
    <array>
        <string>/sbin/dhclient</string>
        <string>$HWREG_BSD_NAME</string>
    </array>
    <key>HardwareMatch</key>
    <array>
        <dict>
            <key>class</key><string>NetworkInterface</string>
            <key>media_type</key><string>ethernet</string>
        </dict>
    </array>
    <key>HardwareMatchAction</key><string>StartOnArrival</string>
</dict>
</plist>

(In practice we'd use the configd/IPConfiguration stack rather than shelling out to FreeBSD's dhclient — this is the illustrative shape.)

5. IOKit userland port — phased

Apple's IOKitUser repo (apple-oss-distributions/IOKitUser, already vendored at /Users/jmaloney/Documents/launchd/IOKitUser/) is ~32k LoC of CF-flavored facade over Mach RPC routines defined in osfmk/device/device.defs. Every IOKitLib.h call decomposes to 1-2 MIG round-trips to a kernel-resident IOService graph.

Direct port is infeasible because the kernel side doesn't exist for us — we'd have to mock the entire MIG surface anyway. The leverage is instead to re-aim IOKitUser's MIG client at hwregd:

K1 — read-only registry ~4 weeks

hwregd binary + IOKit-shape Mach API. IORegistryEntry* walk, IOServiceMatching (class match), IOServiceGetMatchingService(s), IORegistryEntryCreateCFProperty/ies. Marker: ioreg -l works against libdevinfo-backed tree. Smoke test asserts ioreg -p IOService -k IOClass returns sensible output.

K2 — notifications ~2 weeks

IOServiceAddMatchingNotification, IOServiceAddInterestNotification, IONotificationPortCreate, IODispatchCalloutFromMessage. Backing: hwreg_watch() + Mach message delivery via libdispatch MACH_RECV source. Marker: ioreg -w 0 -f follow-mode works, prints arrivals as devices appear.

K3 — power management ~4 weeks

Port IOPMAssertion*, IOPSCopyPowerSourcesInfo/List. Requires a small powerd daemon backing the assertion broker (idle-sleep prevention) and ACPI-backed power source enumeration. Marker: pmset -g prints sensible output.

K4 — HID, USB, audio facades deferred

IOHIDManagerCreate, IOUSBHostInterfaceCreate, audio HAL plumbing. Port only when a consumer (GNUstep app, CoreAudio shim) actually needs it. Each facade gets its own backing FreeBSD subsystem (evdev, libusb, sndio/cuse).

Code-shape example for K1's matching path:

// On the IOKitUser facade side:
io_iterator_t it;
CFDictionaryRef m = IOServiceMatching("IONetworkInterface");
IOServiceGetMatchingServices(kIOMainPortDefault, m, &it);

// Translates to Mach RPC:
//   hwreg_lookup(srv,
//                xpc_dict_create_with_kv("class", "NetworkInterface"),
//                &ids, &cnt);

// hwregd walks libdevinfo for nodes whose dd_drivername matches
// the ifnet driver list (em, igb, re, virtio_net, ...) and returns
// the synthesized node ids.

6. Kext + DriverKit verdicts

6.1 Kernel-side kext support SKIP

Loading unmodified Apple kexts on a FreeBSD kernel would require three pillars, each large:

  1. Mach-O kernel image activator. FreeBSD's linker_class is pluggable (sys/kern/kern_linker.c) so a link_macho_class can be added without kernel patches in principle — but it must port kxld (Apple's Mach-O kernel linker, ~30k LoC in xnu/libkern/kxld/) and the split-segment relocation model.
  2. IOKit C++ class hierarchy in-kernel. IOService, IORegistryEntry, IOPCIDevice, IOWorkLoop, IOMemoryDescriptor, plus the full OSMetaClass/OSObject RTTI runtime that drives Apple's OSDynamicCast and IOService matching. Several hundred classes. Even a minimal IOPCIFamily + IONetworkingFamily subset is ~50k LoC of C++ that must compile against FreeBSD's C kernel.
  3. Apple kernel ABI shim. IOMalloc/IOFree, IOLockAlloc, IOSleep/IODelay, current_task(), thread_call, Mach VM (vm_map_t is named the same on both kernels but the struct is incompatible). Plus in-kernel Block.h closures — FreeBSD base ships Block.h userland but no in-kernel BlocksRuntime.

Effort: 3-5 engineer-years for a credible subset. Reference point: FreeBSD's linuxkpi (sys/compat/linuxkpi/common/) is 30,660 LoC across 47 .c files + 407 headers and has been actively developed for ~10 years to cover a comparable foreign in-kernel ABI; IOKit is arguably wider (C++ RTTI + Mach VM model on top of everything Linux had).

Plus Apple's kernel ABI is unstable across point releases — kexts compiled for 10.15 break on 11.0; we'd have to pin a Darwin version. License: APSL 2.0 (usable, viral within the loader module).

6.2 DriverKit / DEXTs (Apple-binary userspace drivers) DEFER

A DEXT is a launchd-managed process that registers as IOUserService, communicates with a kernel-side IOUserServer shim via Mach over an IOUserClient port, and exposes IOServiceDispatchSource/IODispatchQueue on top of libdispatch.

The bottom half is already there: Mach IPC (mach.ko), libxpc, libdispatch's MACH_RECV backend. The top half:

  1. Kernel-side IOUserServer shim — small mach.ko module proxying between an in-kernel newbus device_t and a userspace Mach port. ~3-5k LoC.
  2. Userspace IOKit substrate — build IOKitUser + a DriverKit.framework-shape API (IOService, IOUserClient, OSAction, IODispatchQueue) on top of libCoreFoundation. ~20-40k LoC, much in IOKitUser already.
  3. MIG io_* op table — kernel side needs to implement the 27 MIG ops IOKitLib.c calls. Reconstructible from headers; not vendored locally.

Effort: 8-18 months for a working subset that runs a vendor-supplied USB-class DEXT.

Risks: DEXT entitlement/sandbox model intricate (we'd no-op all entitlements); DriverKit framework headers public but no open-source implementation exists (reverse-engineer from headers + libIOKit traffic).

Defer until configd + hwregd + IOKitUser facade are green. At that point, the marginal cost of adding DriverKit is much lower because the substrate exists.

6.3 FreeBSD-native DriverKit-shape framework PURSUE (post-configd)

See section 7.

7. FreeBSD-native DriverKit-shape framework

Most of the substrate already exists:

The plan: wrap these in IOKit-shape classes. An IOService/IOUserClient userland API that internally calls cuse_alloc_dev/cuse_wait_and_process instead of IOConnectCallMethod. Use the same IOKitPersonalities Info.plist format and IOServiceMatching predicate engine (we already have OSKext.c's plist parser in IOKitUser).

Effort: 4-8 months. Most work is the matching/personality engine + the userland class hierarchy; the kernel substrate exists.

Coexistence with newbus: native — cuse is already a first-class newbus citizen. No conflict possible.

Trade-off: we get the DriverKit API shape but not binary compatibility with Apple's DEXTs. For our user goal ("FreeBSD modernization first, macOS compat second"), this is the right trade-off.

Walkthrough — Realtek USB Ethernet "looks like a DEXT":

  1. Adapter plugged in → FreeBSD usbus0 enumerates → hwregd sees ARRIVAL event.
  2. hwregd matches vendor=0x0bda,product=0x8153 against linker.hints → no kernel driver, but matches an IOKitPersonalities entry in com.realtek.AppleRTL8153.driver/Contents/Info.plist.
  3. hwregd fires a HardwareMatch event; launchd matches the personality and spawns the driver process under a sandboxed UID.
  4. Driver process calls cuse_alloc_unit_number() + cuse_dev_create() to expose /dev/cuse0, runs a userland USB transfer loop via libusb, wraps a userland tun(4)/tap(4) for the IP side.
  5. From an application's API perspective: indistinguishable from a DEXT on macOS. From the kernel's perspective: just another cuse client + tun client.

8. Coexistence with devd, devmatch, newbus

Note: devd is already gone

This section originally described a two-phase devd→hwregd transition. As of commit 88694f0 (2026-05-16), FreeBSD-devd + FreeBSD-devmatch are not in the runtime rootfs at all. hwregd becomes the sole /dev/devctl reader from day one — no coexistence required, no /var/run/devd.pipe intermediary, no rule-duplication risk. The retained coexistence concern is third-party FreeBSD ports (libudev-devd, libinput, Xorg) that historically consumed /var/run/devd.pipe; hwregd can optionally reproduce that pipe format for back-compat or those consumers can be pushed upstream toward an hwregd backend.

8.1 Day-one direct ownership

hwregd reads /dev/devctl directly

Single-reader cdev, no contention since devd is absent. Same line-format the kernel has always emitted (+attach, -detach, ?nomatch, !notify system=X subsystem=Y type=Z key=value). Parse byte-by-byte, dispatch to the matching/event-fanout pipeline.

Risk avoided: the original "dhclient started twice" risk doesn't exist because there's no devd dhclient.conf rule firing in parallel.

8.2 Third-party /var/run/devd.pipe consumers

libudev-devd / libinput / Xorg back-compat

libudev-devd (used by libinput, used by Xorg) historically reads /var/run/devd.pipe. We don't have those packages installed in the current rootfs, so this is a deferred concern. Two options for when it becomes relevant:

  1. hwregd reproduces /var/run/devd.pipe's exact line format as a compat output. Cheap, ~50 LoC.
  2. Push libudev-devd upstream toward a structured hwregd backend. Better long-term, more work.

Defer until a real consumer (likely Xorg or Wayland-on-FreeBSD when we add graphics) needs it.

8.2 What we don't replace

CapabilityFreeBSD providerStatus in plan
Auto-load kld for unmatched busesdevmatch's matching algorithmLift verbatim into hwregd
newbus probe/attachKernel device_attach() in subr_bus.cUnchanged — hwregd consumes events, doesn't probe
Module dep resolutionlinker_load_dependencies() in kern_linker.cUnchanged — single kldload pulls deps
ZFS event loggingdevd's zfs.conf regex rulesReplace with zfsd / native ZFS event hook
Xorg input hot-pluglibudev-devd reading /var/run/devd.pipePhase 2: hwregd reproduces pipe format, or push libudev-devd upstream
ACPI lid / power eventsdevd's acpiconf.conf notify rulesHardwareMatch plists for {class:"ACPIEvent"}

9. Sequencing relative to configd / IPConfiguration

Important: there's no devd to coexist with

The original draft of this plan assumed devd remained in place during the transition. As of commit 88694f0 (2026-05-16), FreeBSD-devd and FreeBSD-devmatch were removed from pkglist-base.txt alongside FreeBSD-rc, FreeBSD-syslogd, etc. The system currently boots to login because the kernel's own newbus probe attaches drivers for hardware present at boot (em/virtio drivers in BOOT_MODULES), but several capabilities are now gone:

This makes hwregd a current gap, not a future improvement. The sequencing below has been revised: hwregd Phase 0 (minimum viable, ~3-4 weeks) comes first, before configd + IPConfiguration, because IPConfiguration needs the interface-arrival and link-state events that hwregd provides.

Registry-first recommended order — fully replace devd+devmatch and stand up the IOKit-shaped query/watch API before configd:

  1. hwregd Phase 0 (minimum viable) — ~3-4 weeks. Lift sbin/devmatch/devmatch.c's search_hints() parser into a daemon. Open /dev/devctl directly. kldload(2) on every ?nomatch event. Parse +attach/-detach/!notify system=IFNET events; expose them via a simple Mach-RPC publish/subscribe (no full IORegistry yet — just an event bus). Closes the devd-removed gap.
  2. hwregd Phase 1 (full IORegistry) — ~5-6 weeks. Build the in-memory tree, property bags, IOKit-shape Mach-RPC API (hwreg_lookup, hwreg_get_properties, hwreg_watch with criteria dicts). Phase 0's simple event bus becomes a special case of Phase 1's hwreg_watch. Phase 0 clients keep working unchanged.
  3. launchd HardwareMatch plist key — ~1.5 weeks. Builds on hwregd Phase 1's watch criteria. Services declare hardware dependencies in their plists instead of needing custom event-bus client code.
  4. IOKitUser facade (K1+K2) — ~6 weeks. ioreg works, IOServiceAddMatchingNotification works. Unlocks Apple-source userland that queries hardware (system_profiler equivalents). This is the milestone where devd+devmatch are fully replaced and the IOKit query API is live.

After step 4 we hit a decision point: pick whichever of the following has the most actual demand at that time. None of them block each other — they're independent tracks downstream of the hwregd/IOKit foundation.

Why registry-first instead of configd-first: hwregd Phase 1 + HardwareMatch + IOKitUser facade are independent of configd, and we have no current network-management consumer that requires DHCP working in the VM. Replacing devd+devmatch and standing up ioreg-shape introspection first means the entire hwregd+IOKit track ships as one cohesive block, after which the configd/powerd/DriverKit ordering becomes a pure demand-driven call.

10. Effort estimates

MilestoneSingle-engineer effortCritical dependency
hwregd Phase 0 (kldload-on-nomatch + interface/link Mach events)~3-4 weeks
hwregd Phase 1 (full IORegistry, IOKit-shape Mach API)~5-6 weeksPhase 0
launchd HardwareMatch key~1.5 weeksPhase 1
IOKitUser K1 (registry + matching)~4 weeksPhase 1
IOKitUser K2 (notifications)~2 weeksK1
↓ decision point — pick by demand; downstream items are independent ↓
configd v1 (SCDynamicStore + SCPreferences)~6 weeks
IPConfiguration port~1 weekconfigd
powerd + IOKitUser K3 (IOPMAssertion, IOPSCopyPowerSourcesInfo)~4 weeksIOKitUser K1+K2
Native DriverKit-shape framework~4-8 monthshwregd Phase 1 + IOKitUser K1+K2

Cumulative critical path through the foundation block (registry + IOKit facade, devd/devmatch fully replaced, ioreg works): ~3.5 + 5.5 + 1.5 + 4 + 2 ≈ ~4 months.

After the foundation block lands the four downstream options are independent. With one engineer they're picked one at a time by demand; with two engineers the network-mgmt track (configd + IPConfiguration, ~7 weeks) and the Apple-services track (powerd or DriverKit-shape) can run in parallel.

11. Open questions

  1. Property freshness vs. cost. Re-poll thermal/link-state on every get_properties, or only via timer + push CHANGED events? Trade-off: Mach round-trip count vs. staleness window.
  2. Persistent ids. macOS IORegistry ids are not stable across boots. Do we need stable ids for HardwareMatch to target "this specific disk" via hashed location + serial?
  3. User clients. IOKit's IOServiceOpen/IOConnectCallMethod exposes per-driver IPC channels. Do we want hwregd to broker these (proxying to per-driver Mach ports)? Or do FreeBSD drivers stay pure cdevs and hwregd is read-only metadata?
  4. kld dependency probing. kldload is all-or-nothing. Pre-probe with kldxref -d to detect dep mismatch before attempting load and present a clean error?
  5. Authorization. Who can call hwreg_load_driver? Root only? Capsicum-style entitlements via XPC peer credentials?
  6. devd compat shim. Phase 2: do we reproduce /var/run/devd.pipe's line format from hwregd, or push libudev-devd upstream toward a structured hwregd backend? Affects ports tree compat surface.
  7. Tracking newbus probe scores. When multiple modules match the same device, newbus has a probe-priority system. Do we surface that to hwregd for tiebreaking, or use our own priority overlay?

12. Decision matrix

PathVerdictEffortWhen
hwregd Phase 0 (kldload-on-nomatch + interface events)PURSUE FIRST~3-4 wksNow — closes the devd-removed gap
hwregd Phase 1 (full IORegistry + IOKit-shape API)PURSUE~5-6 wksImmediately after Phase 0
launchd HardwareMatch plist keyPURSUE~1.5 wksAfter hwregd Phase 1
IOKitUser facade on hwregd (K1+K2)PURSUE~6 wksAfter hwregd Phase 1 — closes the foundation block; devd+devmatch fully replaced, ioreg live
↓ decision point — pick by demand ↓
configd + IPConfiguration (network-management track)PURSUE~7 wksWhen DHCP / DNS / SCPreferences become a real consumer need
powerd + IOKitUser K3 (IOPMAssertion, IOPSCopyPowerSourcesInfo)PURSUE~4 wksWhen power-mgmt becomes a real consumer need (laptop / suspend-resume / battery)
FreeBSD-native DriverKit-shape framework (cuse + IOKit personalities)PURSUE~4-8 moWhen a real userspace-driver consumer surfaces
Apple DriverKit / DEXT binary compatDEFER~8-18 moResearch now, build later if real demand surfaces
Kernel-side kext support (Mach-O kexts on FreeBSD kernel)SKIP~3-5 yrsApple's kernel ABI is unstable across point releases anyway
Extend configd to do hardwareSKIPMixes concerns; macOS keeps them separate too. configd does network/system policy; hardware discovery deserves its own daemon
Just keep devd as-isSKIP0Forfeits the structured-data win; doesn't unblock IOKitUser-shaped consumers

13. Source citations

FreeBSD source (cgit.freebsd.org/src/tree/)

Apple source (locally vendored)

Related project memory

FreeBSD bugzilla — failure modes referenced


Draft synthesized from five parallel research-agent reports on 2026-05-16: (1) IOKit userland port scope, (2) linker.hints + newbus + PCI exploitation, (3) devmatch/devd functional audit, (4) hardware-registry daemon design, (5) kext + DriverKit feasibility. Please flag anything that should change before this becomes a committed plan.