gershwin-on-freebsd: livecd rework + launchd port

Plan to integrate the freebsd-launchd work (livecd architecture + launchd PID 1 + kmodloader + configd stub) into the gershwin-on-freebsd project. Sequenced: livecd rework first, then launchd, then the Apple-shaped helper daemons. Holds the line on packages — gershwin-on-freebsd's existing resources/packages/ lists are the source of truth and we add nothing new in either phase.

Status: Phase 1 + Phase 2 landed (CI-green) v3 — Phase 3 polish next

Goal

Take what we've learned building freebsd-launchd — the livecd boot pattern (cd9660 → mkuzip → tmpfs → unionfs cascade), the /boot/firmware symlink workaround for kernel-namei firmware loading, the in-chroot GNUstep build, the launchd-as-PID-1 Option D pivot, the kmodloader hardware-bind daemon, the netconfigd stub — and land it in the gershwin-on-freebsd project as a sequenced, reversible series of changes.

Gershwin-on-freebsd already does most of the work. It already uses pkg-base. It already builds a hybrid EFI/BIOS ISO via mkisoimages.sh. It already does the cd9660+uzip+tmpfs+unionfs cascade. It already builds the GNUstep stack into /System/Library/. The integration is therefore not a rewrite — it's a cleanup pass on the livecd (Phase 1) followed by an additive launchd port (Phase 2).

Constraints (hard)

Packages. gershwin-on-freebsd/resources/packages/ is the canonical package set, and we do not re-audit it. Don't redo the work of figuring out what pkg-base entries gershwin needs — that's been settled. No new kernel modules / GPU drivers (no nvidia-drm-kmod; existing drm-kmod in drivers stays as-is). The only package edits across this whole plan are four lines, all in Phase 2: Nothing else changes package-wise. No package list reorganization, no other strip-outs, no other adds, regardless of whether some other entry "looks redundant" under launchd.
Gershwin install orchestration. gershwin-developer on the feat/libs-corebase branch is the canonical installer for the GNUstep system domain plus the desktop. build.sh already invokes it; the only change is the branch flag (git clone --branch feat/libs-corebase --depth 1 …). We do not vendor or fork the GNUstep build steps into gershwin-on-freebsd.
Sequencing. Livecd rework first; launchd after that's stable. Phase 1 done — including the init_script → /init.sh move and the architectural unionfs rework (this is what was originally underscoped as "cosmetic"). No launchd plists land until Phase 2. Phase 2 only starts after the Phase 1 continuous-release ISO boots through the existing rc.d flow on real hardware.
freebsd-launchd source location. Cloned into /Developer/Library/Sources/freebsd-launchd/ via chroot git clone from inside build.sh, matching gershwin's existing pattern (build.sh:281 already clones gershwin-developer in-chroot). The standalone freebsd-launchd repo's "chroot stays git-free + rsync from host" rule is project-local — when integrating into gershwin we follow gershwin's pattern.

Source-of-truth split

RepoOwnsBranch
gershwin-on-freebsd Package lists (base, vital-base, gershwin, drivers, vital-gershwin); livecd build script (build.sh); init_script / /init.sh; loader config; ISO mastering; CI; LaunchDaemon plists in Phase 2. main
gershwin-developer GNUstep system-domain build (libdispatch → tools-make → libobjc2 → libs-base → libs-corebase → libs-gui → libs-back); desktop apps build; clones into /Developer/Library/Sources/; Install-System-Domain.sh drives make install. feat/libs-corebase
freebsd-launchd launchd PID 1 binary + LaunchDaemon plists; kmodloader; netconfigd stub. Cloned by gershwin-on-freebsd's build.sh in Phase 2 into /Developer/Library/Sources/freebsd-launchd/. main

Three repos, one direction of dependency: gershwin-on-freebsd consumes both gershwin-developer and freebsd-launchd; the other two don't know about each other.

Architecture (target end state)

