Skip to content

Commit cba4c11

Browse files
docs(m3-exit): retrospective + TODO closure (#52)
Closes M3 — User Environment (Plan §7.4). 8 PRs since m2-exit (#44#51), 303 workspace tests passing (up from M2's 267). Phase 7 is wired end-to-end: pearlite-userenv adapter (HM + Nix installer) → diff classifier emits UserEnvSwitch when HM-enabled users drift → engine dispatches the switch via HomeManagerBackend → state.toml's [[managed.user_env]] upserts the (user, generation, config_hash) triple. The next plan re-reads, sees no drift, emits no action — the loop closes. Three M3 carry-overs roll into M4: 1. Wire LiveNixInstaller as apply-time / bootstrap caller; ADR for where the installer script + SHA come from. 2. apply_plan ApplyOpts struct (9-arg signature past comfort). 3. Per-user nix.conf ADR. Other TODOs remain: forgotten_count field, envelope-shape freeze, fork-based fs crash tests, state.toml parse benchmark, KernelInfo population, ADR-001..007 backfill. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2efaad3 commit cba4c11

2 files changed

Lines changed: 190 additions & 8 deletions

File tree

TODO.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,16 @@
8484
## M3 — User Environment (2 weeks)
8585

8686
### Week 1 — Adapter and bootstrap
87-
- [ ] `pearlite-userenv` with `HomeManagerBackend`
88-
- [ ] `DeterminateNixInstaller` (hash-pinned curl|sh) + `MockInstaller`
89-
- [ ] `runuser` drop wrapper; per-user `nix.conf` handling
87+
- [] `pearlite-userenv` with `HomeManagerBackend` (PR #44)
88+
- [] `DeterminateNixInstaller` (hash-pinned curl|sh) + `MockInstaller` (PR #45)
89+
- [~] `runuser` drop wrapper; per-user `nix.conf` handling — runuser wrap shipped via `LiveHmBackend`; per-user `nix.conf` provisioning deferred to M4 alongside the bootstrap-config story
9090

9191
### Week 2 — Engine integration
92-
- [ ] Wire phase 7 into `pearlite-engine::apply`
93-
- [ ] Extend `state.toml` schema with `[[managed.user_env]]`
94-
- [ ] `pearlite-diff` surfaces HM config-hash drift
95-
- [ ] VM scenarios: `vm-07` user-env apply, `vm-08` user-env drift, `vm-09` Nix bootstrap
96-
- [ ] Tag `m3-exit`
92+
- [] Wire phase 7 into `pearlite-engine::apply` (PRs #46 / #47 / #50)
93+
- [] Extend `state.toml` schema with `[[managed.user_env]]` (PR #50; schema already shipped in M1)
94+
- [] `pearlite-diff` surfaces HM config-hash drift (PRs #48 / #49)
95+
- [] VM scenarios: `vm-07` user-env apply, `vm-08` user-env drift (PR #51); `vm-09` Nix bootstrap deferred until `LiveNixInstaller` has a wired apply-time caller (M4 W1)
96+
- [~] Tag `m3-exit` (signed tag from this commit)
9797

9898
---
9999

docs/retrospectives/M3.md

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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

Comments
 (0)