|
| 1 | +# M3 — User Environment: Retrospective |
| 2 | + |
| 3 | +**Closed:** 2026-04-29 (~1 day of agent-paced work vs. 2-week budget per Plan §7.4) |
| 4 | +**Exit commit:** to be tagged `m3-exit` from this branch's merge commit. |
| 5 | + |
| 6 | +## Scope delivered |
| 7 | + |
| 8 | +Plan §7.4's two-week task list, exit criteria all hold: |
| 9 | + |
| 10 | +### Week 1 — adapter and bootstrap |
| 11 | + |
| 12 | +- **`pearlite-userenv`** (Plan §6.10, PR #44): `HomeManagerBackend` |
| 13 | + trait + `LiveHmBackend` + `MockHmBackend`. One method — |
| 14 | + `switch(user, config_path, mode, channel) -> UserEnvOutcome { user, |
| 15 | + generation }` — shells out via `runuser -u <user> -- home-manager |
| 16 | + switch …` (CLAUDE.md hard invariant 5: argv arrays only). Standalone |
| 17 | + vs Flake invocation styles supported. Generation parsed from the |
| 18 | + `home-manager generation N created.` line. |
| 19 | +- **`pearlite-userenv::installer`** (PR #45): `NixInstaller` trait + |
| 20 | + `LiveNixInstaller` + `MockNixInstaller`. ADR-004 implementation — |
| 21 | + the only curl-piped script Pearlite tolerates, defended by a |
| 22 | + hash-pin: SHA-256 mismatch returns `Sha256Mismatch` and the script |
| 23 | + is **never** executed. `nix --version` short-circuits when nix is |
| 24 | + already on PATH. |
| 25 | + |
| 26 | +### Week 2 — engine integration |
| 27 | + |
| 28 | +- **`Action::UserEnvSwitch`** + **`ApplyPhase::UserEnv`** (PR #46): |
| 29 | + the diff-side surface, slotted between `ServiceRestarts` and |
| 30 | + `SnapshotPost` per PRD §8.2 phase 7. `failure_coherence` = |
| 31 | + Recoverable; `within_phase_key` = `(0, user.clone())` so multiple |
| 32 | + switches sort alphabetically. |
| 33 | +- **`Engine::apply_plan` dispatch** (PR #47): threaded |
| 34 | + `HomeManagerBackend` through `apply_plan`, added |
| 35 | + `ApplyPhase::UserEnv` to the iteration list, dispatched |
| 36 | + `UserEnvSwitch` actions through `home_manager.switch`. |
| 37 | + `ApplyError::Userenv(UserenvError)` via `#[from]`. The CLI maps |
| 38 | + this to `APPLY_USERENV` / class 3 / exit 4. |
| 39 | +- **Diff classifier** (PR #48): `classify/user_env.rs` emits a |
| 40 | + switch when (a) the user has `home_manager.enabled = true` and |
| 41 | + (b) either no `[[managed.user_env]]` record exists or the |
| 42 | + `config_hash` differs from `declared_user_env_hash[user]`. |
| 43 | +- **`pearlite-fs::sha256_dir`** + per-user hash (PR #49): walks the |
| 44 | + HM config directory in sorted order, hashing |
| 45 | + `<rel-path> 0x00 <content> 0x00` per entry. Determinism property |
| 46 | + exercised by `sha256_dir_is_deterministic_across_directory_layouts`. |
| 47 | + `Engine::compute_user_env_hashes` populates the diff's |
| 48 | + `declared_user_env_hash` map. |
| 49 | +- **`UserEnvSwitch.config_hash` + `[[managed.user_env]]` upsert** |
| 50 | + (PR #50): each action carries the plan-time hash; `apply_plan` |
| 51 | + records `(user, generation, config_hash)` to `state.toml` after |
| 52 | + each successful switch via `upsert_user_env_records`. By-user |
| 53 | + upsert (not append), so re-applies stay at one row per user. |
| 54 | +- **VM scenarios** (PR #51): `vm-07-user-env-apply.sh` exercises the |
| 55 | + one-shot apply path; `vm-08-user-env-drift.sh` exercises the |
| 56 | + apply → idempotent re-plan → mutate-config → drift-detected |
| 57 | + re-plan → upsert loop end-to-end. Both gate behind |
| 58 | + `PEARLITE_VM_TEST=1`. |
| 59 | + |
| 60 | +## Numbers |
| 61 | + |
| 62 | +| Metric | Value | |
| 63 | +|---|---| |
| 64 | +| Commits on `main` since `m2-exit` | 8 | |
| 65 | +| PRs merged in M3 | 8 (PRs #44 – #51) | |
| 66 | +| Tests passing | 303 across the workspace (up from M2's 267) | |
| 67 | +| Workspace crates | 13 (no new crates; `pearlite-userenv` filled out from PR #44 onward) | |
| 68 | +| New ADRs | none — ADR-004 covers Determinate Nix; the per-user `nix.conf` story deferred to M4 ADR | |
| 69 | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings` | clean | |
| 70 | +| `cargo fmt --all --check` | clean | |
| 71 | +| `pearlite-audit check .` | 1 check, 0 violations | |
| 72 | +| End-to-end smoke | `apply` with HM-enabled user records `[[managed.user_env]]`; second apply with drifted `home.nix` upserts the row | |
| 73 | + |
| 74 | +## What went well |
| 75 | + |
| 76 | +- **Trait-first compounded.** Each W1 adapter (HomeManagerBackend, |
| 77 | + NixInstaller) shipped alongside its mock, so by the time W2's |
| 78 | + engine wiring (PR #47) needed them, the test surface was already |
| 79 | + built. Engine integration tests for the new dispatch arm were |
| 80 | + one-liners. |
| 81 | +- **The "diff is pure, engine does I/O" split held under pressure.** |
| 82 | + `pearlite-diff::plan` learned to emit user-env actions without |
| 83 | + acquiring a single `std::fs` import; the hash computation lives |
| 84 | + entirely in the engine. ADR-001 / Plan §6.3 invariant intact. |
| 85 | +- **Action enum scaling stayed linear.** Adding `UserEnvSwitch` was |
| 86 | + one variant in `action.rs`, one arm in each of `phase.rs` / |
| 87 | + `coherence.rs` / `order.rs`, one arm in `compose.rs`, one arm in |
| 88 | + the engine's `exec_action`. No dispatch table, no trait-object |
| 89 | + indirection. The "one variant per primitive" discipline from PRD |
| 90 | + §8.1 paid out again. |
| 91 | +- **`sha256_dir`'s framing format caught a subtle bug at design |
| 92 | + time.** First sketch hashed `<rel-path><content>` without a |
| 93 | + separator; the determinism test exposed that a file's tail could |
| 94 | + collide with a sibling's path bytes. Adding the `0x00` framing was |
| 95 | + a four-line fix and the test went green. Worth the extra structure. |
| 96 | +- **Upsert-by-user is correct from day one.** PR #50's |
| 97 | + `upsert_user_env_records` got the schema right on the first try |
| 98 | + (one row per user, replace not append). The follow-on test |
| 99 | + `user_env_record_upserts_on_re_apply` would have caught any |
| 100 | + regression but didn't have anything to catch. |
| 101 | + |
| 102 | +## What didn't |
| 103 | + |
| 104 | +- **`LiveNixInstaller` shipped without a caller.** PR #45 lands the |
| 105 | + whole adapter + ADR-004 mechanism, but no place in the apply path |
| 106 | + invokes it yet. The apply-time bootstrap story (where does the |
| 107 | + installer script come from? Operator-supplied? Baked-in pinned |
| 108 | + hash?) needs an ADR and a CLI surface, neither of which fit in M3 |
| 109 | + without scope creep. Deferred to M4 W1. |
| 110 | +- **`vm-09` (Nix bootstrap) deferred for the same reason.** Without |
| 111 | + a wired caller for `LiveNixInstaller`, there's nothing for vm-09 |
| 112 | + to assert against beyond the unit-test surface. Pulled forward to |
| 113 | + the M4 W1 chunk that wires the installer. |
| 114 | +- **Per-user `nix.conf` handling skimped.** Plan §7.4 W1 lists |
| 115 | + "runuser drop wrapper; per-user `nix.conf` handling" — runuser is |
| 116 | + done (LiveHmBackend), `nix.conf` provisioning isn't. For |
| 117 | + `experimental-features = nix-command flakes` to be set per-user |
| 118 | + on first-time HM, we'd need to write into each `~/.config/nix/` |
| 119 | + before the switch. That's mutating user-owned files from root, |
| 120 | + which deserves an ADR rather than ad-hoc engine code. Marked `[~]` |
| 121 | + in TODO.md and called out in Open Items below. |
| 122 | +- **Engine apply-time arity climbed again.** `apply_plan` is now 9 |
| 123 | + positional parameters, even with the `clippy::too_many_arguments` |
| 124 | + allow. The CLI already used `ApplyOpts`; the engine should follow |
| 125 | + the same pattern. Slated for M4 alongside the installer wiring. |
| 126 | +- **Two test-fixture patches I'd rather have seen up-front.** The |
| 127 | + PR #45 `nix_already_installed_short_circuits` test relied on |
| 128 | + `/bin/true`, which NixOS doesn't ship — hit on first push, fixed |
| 129 | + by writing a local stub script. The PR #47 `apply_plan` rewire |
| 130 | + pushed a leaked-`TempDir` change from PR #45's installer fixtures |
| 131 | + back to the surface (workspace-test race). Both were one-line |
| 132 | + follow-ups but suggest authoring tests in nix-shell rather than |
| 133 | + on a known-good Linux box would catch these earlier. |
| 134 | + |
| 135 | +## Open items rolled forward |
| 136 | + |
| 137 | +| Item | Where logged | Resolution target | |
| 138 | +|---|---|---| |
| 139 | +| `LiveNixInstaller` apply-time wiring + bootstrap config story | M3 retro + PR #45 §"Engine integration" | M4 W1 ADR + chunk. | |
| 140 | +| `vm-09-nix-bootstrap.sh` | M3 retro + Plan §7.4 W2 | M4 W1 alongside the installer wiring. | |
| 141 | +| Per-user `nix.conf` provisioning | M3 retro + Plan §7.4 W1 | M4 ADR + chunk; needs the bootstrap config story to land first. | |
| 142 | +| `apply_plan` parameter struct (mirror of CLI's `ApplyOpts`) | M3 retro | M4 W1 cleanup. | |
| 143 | +| `forgotten_count` field on `Plan` for accurate prune threshold | M2 retro + ADR-0011 §4 | M4 alongside `[remove]` policy emission. | |
| 144 | +| Per-command envelope-shape freeze | M2 retro + PR #38 callout | M5 W1 (CLI schema export). | |
| 145 | +| Fork-based atomic-write crash tests in `pearlite-fs` | M1 retro + Plan §6.4 stub | Still TODO; M4 cleanup. | |
| 146 | +| 1 MB `state.toml` parse benchmark | M1 retro + Plan §6.2 | Still TODO; M4 cleanup. | |
| 147 | +| `KernelInfo` populated from `uname` / `/proc/version` | M1 retro | Still TODO; M4 cleanup. | |
| 148 | +| Backfill ADR-001..007 as standalone files | M2 retro + Plan §13 | M6 polish. | |
| 149 | + |
| 150 | +## Actions for M4 |
| 151 | + |
| 152 | +Per Plan §7.5 (Reconcile and Codify), M4's main thrust is the |
| 153 | +reverse-direction flow — `pearlite reconcile` emits |
| 154 | +`hosts/<host>.imported.ncl` from a probed system, and `pearlite |
| 155 | +codify` writes the deterministic sidecar that lets `state.toml` and |
| 156 | +the user's Nickel config stay in sync. Three concrete M3 carry-overs |
| 157 | +ride along: |
| 158 | + |
| 159 | +1. **Wire `LiveNixInstaller` into `apply_plan` as phase-0 |
| 160 | + bootstrap** (or as `pearlite bootstrap`, depending on the ADR). |
| 161 | + Includes the config-hash story for the installer script — operator |
| 162 | + supplies SHA in the host file or via a top-level |
| 163 | + `bootstrap.toml`. |
| 164 | +2. **`apply_plan` parameter struct.** The 9-arg signature is past |
| 165 | + the comfort line; cargo-clippy will keep escalating. Mirror |
| 166 | + `ApplyOpts` from the CLI side. |
| 167 | +3. **Per-user `nix.conf` ADR**. Decide whether Pearlite writes |
| 168 | + `~/.config/nix/nix.conf` from a declared block, ignores it (HM |
| 169 | + has its own `nix.settings`), or some hybrid. |
| 170 | + |
| 171 | +## Appendix: PR list |
| 172 | + |
| 173 | +| PR | Title | Crate(s) | |
| 174 | +|---|---|---| |
| 175 | +| #44 | feat(userenv): HomeManagerBackend trait + LiveHmBackend + MockHmBackend | userenv | |
| 176 | +| #45 | feat(userenv): NixInstaller trait + LiveNixInstaller + MockNixInstaller | userenv | |
| 177 | +| #46 | feat(diff): UserEnvSwitch action + UserEnv apply-phase | diff | |
| 178 | +| #47 | feat(engine): apply_plan dispatches UserEnvSwitch via HomeManagerBackend | engine, cli | |
| 179 | +| #48 | feat(diff): user_env classifier emits UserEnvSwitch on first-apply or drift | diff | |
| 180 | +| #49 | feat(fs,engine): sha256_dir + Engine::plan computes per-user HM hash | fs, engine | |
| 181 | +| #50 | feat(diff,engine): UserEnvSwitch.config_hash + state.toml [[managed.user_env]] | diff, engine | |
| 182 | +| #51 | test(vm): vm-07 user-env apply + vm-08 user-env drift | tests/vm | |
0 commit comments