--- layer: to-be status: in-progress code: [mesh-host, mesh-controller, mesh-catalog] updated: 2026-10-04 decisions: - 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md - 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md - 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md - 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md - 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md - 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md --- # 41. The shell and the account's environment What it takes for the operator's shell to be modules, without a machine losing anything it does today. This design replaces the shell half of [to-be 38](38-building-the-operators-machine.md) WP5, and finishes the service-manager module of WP6 short of its user-scoped units. The decisions are [ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md) (the environment), [ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md) (shell code and the seat) and [ADR 0205](../../02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md) (vendored software). The evidence is [research 025](../../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md). ## What a node with a shell looks like ``` toolchain, agent, … prompt, plugins, version manager │ environment │ shell (zsh, slot) ▼ ▼ node-environment ── holds ── node-env node-login-shell ── holds ── zsh │ │ ├─▶ ~/.config/mesh/environment.sh ◀─ sourced from zsh's block in ~/.zshenv └─▶ ~/.config/environment.d/50-mesh.conf ◀─ read by the account's service manager │ ~/.zshrc: the mesh's block FIRST — defaults and the three slots — then the operator's own lines, kept ``` **The environment module** (`node-env`) holds `node-environment`. It has no package and no process. Its two files are written by the host from placeholders the controller fills. **The shell module** (`zsh`) holds `node-login-shell`. It: - installs the package; - sets the login shell through the `user` shape; - writes two blocks: - one in `.zshenv`, sourcing the environment; - one at the start of `.zshrc`, holding the defaults every machine shares today: the title, the keybindings, the aliases and the two small functions, with the three slots in place; - contributes its own environment: the editor, the configuration home, and `~/.local/bin` plus the two script directories on `PATH`; - serves `execute` and its own `zsh_config`. **The prompt module** (`powerlevel10k`) ships the theme as a pinned vendored archive, and the prompt's configuration as its own file in a directory it owns. It contributes the zsh code that loads both. **Two plugin modules** (`zsh-autosuggestions`, `zsh-syntax-highlighting`) each install their distribution package and contribute one line. Syntax highlighting goes in the `last` slot, which is what its upstream asks for. **What stays the operator's** is everything below the block in `.zshrc`, and `~/.zshrc.local`, which the operator's lines source as they do today. On every machine today that means: - the version manager's lines and the toolchain's `PATH` entry, until those modules exist; - the two variables naming the operator's own script library; - the agent's title variable; - the port aliases; - the workstation's desktop variables, which live in `~/.zshrc.local` already. Nothing is lost at any step, because a line moves out of the operator's part only when a module carries it. **The migration is a person's act**, listed in the zsh module's documentation (ADR 0182): after the first push, delete from `.zshrc` the lines the block now carries. Until then they run twice, which is harmless and visible. ## Work packages ``` WP1 the host gives a login back (mesh-host) issue 228 WP2 the controller composes environment and shell code (mesh-controller) WP3 the modules (mesh-catalog) needs WP2 to resolve WP4 the service manager's module, finished (mesh-catalog) independent WP5 assign and prove (operator-gated) needs WP1–WP3 merged and rolled ``` WP1, WP2 and WP4 are independent, and are built in parallel on one feature branch per repository ([playbook 07](../../00-META/process/07-feature-branches.md)). WP3 is written in parallel and proven against WP2's controller before anything is published. ## WP1 — The host gives a login back *mesh-host. Half a day. [Issue 228](../../04-ISSUES/228-a-login-the-mesh-set-is-never-given-back/00-report.md).* **What changes.** - The `user` shape records, in its applied record, the login shell it found whenever it changes it. - Removing a `user` never deletes the account, whether or not the mesh created it. If the account's shell is still the one the mesh set, and the recorded shell is still executable, the recorded shell is set back. Otherwise the shell is left as it is, and the outcome says why. - Before a shell is set, it is refused unless it is executable and listed among the machine's shells. The exception is a shell that refuses logins (`nologin`, `false`): the distribution does not list those, and the controller's own account uses one, so it need only be executable. The refusal fails that resource and leaves the account untouched. - A directory the host creates on the way to a file, a block or an archive inside an account's home belongs to that account, the home itself included when the host makes it. A directory that was already there keeps its owner and mode (ADR 0182). Until this, a fresh account's `~/.config` or `~/.local/share` would have been created as root's. - Giving the shell back is reported, never fatal. A failed `usermod` on removal is named in the outcome and the record is dropped, because a fatal removal is exactly the wedge issue 228 is about. **Proof.** The host's tests: - an undeclared `user` no longer stops the apply; - the found shell comes back; - a shell changed by a person since is left alone; - a missing shell is refused before `usermod` runs; - a created account survives its removal. ## WP2 — The controller composes environment and shell code *mesh-controller. One to two days.* **What changes.** - **The seat table.** It gains `node-environment` (node scope, no verbs) and `node-login-shell` (node scope, the verb `execute`). A module may no longer declare a seat named `login-shell` or `node-login-shell`. Both new seats are seeded into a live store by the existing additive seeding. - **The manifest.** It gains two contribution fields, each refused at parse when malformed: - `environment`, with `variables` and `path`: a variable name must be a POSIX name and not `PATH`; a value may not contain `$`, a quote, a backslash or a line break; a path entry's place is `start` or `end`; - `shell`: each entry names a known shell, a known slot, and non-empty code. - **Composition.** It fills `${environment:posix}`, `${environment:systemd}` and `${shell::}` in the claiming holder's file contents, from every module assigned to the node. The rendering is ADR 0203's and ADR 0204's: module order, a naming line per contribution, `PATH` entries added only when missing. `${machine:…}` in a contributed value is resolved first. - **Refusals.** A variable set by two modules on one node is refused, naming both. A placeholder in a module that does not claim the matching seat is refused, both at the catalogue check and at composition. **Proof.** The controller's tests: - both environment renderings, byte for byte, from a fixed set of contributions; - the POSIX rendering sourced twice by `sh` leaves `PATH` unchanged; - slot order and per-shell filtering; - each refusal, by name; - the seat table carries both seats and refuses a module declaring either. The catalogue check over the whole catalogue passes. ## WP3 — The modules *mesh-catalog. One day.* **What changes.** - **`node-env`, new.** It claims `node-environment` and declares two owned files: the POSIX file at the path the seat fixes, and the service manager's file, each holding its placeholder. It declares no tools. - **`zsh`, rewritten.** - It drops its seat declaration and claims `node-login-shell`. - Its environment moves to a contribution. - It writes a `.zshenv` block that sources the environment file. - Its `.zshrc` block goes at the start and carries today's shared defaults, with the three slots. - It keeps the `user` shape. - `execute` runs `zsh -lc` in the account's home, with the runtime's session words for the user manager. Its timeout is bounded below the runtime's thirty-second call limit; its output is cut at a bound and marked as cut; on timeout it kills the process group. - Tests cover the tool over real child processes and the manifest's shape. - Its documentation lists the one-off migration. - **`powerlevel10k`, new.** - The theme is vendored at a pinned upstream release, with its licence, as an archive artifact unpacked into the module's directory under the account's home. - The prompt configuration is today's file, as its own owned file in the same directory. - It contributes the zsh code that loads the theme and the configuration. - Today's file has the instant-prompt cache commented out, so the module does not turn it on. - **`zsh-autosuggestions` and `zsh-syntax-highlighting`, new.** Each declares its package and contributes its loader from the distribution's path, in the `normal` and `last` slots. **Proof.** The controller's catalogue check over the whole catalogue passes. The modules' tests pass. A rehearsal composition for a node holding all five shows: - the `.zshrc` block with the prompt in `normal` and highlighting in `last`; - the environment file with the shell's `PATH` entries; - the service manager's file. ## WP4 — The service manager's module, finished *mesh-catalog. Half a day. From the review of 2026-10-04.* **What changes.** - System-scope `start`, `stop`, `restart`, `enable` and `disable` escalate with `sudo -n` when the runtime is not root, as the packet filter and intrusion modules do. They name a refusal by how it failed. - User scope reaches the account's manager by its runtime directory, which the runtime's environment lacks. - A failed `systemctl` is an error, not an empty list. - The package resource goes: the service manager is always present, and it collided with the network module's identical declaration on a machine running both. - `status` says whether the mesh declares the unit. The restore note is attached only to such a unit. - Tests cover a fake runner. The user-scoped units of mesh-host #72 stay to-be 38's WP6. **Proof.** The module's tests. Live, after WP5: - `node-service-manager.units` answers in both scopes on a workstation and on a server; - `restart` of a harmless unit answers `ok`. ## WP5 — Assign and prove *Operator-gated. Nothing here runs without the operator's go-ahead.* **Order.** 1. Merge WP1 and roll the host. 2. Merge WP2, and push the controller. 3. Merge WP3 and WP4, and build the new modules by hand: a new catalogue module's first build is asked for, not automatic. 4. On one server, assign `node-env`, `zsh`, `zsh-autosuggestions` and `zsh-syntax-highlighting`, and push. Then check: - `node-login-shell.execute@ command="echo $PATH"` shows the shell's entries; - `.zshrc` begins with the block; - the operator's lines follow untouched; - the environment file and the service manager's file exist. 5. The operator deletes the duplicated lines, per the zsh module's documentation. 6. The other server, then the two workstations, the workstations also with `powerlevel10k`. 7. Assign `systemd` everywhere, and prove WP4. 8. Unassign one plugin module on one machine. Its line leaves the block at the next push, and nothing else changes. The follow-up records to-be 38 names are still owed: - what a shell module assigned beside the holder does; - how a person's own environment variable is a setting rather than a line, once issue 168 closes.