Phase 1 (landed): Phase 2 (pending): livecd ISO boot flow same flow, last line of init.sh swapped + loader.conf flips to Option D +--------------------------+ | cd9660 (kernel root) | init.sh today (Phase 1): | /boot/{loader,kernel,…}| kenv init_chroot=/sysroot | /rootfs.uzip | exit 0 | /init.sh ◄ kernel reads init_script | | /sysroot/ /upper/ /dev/ from loader.conf, forks /rescue/sh /init.sh +--------------------------+ | | v /sbin/init runs from cd9660 (PID 1) /sbin/init reads init_chroot kenv | chroots into /sysroot reads init_script kenv = /init.sh continues normal multi-user | | /init.sh: v mdconfig /rootfs.uzip rc.d (Phase 1) / launchd (Phase 2) mount UFS at /sysroot (lower) | mount tmpfs at /upper (writable) v mount unionfs /upper /sysroot login: prompt mount devfs /sysroot/dev | gershwin live tweaks (rc.conf, (Phase 2 swap) LoginWindow.plist, hostname, init.sh's last line becomes: VirtualBox detect) exec chroot /sysroot /sbin/launchd kenv init_chroot=/sysroot loader.conf drops init_script/init_shell exit and sets init_path="/init.sh" (Option D) Source-of-truth split: +--------------------------+ +--------------------------+ | pkgdemon/gershwin-on- | | gershwin-developer | | freebsd build.sh: | | feat/libs-corebase | | setup_workspace | | Library/Scripts/ | | install_base_system ───┼─pkg-base→ Bootstrap.sh | | install_gershwin_software│ | Checkout.sh | | build_gershwin_components│ | Install-System- | | └─ git clone --branch ─┼──→ Domain.sh | | feat/libs-corebase | | builds GNUstep stack | | gershwin-developer | | + desktop apps | | build_launchd (Phase 2) ─┼──→ /Developer/Library/ | | └─ rsync freebsd- | | Sources/freebsd- | | launchd into | | launchd/ (Phase 2) | | /Developer/Library/ | | builds launchd, plists, | | Sources/ | | kmodloader (Phase 3) | +--------------------------+ +--------------------------+

Phase 1 (landed) keeps stock /sbin/init+rc.d but introduces the unionfs+chroot architecture. Phase 2 changes the last line of init.sh and the loader knobs to swap stock init for launchd; the rest of the cascade body is reused unchanged. Phase 3 fills in the Apple-shaped helpers.

Phase 1: livecd rework landed

Three commits on pkgdemon/gershwin-on-freebsd:main. Init stays stock; rc.d stays the service manager — but the boot architecture now mirrors freebsd-livecd-unionfs (single uzip rootfs + tmpfs upper + unionfs + init_chroot kenv pivot), making Phase 2's launchd swap a single-line change.

1.1 Switch gershwin-developer to feat/libs-corebase done — 1d2150b

One-line change at build.sh:281:

git clone --branch feat/libs-corebase --depth 1 \
    https://github.com/gershwin-desktop/gershwin-developer "${RELEASE_DIR}/Developer"

Diff between main and feat/libs-corebase in gershwin-developer: two commits (~10 lines) that add libs-corebase to the build orchestrator. There is no patched/forked libs-corebase — it's plain upstream gnustep/libs-corebase HEAD with a vanilla configure invocation. Once feat/libs-corebase merges to main upstream, drop the --branch flag — but until then, the user's open upstream PR (gershwin-developer#32) is the source of truth and we leave it alone.

This enables CoreFoundation-shaped APIs (libgnustep-corebase.so) for everything downstream — including freebsd-launchd in Phase 2, which links against it for plist parsing.

1.2 /boot/firmware symlink — not needed

Resolved (skip): the symlink trick fixes a kernel-namei vs. userspace-chroot namespace split that exists in freebsd-launchd's chroot-to-/sysroot model. Gershwin's Phase 1 architecture also chroots to /sysroot, but does so via /sbin/init's init_chroot kenv — the kernel's view of /sysroot is a real mount stack (uzip + tmpfs upper + unionfs), and /sysroot/boot/firmware resolves through that stack to the actual firmware files in the uzip. The "kernel can't see what userspace can see" bug doesn't apply because the kernel's namei is operating against the same mount tree. If post-rework hardware testing surfaces firmware-loading failures we revisit; until then no symlink.

1.3 Live-mount cascade rework: single-root unionfs + init_chroot pivot done — 3b97e40

This was originally underscoped as "tighten init_script (cosmetic)." That was wrong — gershwin's livecd had to actually move to the freebsd-livecd-unionfs runtime model before Phase 2 could land cleanly. What changed:

Before: resources/overlays/boot/init_script ran a 14-mount cascade (per-subdir nullfs of /Developer, /System, /Local, /bin, /lib, /libexec, /sbin, /usr, /boot, /root, plus tmpfs at /nvidia, /compat, /tmp, /media) plus 5 unionfs mounts on top, plus cp -R /media/.uzip/var → /var (~10–50 MB physical copy) and cp -R /media/.uzip/etc → /tmp; nullfs /tmp/etc /etc (~5–20 MB physical copy). No chroot — kernel root stayed cd9660. Mount points to track: 19.

After: resources/overlays/init.sh at cdroot top-level. Cascade:

mdconfig -a -t vnode -o readonly -f /rootfs.uzip -u 0
mount -t ufs -o ro /dev/md0.uzip /sysroot   # lower
mount -t tmpfs tmpfs /upper                  # writable upper
mount -t unionfs /upper /sysroot             # combined
mount -t devfs devfs /sysroot/dev
# … gershwin live-mode tweaks against /sysroot/… …
kenv init_chroot=/sysroot
exit 0

/sbin/init (still PID 1, real FreeBSD binary from /rescue/init since /sbin/init isn't on the cd9660) reads init_chroot kenv at init.c:333 and chroots into /sysroot before continuing multi-user. Kernel root stays cd9660; userland sees the unionfs as /. Mount points: 6.

Loader.conf:

# Phase 1 additions:
unionfs_load="YES"
init_shell="/rescue/sh"
init_script="/init.sh"   # was /boot/init_script

Build.sh changes:

Live-mode tweaks preserved (rcorder surgery, SMBIOS hostname, VirtualBox detect, sendmail/linux/dbus rc.conf overrides) — paths retargeted to /sysroot/… since they run before the chroot.

Footprint impact: disk unchanged; RAM at idle ~15–70 MB lower; boot speed faster (two recursive cp -R passes go away); cognitive load much lower (one mount stack instead of nineteen).

1.4 CI boot-test gate done — 7caefbf

Workflow split into three jobs: buildtestrelease. release only fires on push to main and only when both prior jobs pass.

tests/boot-test.sh (lifted from freebsd-launchd, simplified to single stage): runs qemu-system-x86_64 with OVMF, KVM if available else TCG single-thread, -display none -serial stdio. Watches the serial log for any of: "login:" (getty prompt), "Starting local daemons" (rc multi-user marker), or "Welcome to Gershwin/FreeBSD" (banner). 10-minute timeout. Boot log uploaded as artifact on failure.

The test job runs on ubuntu-latest (not the freebsd-vm) and pulls qemu+expect+ovmf via apt-get — same pattern as freebsd-launchd's CI.

1.5 Phase 1 acceptance criteria

Phase 2: launchd port starts only after Phase 1 ships

Goal: replace stock /sbin/init+rc.d with launchd as PID 1, while keeping the same package set and the same observable services. This is the bulk of the work.

2.1 Build launchd in the chroot

Add a new build stage in build.sh, between build_gershwin_components and configure_system. Cloned inside the chroot to match gershwin's existing pattern (build.sh:281 already clones gershwin-developer the same way).

build_launchd() {
    log "Building freebsd-launchd from source..."

    # Host-side git clone writing into the chroot's Sources tree.
    # Matches gershwin's existing build.sh:281 pattern verbatim — same
    # destination convention (gershwin-developer's $SCRIPT_DIR/../Sources
    # from Checkout.sh:10) and same in-chroot Sources layout. Lands as
    # a sibling to libdispatch, tools-make, libobjc2, libs-base,
    # libs-corebase, libs-gui, libs-back already cloned by Checkout.sh.
    git clone --depth 1 https://github.com/pkgdemon/freebsd-launchd \
        "${RELEASE_DIR}/Developer/Library/Sources/freebsd-launchd"

    # Build setup mirrors build_gershwin_components: resolv.conf for
    # any network-touching configure step, devfs for /dev/null-style
    # subprocess pipes that gmake/configure want.
    cp /etc/resolv.conf "${RELEASE_DIR}/etc/resolv.conf"
    mount -t devfs devfs "${RELEASE_DIR}/dev" 2>/dev/null || true

    # GNUstep environment is already populated by gershwin-developer's
    # make install. launchd just needs to compile against libgnustep-base
    # and libgnustep-corebase, both already at /System/Library/Libraries.
    chroot "${RELEASE_DIR}" sh -c "
        . /System/Library/Makefiles/GNUstep.sh &&
        cd /Developer/Library/Sources/freebsd-launchd &&
        ./configure --prefix=/ &&
        gmake -j\$(sysctl -n hw.ncpu) &&
        gmake install
    "

    umount "${RELEASE_DIR}/dev" 2>/dev/null || true
    rm -f "${RELEASE_DIR}/etc/resolv.conf"
}

Wired into main at build.sh:441:

build_gershwin_components
build_launchd                # NEW
configure_system

2.2 Swap to Option D + launchd PID 1

Phase 1 already moved init.sh to cdroot top-level, restructured to the /sysroot unionfs model, and delivered the live-mount cascade body. Phase 2 only needs to change how the kernel exec's init.sh (init_script kenv → Option D shebang) and what init.sh does at the end (set init_chroot kenv → exec launchd in the chroot).

Concrete migration:

  1. Update loader.conf: remove init_script="/init.sh" and init_shell="/rescue/sh"; add init_path="/init.sh". (The #!/rescue/sh shebang on init.sh is already there from Phase 1.) With Option D, the kernel's imgact_shell resolves the shebang and exec's /init.sh as PID 1 directly — no /sbin/init, no init_chroot kenv path.
  2. Update init.sh tail: replace
    kenv init_chroot=/sysroot
    kenv -u init_script
    kenv -u init_shell
    exit 0
    with
    exec chroot /sysroot /sbin/launchd
    The cascade body above (mdconfig + ufs + tmpfs + unionfs + devfs + gershwin live tweaks) stays unchanged.
  3. Delete the rcorder surgery from init.sh. launchd reads its own dependency graph from plist RunAtLoad/KeepAlive/socket-activation keys; it doesn't care about # REQUIRE: lines in /etc/rc.d/*.

Reference: freebsd-launchd's plan §boot walks through the Option D mechanics. The diff against Phase 1's init.sh is small (delete the rcorder block, swap the last 4 lines for one).

2.3 LaunchDaemon plist set narrowed

Phase 2's launchd plist set is small. Per user direction 2026-05-07: only port what's actually needed to boot gershwin to login. Most services don't earn their plist yet.

PlistSourceNotes
org.freebsd.varrun
org.freebsd.syslogd
org.freebsd.cron
org.freebsd.getty
org.freebsd.dhcpcd
org.freebsd.kmodloader
freebsd-launchd Bedrock plists; install via gmake install in Phase 2's build_launchd stage. Already authored.
org.gershwin.dshelper NEW (gershwin-specific) Replaces dshelper_enable=YES. Authored against the rc.d source for dshelper (user will share when we're authoring).
org.gershwin.loginwindow NEW (gershwin-specific) Replaces loginwindow_enable=YES. Authored against the rc.d source for loginwindow.

Everything else in configure_system stays as-is. dbus, cupsd, avahi-daemon, avahi-dnsconfd, ntpd, smartd, moused, webcamd, dsbdriverd, initgfx — none get launchd plists in Phase 2. With launchd as PID 1 there is no /sbin/init, no /etc/rc, and so the _enable=YES lines for those services don't fire — they sit harmlessly in rc.conf. Gershwin boots to login with just the bedrock + dshelper + loginwindow + getty + dhcpcd; the desktop reaches a usable login screen without dbus or the rest.

Phase 3 follow-ups (one at a time, each on user signal):

2.4 configure_system rc.conf cleanup

Two new lines added to configure_system's sysrc block, two implicitly removed:

2.5 Phase 2 acceptance criteria

Out of scope for Phase 2: dbus, cupsd, avahi, ntpd, smartd, moused, webcamd. Those start under rc.d today; with launchd as PID 1 they don't start at all. Their plists land in Phase 3+ on user signal.

Known gap — single-user mode (deferred): boot -s at the loader sets RB_SINGLE in kern.boothowto, but our init.sh doesn't check it — it always exec's launchd, which then tries to load LoginWindow on a system with no X. Stock FreeBSD and macOS both handle single-user as a "root shell on console, no auth, no daemons" recovery mode. Implementation sketch for the future: branch in init.sh after the cascade — if boothowto & 0x10, exec chroot /sysroot /bin/sh -i; else exec chroot /sysroot /sbin/launchd. No getty needed (single-user bypasses auth). Routing around launchd in init.sh is simpler than porting macOS's single-user-aware launchd codepath. Per user direction 2026-05-07: "this can come much later it isn't important right now. i just want to document the gap."

Phase 3: polish (deferred) post-launchd

Phase 3 is the long tail of Apple-shaped helper daemons that earn their keep one at a time. None of them block Phase 2 from being usable. Listed in priority order:

ComponentReplacesTrigger to land
kmodloaderinitgfx + dsbdriverd + manual kld_list= entriesWhen a class of hardware (GPU, NIC, USB) regularly fails to bind in the field. Most of the freebsd-launchd version is reusable wholesale; only the GPU vendor map needs gershwin-specific tuning if any.
netconfigd stubNothing yet — it's foundationalWhen the desktop needs a programmable view of network state (Network preference pane, WiFi switcher). Phase 1 stub from freebsd-launchd ships as-is.
ASLFreeBSD-syslogdWhen console-noise filtering becomes worth months of work. Today: dhclient and dhcpcd-style spam goes to /var/log/messages; ASL's structured filtering would cleanly suppress per-Sender at the daemon side. Major port; defer.
mDNSResponderAvahiWhen .local resolution against Avahi is unreliable. Apple's mDNSPosix layer is portable; the swap is mostly configuration. Could be small.
notifydNothing yetWhen a desktop component wants Apple's lightweight pub/sub event bus.
DiskArbitrationWorkspace File Viewer's ad-hoc kqueue pollingWhen the Devices sidebar needs proper attach/detach events.

Post-launchd cleanup deferred

Once Phase 2 lands and gershwin boots reliably with launchd as PID 1 + a working org.gershwin.loginwindow.plist, do this single follow-up commit on pkgdemon/gershwin-on-freebsd:main. End-user UX takes precedence over development diagnostics from this point.

Revert the Phase 1 boot diagnostics (commit 02e4e9a)

Retire the CI boot-test gate (commit 7caefbf)

Why deferred, not skipped: during Phase 1/2 the verbose boot output + the boot-test gate are valuable diagnostic surface — both for catching kernel/init regressions in CI and for reading dmesg on real hardware while the architecture is in flux. After launchd + loginwindow stabilize, neither earns its keep. The CI overhead of a per-push boot-test (build + qemu run on every push) becomes pure tax once the fast-iteration phase ends.

What does NOT need reverting: the exec >/dev/console 2>&1 in init.sh (commit 206f8a7). It lives inside the else branch of the boot_mute check — when boot_mute="YES" is restored, the silencing branch fires and the explicit redirect is dead code. Leave it; it gives future debugging cycles visible output for free.

Single commit suggested title: "post-Phase-2 cleanup: restore quiet boot, retire CI boot-test gate" with reference to commits 02e4e9a + 7caefbf in the body.

LoginWindow plist

Three things share the name "LoginWindow"; keep them straight.

ArtifactWhat it isPhase
/Local/Library/Preferences/LoginWindow.plist Existing gershwin state file. Tracks last-logged-in user and last session script. Written by init_script today (lines 133-139). Two-key dictionary, no launchd semantics. Already shipped. Keep as-is.
/System/Library/LaunchDaemons/org.gershwin.loginwindow.plist NEW launchd job to be authored in Phase 2. Replaces the rc.d loginwindow_enable=YES. Starts whatever binary today's gershwin loginwindow rc script starts (likely a SLiM-style greeter or a Gershwin-native equivalent — needs identification before authoring). Phase 2.
Apple's loginwindow.app Closed-source macOS daemon. Per the LoginWindow research: not portable — depends on SkyLight/CGSSession internals with no FreeBSD analog, on per-session launchd domains we don't yet model, on Security.framework keychain. Real port is a clean-room reimplementation against GNUstep AppKit + a chosen WindowServer + OpenPAM, gated on prerequisites that don't exist. Phase 4+, research only.

Phase 2's org.gershwin.loginwindow.plist is the small, concrete, useful thing. Sketch:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>org.gershwin.loginwindow</string>
    <key>ProgramArguments</key>
    <array>
        <!-- TODO: identify what gershwin's current loginwindow rc script
             actually exec's; pkg shows it under /usr/local/etc/rc.d/loginwindow
             after install_gershwin_software lands. Likely a wrapper script
             around slim or a custom greeter -->
        <string>/usr/local/sbin/loginwindow</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/var/log/loginwindow.log</string>
    <key>StandardErrorPath</key>
    <string>/var/log/loginwindow.err</string>
</dict>
</plist>

Authored once we read the actual rc script in the post-install image — the ProgramArguments path is a guess until then. StandardOutPath/StandardErrorPath are recorded as not-yet-implemented in freebsd-launchd's plist parser; they're written for the eventual implementation, ignored harmlessly today.

File-by-file change inventory

gershwin-on-freebsd

FilePhase 1Phase 2
build.sh:281done — 1d2150b --branch feat/libs-corebase added.Insert build_launchd stage between build_gershwin_components and configure_system.
build.sh:224-257 (configure_system rc.conf block)Untouched.Strip _enable=YES lines for converted services; keep tunables.
build.sh:319-389 (prepare_boot_env)done — 3b97e40 dropped 30+ mountpoint mkdir; only /sysroot, /upper, /dev; dropped /etc/login.conf workaround; added unionfs.ko to keep list; cp init.sh from overlays top-level.Untouched (init.sh tail change is in the overlay, not build.sh).
build.sh generate_isodone — 3b97e40 uzip moved from /boot/rootfs.uzip to /rootfs.uzip.Untouched.
resources/overlays/boot/init_script (old)done — 3b97e40 deleted; replaced by resources/overlays/init.sh.
resources/overlays/init.sh (new)done — 3b97e40 single-root unionfs cascade + gershwin live tweaks against /sysroot/… + kenv init_chroot=/sysroot + exit.Delete rcorder surgery block; replace last 4 lines (kenv init_chroot + cleanup + exit 0) with single line exec chroot /sysroot /sbin/launchd.
resources/overlays/boot/loader.confdone — 3b97e40 init_script="/init.sh", init_shell="/rescue/sh", unionfs_load="YES".Drop init_script + init_shell; add init_path="/init.sh".
resources/overlays/Local/Library/Preferences/LoginWindow.plistUntouched.Untouched (this is the state file, not the launchd plist).
resources/overlays/System/Library/LaunchDaemons/*.plistNEW directory; ~15 plists land here over the rollout.
resources/packages/gershwinUntouched.Add dhcpcd and wpa_supplicant.
resources/packages/baseUntouched.Remove FreeBSD-dhclient and FreeBSD-wpa.
resources/packages/vital-baseUntouched.Remove FreeBSD-dhclient and FreeBSD-wpa if present.
resources/packages/{drivers,vital-gershwin}Untouched.Untouched — settled work; not re-audited.
tests/boot-test.shdone — 7caefbf qemu+OVMF+expect; multi-marker.Add a second grep for launchd: PID 1 ready.
.github/workflows/build.ymldone — 7caefbf split into build → test → release; release gated by boot-test.Untouched (boot-test detects launchd via grep).

gershwin-developer

FileChange
(none required)We consume the feat/libs-corebase branch as-is. If the branch needs to merge to main and pick up an upstream PR before we can drop the --branch flag, that's a separate gershwin-developer task.

freebsd-launchd

FileChange
build.sh (the freebsd-launchd one)Untouched. freebsd-launchd remains independently buildable as a standalone livecd. Gershwin-on-freebsd just consumes the launchd binary and plists, not the build.sh.
kmodloader/, configd/Untouched in Phase 2; potentially adopted in Phase 3.

Open questions

11.1 — Firmware symlink — resolved (skip). Phase 1's init_chroot chroot operates against a real mount stack (uzip + tmpfs + unionfs at /sysroot); the kernel's namei sees the firmware files through the same mount tree the userspace chroot sees. The freebsd-launchd kernel/userspace namespace split that motivated the symlink doesn't apply. Revisit only if hardware testing surfaces a firmware-loading failure under the new architecture.
11.2 — Identity of the existing loginwindow binary. The loginwindow_enable=YES sysrc points at an rc.d script in /usr/local/etc/rc.d/loginwindow that lands during install_gershwin_software. Need to read that script after a fresh build to identify the command_args + executable path — drives the Phase 2 launchd plist's ProgramArguments. Likely shipped by one of the gershwin-* packages (gershwin-system or gershwin-workspace).
11.3 — DSBSD dsbdriverd overlap with kmodloader. Phase 2 keeps dsbdriverd. Phase 3's kmodloader does devmatch-based kmod loading + a GPU PCI scan, which is approximately what dsbdriverd does. Need a side-by-side coverage comparison before retiring either. Tabling for Phase 3.
11.4 — initgfx vs kmodloader for GPU. Same pattern as 11.3. initgfx (GhostBSD) configures Xorg too, not just KLD load. kmodloader only loads kmods. If we keep Xorg config in initgfx and let kmodloader handle bind, both can coexist. Or rip out initgfx entirely and have a separate gershwin component own Xorg config.
11.5 — pkg-base fingerprints across versions. Per the pkg-base research, the keys live in /usr/share/keys/pkgbase-15 — version-suffixed. When gershwin-on-freebsd bumps from 15 to 16, this path moves. build.sh currently doesn't reference it (host's pkg knows; the chroot inherits the repo conf). Worth a comment somewhere, no immediate change.
11.6 — Boot-test on real hardware in CI. QEMU boot-test catches a lot but not everything (firmware loading on the Lenovo wouldn't have been caught in QEMU). Worth investigating a real-hardware boot test runner — overkill for now, log it as a future improvement.

Decisions log

Plan v3, 2026-05-07 — Phase 1 + Phase 2 fully landed and CI-green (Phase 2 ends at run 25504336947, commit ebe7320). Phase 3 polish (dbus, kmodloader, gdomap lo0 fix, dshelper daemonization, ASL, single-user mode) follows on user signal.