Research 025 graduated: ADRs 0203–0205, issue 228, to-be 41 (the shell and the account's environment) #351

Merged
mesh-admin merged 7 commits from feat/the-shell-and-its-environment into main 2026-10-04 08:49:36 +00:00
14 changed files with 1044 additions and 8 deletions
@@ -0,0 +1,94 @@
---
status: graduated
initiated: 2026-10-04
touches:
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.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/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
- 03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md
- 03-DESIGN/01-to-be/37-the-operators-machine.md
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
became:
- 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
- 03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md
---
# 025 — How a module plugs into the operator's shell
## What is investigated
The shell module writes the mesh's part of the account's shell startup file. But the shell is not the
only module that needs a line there. A prompt theme loads itself from it. A language version manager
sets a variable and sources its loader. A toolchain puts its directory on `PATH`. A desktop module
names the browser. Today all of these sit in one hand-written file, and the shell module as written
carries some of them in its own block and loads others only "if a module placed them". Nothing says
how they get placed.
This effort asks four things:
1. **How a module contributes to the shell**: what it declares, who composes it, and in what order it
lands.
2. **Where the environment lives.** Variables and `PATH` entries are facts about the account, not
lines of one shell's syntax. They should reach every shell (interactive or not), the login shell's
`execute` verb, and programs a graphical session starts.
3. **Where the operator's own lines go,** so that assigning the shell module loses nothing the
machine does today.
4. **Which part of a file the mesh owns.** ADR 0174 calls the kept region the operator's; the host and
to-be 38 implement the inverse (the mesh owns a marked block, and everything outside it is the
operator's). The record this becomes says which.
## Why
Rolling out the shell module (to-be 38 WP5) was stopped on 2026-10-04 after a review of what assigning
it would do. Measured in [01](01-what-the-shell-file-holds-today.md):
- Every machine carries the same predecessor-written startup file, so the module's block would be
appended after its own older copy and everything would run twice.
- The block drops lines the machines rely on today.
- Nothing installs the prompt theme or the plugins the block loads.
- The `execute` verb runs a non-interactive login shell, which never reads the file the block is
written into.
The operator's direction: other modules must be able to plug themselves into the shell; the prompt
becomes its own module; assigning the shell module must lose no functionality; and the environment,
`PATH` above all, needs an answer of its own.
## What it touches
- The manifest. A contribution to the shell is either a new use of the existing `contributes` /
`receives` pair or a new gathered field like `jails` (to-be 31).
- The controller's composition, if the controller assembles the text.
- The `login-shell` seat (ADR 0176): what a holder must do with what is contributed to it, and
whether a module or the mesh declares the seat. Possibly a new seat for the environment, beside it
and beside the service manager's (ADR 0177). Research 023 asks the related question of a seat
naming the files its holder owns.
- ADR 0174's wording of the kept region, and ADR 0182's classification of the paths under a home.
- The zsh module, and the modules this makes possible: an environment module, the prompt, a version
manager, a toolchain.
## Where it stands
The operator proposed a separate **environment module**: one module, holding a mesh seat of its own,
that alone writes the account's environment. It writes a file that shells source and the service
manager's user environment, from the variables and `PATH` entries every other module contributes to
it. That is the starting position for the environment ([02](02-how-a-module-plugs-in.md) §1, option
E6). It leaves the shell's contribution as shell code only (§2), addressed to the `login-shell` seat,
which moves into the mesh's own seat set beside the new `node-environment` (§6).
Graduated on 2026-10-04 with one change from the starting positions: the controller, not the
environment module's own code, renders the environment into the module's files, so that the result
is in the declaration before a machine applies it ([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
option 6b).
## Documents
- [01 — What the shell file holds today](01-what-the-shell-file-holds-today.md): evidence.
- [02 — How a module plugs in](02-how-a-module-plugs-in.md): the options and the starting position.
@@ -0,0 +1,103 @@
# 01 — What the shell file holds today
Measured 2026-10-04 on the four machines of one installation: two servers and two workstations. All
four have the account's login shell set to zsh, zsh installed from the distribution, and a
predecessor-written startup file. The predecessor is retired, so nothing manages these files any more.
## The startup file is the same everywhere
The account's `~/.zshrc` is **byte-identical on all four machines**: 102 lines, one checksum.
`~/.zshrc.local`, which the last line of `~/.zshrc` sources, comes in **two variants**: one shared by
both servers, and one shared by both workstations. So the "per-machine" part is really a
per-*kind*-of-machine part.
The predecessor produced these from one module with two *flavors*: a prompt flavor and an
autocomplete flavor, each of which swapped in a different local file. Its install hook also:
- cloned the prompt theme and three plugins from their upstream repositories into `~/.zsh/`;
- installed fonts;
- changed the login shell.
On the workstations the theme and plugins are still on disk, left over and now owned by nothing. The
servers have none of them.
## What the 102 lines are
Sorted by who should own each line once the machine is modules:
| Lines today | What they are | Natural owner |
|---|---|---|
| `EDITOR`, `VISUAL`, `XDG_CONFIG_HOME`, `PATH` gaining `~/.local/bin` and two script directories | the account's environment | the shell's default, or the environment itself |
| `PATH` gaining a toolchain's directory | environment, for one tool | the toolchain's module |
| a version manager's directory variable plus sourcing its loader | environment *and* shell code | the version manager's module |
| two variables naming the operator's own script library | environment, the operator's own | the operator |
| a variable that turns off an agent's terminal-title handling | environment, for one tool | the agent's module |
| the terminal title hook, keybindings, `dircolors`, the `ls`/`grep` aliases, `ll`/`la`/`l`, a container-run alias, two disk-usage functions, two port aliases | interactive shell behaviour | the shell's default |
| the prompt's instant-prompt cache, the theme, the prompt's own configuration file | shell code, order-sensitive (instant prompt first) | the prompt module |
| autosuggestions, syntax highlighting (and, unloaded, an autocomplete plugin on disk) | shell code, order-sensitive (syntax highlighting last) | a plugin module, or the prompt module |
| sourcing `~/.zshrc.local` | the operator's hook | the operator |
The workstation variant of the local file adds:
- more environment: a desktop toolkit theme, a file manager's plugin list, `BROWSER`, `VISUAL`
overridden to a graphical editor, a language toolchain's binary directory on `PATH`;
- two pieces of shell code: one that pads the prompt to the bottom of the terminal under a display, and
one that sources a function file another module places;
- a hook sourcing a further per-node file.
The server variant holds only that last module-placed source line.
**Count:** a workstation runs 65 non-comment lines from the two files (53 shared, 12 local); a server
runs 54. Of a workstation's 65:
- about a quarter (15) are environment;
- about half are interactive defaults no other module cares about;
- the remaining quarter is other modules' code and hooks (a prompt, plugins, a version manager, an
agent's functions), loaded from the shell file only because there was nowhere else to put it.
## The shell module as written
The `zsh` module of to-be 38 WP5 (catalogue change, unmerged):
- writes one block, appended at the end of `~/.zshrc`, holding a subset of the shared file:
- its environment lines, minus the toolchain directory, the version manager and the agent variable;
- the title hook, keybindings and the most common aliases, minus the port aliases;
- guarded `source` lines for the theme and two plugins *if present*;
- the source of `~/.zshrc.local`.
- assigned to any of the four machines, appends that block after the identical lines already there, so
every line in it runs twice, `~/.zshrc.local` included.
- on the servers, the guarded prompt lines find nothing; nothing installs the theme anywhere.
## Which startup file reaches what
zsh's startup order, and what each path through it reads:
| started as | reads |
|---|---|
| interactive login (a console, ssh with a terminal) | `.zshenv`, `.zprofile`, `.zshrc`, `.zlogin` |
| interactive non-login (a new terminal window) | `.zshenv`, `.zshrc` |
| non-interactive login: `zsh -lc …`, what the `execute` verb runs | `.zshenv`, `.zprofile`, `.zlogin`, **not** `.zshrc` |
| non-interactive: a script, `ssh host command` | `.zshenv` only |
So an environment written into `.zshrc` reaches neither `execute` nor a script. The distribution's
system-wide login profile, which zsh's system `zprofile` sources, only ever *appends* to `PATH` when an
entry is missing. An entry the account's `.zshenv` puts first therefore survives a login.
A graphical session's programs (a launcher, a bar, a window manager's key bindings) are started from the
display manager and the service manager, not from a shell, and read none of these files. The service
manager's own place for the account's environment is `~/.config/environment.d/`. Today it holds nothing
on any of the four machines, so a program launched from the window manager does not see `PATH` entries
that a terminal does.
## What the mesh already has for "many modules, one file"
Measured over the catalogue's 69 module definitions:
| mechanism | used by | shape |
|---|---|---|
| `contributes` / `receives` | 28 contribute, 15 receive | A consumer contributes **facts** keyed by a requirement. The provider receives all of them as one file in the mesh's own format, and **renders them itself**. "The controller does not know what a reverse proxy is." |
| `listens` / `filtering` | 40 declare listens, 1 composes | The controller derives the whole firewall rule set from every module's ports and writes it where the holder asks. |
| `jails` / `jailing` | 3 declare, 1 composes | Each module supplies its jail **in the tool's own format**. The controller assembles them, sorted, into the one file the holder names. |
| `into: block` on a file | 2 | One module's marked region inside a file something else owns. Text outside the region is kept byte for byte. Placement is at the end, or at the start. |
None of these is a contribution of shell code or of environment today.
@@ -0,0 +1,197 @@
# 02 — How a module plugs in
Six questions, taken one at a time: the environment, shell code, the operator's own lines, order,
who renders, and what a contribution is addressed to. Each has the options weighed and a starting
position. The positions were set with the operator on 2026-10-04 and are what this effort tests, not
what it has decided.
## 1. The environment: variables and `PATH`
A variable or a `PATH` entry is a fact about the account. It holds whichever shell is the login shell,
and it is wanted by:
- every shell, interactive or not;
- the login shell's `execute`;
- a graphical session's programs.
[01](01-what-the-shell-file-holds-today.md) measures that `.zshrc` reaches only the first kind, and
only interactively.
| | option | for | against |
|---|---|---|---|
| E1 | Each module writes lines into the shell's rc file (today) | nothing new | misses `execute`, scripts and the graphical session; written in one shell's syntax, so a second shell module starts over |
| E2 | A module contributes environment facts (a variable and its value; a `PATH` entry and its position) **to the login shell**. The holder renders them into its shell's always-read file (`~/.zshenv` for zsh) | reaches every zsh, `execute` included; a contributor names no path and no shell | the graphical session sees none of it; the environment is tied to which module holds the shell; every shell module reimplements the same rendering |
| E3 | E2, and the service-manager holder (ADR 0177) renders the same facts a second time into `~/.config/environment.d/` | the graphical session sees the same `PATH` as the terminal | one fact set, two owners, two renderings that can disagree; the service manager's module gains a duty unrelated to managing services |
| E4 | One composed file in `environment.d` syntax, sourced by the shell with export-all | one file, two readers | ties the shell to the service manager's syntax, which is close to POSIX assignments but not equal (its `${VAR:-default}` and quoting rules differ); a value with a space breaks one reader or the other |
| E5 | Shells take the environment from the service manager's environment generator, which prints the merged `environment.d` | no file of the shell's at all | every shell depends on the service manager and starts a process on every start; the generator's output is unquoted, so a value with a space breaks it |
| **E6** | **An environment module.** A module of its own (working name `node-env`) holds a mesh seat, `node-environment`, and is the only writer of the account's environment. Every module contributes its variables and `PATH` entries to that seat. The holder writes them in each reader's format: a POSIX file of `export` lines that shells source, and the service manager's `~/.config/environment.d/` | the environment no longer depends on which shell holds `login-shell`; one owner and one rendering per format, both from the same facts; the graphical session included without the service manager's module; a contributor addresses "the environment", never a shell; the `PATH` rules (order, de-duplication) live in one module's code, where a test can hold them | one more module and seat, assigned on every node beside the shell; the `login-shell` protocol gains a duty, to source the environment file, which must be written down and checked |
**Starting position: E6.** It was the operator's proposal on 2026-10-04, and it replaces this
document's first position (E2, then E3).
- The facts are the contribution. Each format is rendered once, by the one module whose subject is the
environment.
- A shell module's part shrinks to one line in its always-read file: `.zshenv` for zsh, sourcing the
environment module's POSIX file. A bash or fish module writes the same line in its own file, and no
contributor changes when the login shell does.
Sketched, for a node with zsh, the environment module, and a toolchain:
```
toolchain ──contributes PATH entry──▶ node-environment ◀──contributes EDITOR, ~/.local/bin── zsh
│ (held by node-env)
┌──────────────────┴──────────────────┐
▼ ▼
POSIX export file ~/.config/environment.d/
▲ ▲
sourced from ~/.zshenv read by the service manager
(every zsh, execute too) (the graphical session)
```
The shell module still contributes its own environment (`EDITOR`, `XDG_CONFIG_HOME`, `~/.local/bin` on
`PATH`) as a contributor like any other; it does not write those lines itself. Once issue 168 closes,
the values a person varies become settings of whichever module contributes them (ADR 0174).
## 2. Shell code: a prompt, plugins, a version manager's loader
This *is* one shell's syntax, and order matters: a prompt's instant-prompt cache must run first, and
syntax highlighting last.
| | option | for | against |
|---|---|---|---|
| S1 | **A contribution of code for one shell** (the shell it is for, the code, a slot), gathered by the controller and placed inside the holder's block in slot order. The same shape as `jails`, which a module supplies in fail2ban's own format and the controller assembles | a contributor names no path; the order is declared and checkable; unassigning the contributor removes its code at the next composition; a node holding fish simply has no zsh code rendered, and the resolver can say so | the controller gains one more gathered field; code for a shell travels in the declaration (in the clear, so no secrets in it, as for any file) |
| S2 | **A drop-in directory**: each module places its own `~/.zsh/rc.d/NN-name.zsh`, and the shell's block sources the directory | no controller change; each file is its module's own, removed when undeclared | every contributor hard-codes a path inside the shell module's territory, against ADR 0112's spirit; order is a naming convention nothing checks; nothing ties the file to the shell actually being zsh |
| S3 | Contributions as facts the holder renders (`contributes`/`receives` proper) | one mechanism with question 1 | code is not a fact; the holder would only paste it, which is S1 with an extra file |
**Starting position: S1.** A contribution to the shell carries **only code**, for named shells, each
piece in a slot. Variables and `PATH` entries never go here; they go to the environment (§1). So a
module touching both makes two contributions:
- A prompt module contributes zsh code in the first slot, and its own configuration file is its own
owned file (ADR 0182).
- A version manager contributes its directory variable to the environment, and its loader as code
for each shell it supports.
- A toolchain contributes a `PATH` entry to the environment and nothing to the shell.
What has to be settled: what each contribution is *addressed to*. Section 6 covers that.
## 3. The operator's own lines: the "local override"
Assigning the shell module must lose nothing the machine does today. That has two halves.
**What is common is the module's default, not an override.** The startup file is identical on all four
machines ([01](01-what-the-shell-file-holds-today.md)). A line every machine has is the shell module's
default, or another module's contribution. It is not a local override that a person would keep in step
on every machine by hand. Most of today's file therefore moves into the shell module's block and into
the contributions above. Little of it stays the operator's.
**What is the operator's is everything outside the mesh's block.** The host already works this way:
- the mesh's region is the marked block;
- text outside it is kept byte for byte, and checked unchanged;
- the region is given back when the module goes.
| | option | for | against |
|---|---|---|---|
| O1 | The mesh's block at the **start** of the file; the operator's lines after it | the operator's lines run last and win, which is what an override means; already supported (`at: start`) | a file the operator later rewrites must keep the markers; the host refuses a broken pair rather than guess |
| O2 | A named operator region *inside* a file the mesh writes whole (ADR 0174's wording) | the file is entirely the mesh's except one hole | the opposite of what the host implements; a file a person already owns becomes the mesh's |
| O3 | Only `~/.zshrc.local`, sourced from the block; `~/.zshrc` the mesh's whole | one obvious place | takes over a file the person owns today; ADR 0182 classifies the shell's own file as *written into*, not owned |
**Starting position: O1.** `~/.zshrc.local` keeps working because the operator's own lines source it,
not because the mesh's block does.
The record this effort becomes corrects ADR 0174's description of the kept region as a **progressive
insight**: the decision stands (a node varies a module by settings or by the operator's own lines,
never by an edit), and only its description of which side is marked changes.
**The one-off migration** is a person's act, listed in the module's documentation (ADR 0182):
- remove from today's file every line the block or a contribution now carries;
- keep the rest below the block.
Until a prompt module and the other contributors exist, the lines they will carry stay among the
operator's own. Nothing is lost at any step.
## 4. Order
Order matters only for code. The environment is set before any code runs, because zsh reads
`.zshenv` first. `PATH` entries carry their own position (before or after the system's), which the
environment module orders, not the shell.
| | option | for | against |
|---|---|---|---|
| R1 | Numbers (`10`, `50`, `90`) | familiar | every contributor guesses a number; collisions are silent |
| R2 | **A few named slots**, `first` / `normal` / `last`, with the module name breaking ties | the prompt says `first` and highlighting says `last` because that is what they mean; the composed result is the same bytes every time | three slots may not be enough |
**Starting position: R2.** Inside the shell module's block, the order is:
1. the line sourcing the environment module's file (in `.zshenv`, so it runs for every zsh; the rest
of this list is `.zshrc`);
2. the `first` slot;
3. the shell module's own defaults;
4. the `normal` slot;
5. the `last` slot.
The operator's lines come after the block, as option O1 says.
## 5. Who renders: the controller or the holder's code
There are two different renderings, and E6 lets them be answered differently.
**The environment** is facts rendered into two fixed formats by the one module whose subject they are.
- The environment module receives the gathered contributions (the `contributes` / `receives` shape:
facts in the mesh's own format, rendered by the receiver).
- Its own code writes the POSIX file and the `environment.d` file whenever what it receives changes.
That is ADR 0182's third class, written by the module's own process, owned by the account,
atomically.
- The controller learns no shell and no service manager. The `PATH` rules (prepend or append,
de-duplicate, keep the system's entries) are ordinary code with ordinary tests.
- To settle: what runs that code when the received file changes. The candidates are a host action
that restarts on the received file, or a subscription through the runtime (ADR 0198).
**Shell code** is not facts. It is text in the shell's own syntax, assembled in slot order, which is
what the controller already does for fail2ban jails: sort the pieces and concatenate them into the
holder's region. The controller assembles; it never interprets the code. This keeps the shell module
bundle-free for its files, and keeps the composed result visible in the declaration before a machine
applies it.
## 6. What a contribution is addressed to
Under E6 there are two addressees: the environment and the login shell.
| | option | for | against |
|---|---|---|---|
| A1 | **Seats**: environment facts to `node-environment`, shell code to `login-shell`. Each seat's protocol says what its holder does with what is contributed to it | a contributor depends on a role ("the environment", "the login shell"), never on zsh or on one module; works the same for any holder | `login-shell` today is declared by the zsh module itself (ADR 0126). A second shell module may only claim it, never declare it, and the seat exists only while zsh's definition is registered |
| A2 | Requirements the modules provide (`contributes` keyed by them, as the reverse proxy is) | an existing mechanism | a contributor on a node without the provider fails to resolve, though a toolchain's `PATH` entry with no environment module is merely unwritten |
**Starting position: A1, both seats in the mesh's own seat set** beside the service manager.
- `node-environment` is new, and is the mesh's from the start.
- `login-shell` moves there from the zsh module's definition. A shell is as universal a role as a
service manager, and a protocol that now carries duties (render the shell code contributed to it,
source the environment file) should not depend on one module's registration.
Research 023 (a seat's protocol naming what its holder owns) is the general form of this: the
environment seat would own the two environment files, and the login-shell seat the shell's
startup-file region. The two efforts should not decide it twice.
## Open questions
- Whether a contribution may be conditional on a capability: the workstation-only environment (a
browser, a toolkit theme) is a desktop module's contribution, which arrives only where that module is
assigned. Measured, this may need nothing new.
- What a node without the environment module does with environment contributions: refuse them at
resolve, or leave them unwritten and say so. The position here is to say so; a missing `PATH` entry
is a visible gap, not a broken machine.
- Whether the operator's own variables (the script-library paths in [01](01-what-the-shell-file-holds-today.md))
are the operator's lines below the shell block, or a kept region of the environment module's file.
The first needs nothing new, but reaches only interactive zsh.
- How the prompt module and a plugin module divide the plugins. Packaging decides it as much as
ownership: the plugins come from upstream repositories, not distribution packages, on these machines.
- Whether the `execute` verb should read the interactive file at all once the environment is in
`.zshenv`. The position here is no: a non-interactive login shell plus the environment is what a
command needs, and the prompt's code should not run for it.
- How a contribution reaches a second shell assigned beside the holder, which to-be 38 WP5 names as the
first follow-up record. Under A1 a non-holder renders nothing, so the question becomes whether a
non-holding shell module may render contributions for interactive use.
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
# 174. A node varies a module through settings and kept regions, never through an edit
> **The mechanism changed — 2026-10-04, by [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md).** Where this record calls a kept region *a marked block in which the operator's own lines are kept*, read the inverse, which is what the host built: the mesh's region is the marked block, and every line outside it is the operator's, kept byte for byte and given back when the module goes. The decision stands: a node varies a module by settings and by the operator's own lines, never by an edit.
## Context
[ADR 0011](0011-managed-files-are-generated-never-edited.md) says a managed file is derived and an
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
# 176. The login shell is a node seat held by one shell module, and `execute` is its contract
> **The mechanism changed — 2026-10-04, by [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md).** The seat is no longer declared by the shell modules (§1). It is `node-login-shell`, in the mesh's own seat set, which a shell module claims. Its holder also places the shell code other modules contribute, and sources the account's environment ([ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)). What stands: one holder per node, the login shell set by the `user` shape and given back, `execute` as the contract, and any node may call it.
## Context
[ADR 0040](0040-what-a-module-is.md) names the shell as its example of a *shared* seat: bash, zsh
@@ -0,0 +1,131 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
---
# 203. The account's environment is one module's, and every module contributes to it
## Context
A variable or a `PATH` entry is a fact about the operator's account. A toolchain needs its directory
on `PATH`, a version manager needs a variable naming its directory, an agent needs a variable that
turns one of its behaviours off, and the shell sets an editor. Today every one of these is a line of
one shell's syntax in one hand-written startup file. [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
measured on four machines:
- about a quarter of the 65 lines a workstation runs at shell start are environment;
- written into `.zshrc`, that environment reaches only interactive zsh. It misses the login shell's
`execute` verb ([ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)),
every script, and every program a graphical session starts;
- the service manager's place for the account's environment, `~/.config/environment.d/`, holds
nothing on any machine.
The shell module as first written carried some of these lines in its own block and dropped the rest.
## Considered Options
1. **Each module writes its own lines into the shell's startup file.** Rejected: one shell's syntax,
read by one kind of start, and the same lines rewritten by every shell module.
2. **Contribute the facts to the login shell, whose holder renders them.** Rejected: the environment
then depends on which module holds the shell, every shell module renders the same facts again, and
the graphical session sees nothing.
3. **Option 2, and the service manager's holder renders the same facts a second time** into
`environment.d`. Rejected: one fact set with two owners, whose renderings can disagree, and a
duty for the service manager unrelated to managing services.
4. **One file in `environment.d` syntax, sourced by shells.** Rejected: that syntax is close to
POSIX assignment but not equal, and a value one reader accepts breaks the other.
5. **Shells read the service manager's environment generator.** Rejected: every shell start then
runs a process and depends on the service manager, and the output is unquoted.
6. **A module of its own holds the environment.** One mesh seat, held by one module per node,
whose files are the account's environment. Every module contributes facts to it, and those facts
are written in each reader's format. Chosen. It was the operator's proposal.
Within option 6, two ways to write the files:
- **a. The holder's own code renders what it receives.** This was research 025's starting position.
Rejected: the code needs something to run it whenever a contribution changes, and the result exists
only after a machine has applied and run it.
- **b. The controller renders the facts into the holder's files,** in two named formats, at
composition. Chosen. The result is in the declaration before any machine applies it, nothing has to
trigger anything, and the two formats are standards: POSIX shell assignment and the service
manager's `environment.d`. The controller learns no shell. It writes an assignment in a standard
syntax, as it already writes a fail2ban stanza a module supplied.
## Decision
**1. The environment is a node seat, `node-environment`, in the mesh's own set.** One module per node
holds it, and it is the only writer of the account's environment. The first holder is a module of its
own (working name `node-env`), with no package and no process.
**2. Any module contributes to it with `environment`:**
- **`variables`:** names and values. A name is a POSIX variable name and never `PATH`. A value is
literal; it may use `${machine:…}`, resolved first, and may not contain `$`, a quote, a backslash or
a line break. The only expansion is the mesh's own, so the two formats cannot read one value
differently.
- **`path`:** entries, each placed at the `start` or the `end` of the account's `PATH`.
**3. The holder places the rendered environment with two placeholders** in its own files:
- **`${environment:posix}`** renders lines a POSIX shell sources:
- every variable exported;
- every `PATH` entry added only if missing, so sourcing twice changes nothing.
- **`${environment:systemd}`** renders the same facts as the service manager's user environment, with
the account's existing `PATH` kept between the start and the end entries.
Each rendered line names the module that contributed it, so the file answers *where did this come
from*. Contributions are ordered by module name, and then in the order a module declared them.
**4. The seat's protocol fixes where the POSIX file is:** `~/.config/mesh/environment.sh` under the
account's home. A shell sources that path without knowing which module wrote it. The service
manager's file is `~/.config/environment.d/50-mesh.conf`.
**5. Refused at composition:**
- two modules on one node setting the same variable, both named;
- an environment placeholder in a module that does not claim `node-environment`.
A node with contributions and no holder writes them nowhere. The holder's absence is visible in the
node's assignments, and no contributor is refused for it, because a missing `PATH` entry is a gap, not
a broken machine.
## Consequences
- A shell's part in the environment is one line in its always-read startup file, sourcing the
POSIX file. A second shell module writes the same line in its own syntax, and no contributor
changes when the login shell does.
- The graphical session sees the same `PATH` as the terminal, from the same facts.
- The controller gains one gathered field and two renderers. Both are tested byte for byte, like
the jails a node composes ([to-be 31](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)).
- What a person sets for themselves stays theirs: variables of their own sit in their own lines of
their shell's file, read after the mesh's.
- **What got harder:** a value that needs another variable expanded (`$HOME`, `$XDG_CONFIG_HOME`)
must be written with the mesh's own `${machine:…}` facts, or it is refused. Expansion at shell start
is exactly what made one value mean two things in two readers.
- Once [issue 168](../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)
closes, a value a person varies becomes a setting of the module that contributes it
([ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)).
## How it is checked
| Rule | Checked by |
|---|---|
| Both renderings, byte for byte, from a fixed set of contributions | the controller's environment tests |
| Sourcing the POSIX rendering twice leaves `PATH` unchanged | the same tests, running `sh` over the rendering |
| A variable set by two modules is refused, naming both | the controller's resolve test |
| An environment placeholder outside the holder is refused | the catalogue check, which registration runs |
| A value with `$`, a quote, a backslash or a line break is refused | the manifest's parse test |
| The zsh holder sources the path the seat fixes | the catalogue's zsh test |
## References
- [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
[ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md),
[ADR 0177](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md),
[ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
@@ -0,0 +1,131 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
---
# 204. A module contributes shell code to the login shell in named slots, and the login shell is the mesh's seat
## Context
Some of what a shell runs at start is code in that shell's own syntax, and it belongs to other
modules:
- a prompt theme loads itself and its configuration;
- plugins load themselves;
- a version manager sources its loader.
Order matters: a prompt's instant-prompt cache must run first, and syntax highlighting last. The
predecessor kept all of this in one file per machine, and installed the theme and plugins by cloning
them in a hook.
[Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md) measured that file
as byte-identical on four machines. It carries:
- the shell's defaults;
- code belonging to four other pieces of software;
- a handful of the operator's own lines.
Nothing gave the other pieces a way in.
Two further facts bear on the seat itself:
- **The seat is declared by the zsh module** ([ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
§1, under [ADR 0126](0126-a-module-declares-its-own-seats.md)). The controller refuses a second
module declaring a seat name, so fish or bash could only ever claim it, and the seat exists only
while zsh's definition is registered.
- **The kept region is the other way round.** [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
describes a kept region as a marked block holding the operator's lines. The host built the inverse:
the mesh's region is the marked block, and every byte outside it is kept, verified unchanged, and
given back when the module goes.
## Considered Options
1. **A drop-in directory** that each module places a file in, and the shell sources. Rejected: every
contributor names a path inside the shell module's territory
([ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md)); order becomes a naming
convention nothing checks; and nothing ties the file to the shell actually being the one it is
written for.
2. **Facts the holder renders**, through `contributes` / `receives`. Rejected: code is not a fact,
and the holder would only paste it.
3. **Code contributed for a named shell in a named slot, assembled by the controller into the
holder's file.** This is what the controller already does for fail2ban jails: each module supplies
text in the tool's own format, and the controller sorts and concatenates it into the holder's file
without interpreting it. Chosen.
For order, numbers (`10`, `50`, `90`) were rejected: every contributor guesses one, and collisions are
silent. **Three named slots** were chosen: `first`, `normal`, `last`.
## Decision
**1. `node-login-shell` is a node seat in the mesh's own set,** with the verb `execute`. It replaces
the module-declared `login-shell`. Everything else ADR 0176 decided stands: the holder sets the
account's login shell through the `user` shape, `execute` is the contract, and any node may call it.
A shell module claims the seat; none declares it.
**2. Any module contributes shell code with `shell`:** entries naming the shell they are for (`zsh`,
`bash`, `fish`), the slot, and the code. The controller does not read the code.
**3. The holder places the code with placeholders** in its own files: `${shell:<shell>:<slot>}`. Each
is filled with that shell's code for that slot, from every module on the node:
- ordered by module name;
- each piece preceded by a line naming its module;
- empty when nothing is contributed.
A shell-code placeholder in a module that does not claim `node-login-shell` is refused.
**4. The holder's duties, which are the seat's protocol:**
- Source the account's environment ([ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md))
from the startup file every start of that shell reads. For zsh that is `.zshenv`, which a script, a
login and `execute` all read.
- Write its interactive block at the **start** of the interactive startup file, so the operator's own
lines run after the mesh's and win.
- Run `execute` as a non-interactive login shell in the account's home:
- bounded below the runtime's call limit;
- its output bounded;
- its whole process group ended on timeout.
**5. The marked block is the mesh's; everything outside it is the operator's.** This is how ADR 0174's
"kept region" is built. That record keeps its decision and gains a note saying where the mechanism
lives.
## Consequences
- A prompt, a plugin and a version manager are each a module with its own package or archive, its own
configuration file, and a contribution. Assigning one adds its line to the shell, and unassigning it
takes the line away at the next composition.
- Assigning the shell module loses nothing the machine does today:
- what is common to every machine becomes the shell module's default or another module's
contribution;
- what is the operator's stays below the block.
- **The one-off migration is a person's act**, listed in the shell module's documentation
([ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)):
delete the lines the block now carries from the found file.
- `login-shell.execute` becomes `node-login-shell.execute`. Nothing has called it yet; the shell
module was never assigned.
- **What got harder:** a module wanting a line in the shell must say which shell and which slot, and a
module supporting three shells writes its code three times. That is the honest cost of code in
three syntaxes.
## How it is checked
| Rule | Checked by |
|---|---|
| Code lands in its slot, in module order, only for its shell | the controller's shell-contribution tests |
| A shell-code placeholder outside the holder is refused | the catalogue check |
| `node-login-shell` is the mesh's, and no module may declare it | the seat table's tests |
| The zsh block sits at the start, sources the environment from `.zshenv`, and holds the three slots | the catalogue's zsh test |
| `execute` is bounded in time and output and kills its process group | the zsh module's tool tests over real child processes |
## References
- [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
[ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md),
[ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
[to-be 31](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
@@ -0,0 +1,69 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
---
# 205. Software the distribution does not package ships as a pinned archive of the module's own
## Context
The prompt theme the operator uses is not in the distribution's repositories. Its two plugins and an
autocomplete plugin are. The predecessor installed all four by running `git clone` against their
upstream repositories from an install hook. That way:
- the version on a machine was whatever upstream's default branch held the day the hook ran;
- two machines set up a week apart could differ;
- a machine with no route to upstream failed its install.
The mesh already has a pinned, delivered form for a module's own files: an **archive artifact** built
from a directory of the module's source, delivered by the artifact store, unpacked by the host's
`archive` resource, and pinned by digest. One showcase module uses it.
## Considered Options
1. **Clone from upstream on the machine,** as the predecessor did. Rejected: unpinned, unreproducible,
and it needs upstream reachable from every machine.
2. **Build from the distribution's user repository.** Rejected: the host installs packages from the
distribution's own repositories. A user-repository build is a toolchain on every machine for one
theme.
3. **Vendor a pinned upstream release into the module's directory and ship it as the module's archive
artifact.** Chosen. The release and its version are named in the module, its licence travels with
it, and every machine gets the same bytes from the mesh's own store.
## Decision
**A module whose software the distribution does not package carries a pinned upstream release in its
own source directory and ships it as an archive artifact.**
- The module's documentation names the upstream, the version and the licence.
- The host unpacks it with the `archive` resource into a directory the module owns.
- An upgrade is a change to the module, reviewed like any other.
Software the distribution *does* package is installed as a package; a vendored copy of it is
refused in review.
## Consequences
- The catalogue grows by the size of what it vendors: 1.4 MB for the prompt theme at the pinned
release.
- Upstream's security fixes reach a machine only when somebody updates the module. That is the same
trade every pinned dependency makes, and it is visible: the version is in the module.
- **What got harder:** a vendored program that downloads more at run time, as the prompt theme does
for its git status helper, still fetches that part from upstream on first use. This record pins
what the mesh ships, not what the software fetches for itself. The module's documentation says so.
## How it is checked
| Rule | Checked by |
|---|---|
| The archive is pinned by digest | the host's declaration validation, which refuses an archive without one |
| The upstream, version and licence are named | review of the module's documentation; the module's test asserts the licence file is in the archive |
| Packaged software is not vendored | review |
## References
- [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
+3
View File
@@ -301,6 +301,9 @@ python3 00-META/checks/index.py fail if stale
- **0197** — [Every tool announces itself on the bus, in the NATS services protocol](0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md)
- **0198** — [A module's long-running code is launched by the node's runtime, and reaches the bus through it](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)
- **0201** — [A module keeps its current state in key-value buckets it declares, and reaches them through the runtime](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)
- **0203** — [The account's environment is one module's, and every module contributes to it](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)
- **0204** — [A module contributes shell code to the login shell in named slots, and the login shell is the mesh's seat](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
- **0205** — [Software the distribution does not package ships as a pinned archive of the module's own](0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md)
### How it is built
+13 -8
View File
@@ -2,7 +2,7 @@
layer: to-be
status: in-progress
code: [mesh-host, mesh-controller, mesh-tools, mesh-catalog]
updated: 2026-10-02
updated: 2026-10-04
decisions:
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
@@ -33,12 +33,16 @@ This is the design [to-be 29](29-a-node-has-operator-accounts.md) §2 called *a
Worked on the first one, a shell. The `zsh` module declares:
- a **package**, `zsh`;
- **files under the home**, owned by the account: the shell's rc file with the module's default
configuration, carrying a kept region for the operator's own lines, and `${setting:…}`
placeholders for the few values a node varies; the account and its home are machine facts the
controller resolves ([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md),
to-be 29 §2);
- a **seat declaration**, `login-shell`, node-scoped, with its one verb; and a **claim** on it;
- **files under the home**, owned by the account: the mesh's block at the start of the shell's rc
file with the module's default configuration and the slots other modules' code lands in, the
operator's own lines kept after it, and `${setting:…}` placeholders for the few values a node
varies; the account and its home are machine facts the controller resolves
([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md),
to-be 29 §2). Its environment is a contribution to the environment module, not lines of its own
([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
[ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md),
[to-be 41](41-the-shell-and-the-accounts-environment.md));
- a **claim** on the mesh's node-scoped seat `node-login-shell`, with its one verb;
- a **`user` shape** naming the shell, applied only where the module holds the seat;
- a **tools bundle**, the artifact kind for interpreted code, with `execute` and the module's own
`show-config`.
@@ -80,7 +84,8 @@ root escalates itself.
## 4. The seats of the environment
Decided now: **`login-shell`** (module-declared; zsh, fish, bash; verb `execute`) and
Decided now: **`node-login-shell`** (the mesh's own, ADR 0204; zsh, fish, bash; verb `execute`),
**`node-environment`** (the mesh's own, ADR 0203; the environment module; no verbs) and
**`node-service-manager`** (the mesh's own; systemd; verbs over units in both scopes). The rest
are candidates from [research 018](../../01-RESEARCH/018-the-operators-machine-as-modules/04-the-seats-of-the-environment.md),
one record each when its first holder is written: display server, display session, terminal
@@ -15,6 +15,7 @@ decisions:
- 02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md
- 02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md
- 02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
---
# 38. Building the operator's machine
@@ -330,6 +331,13 @@ tool of each.
## WP5 — The shell, on a server first
*Replaced on 2026-10-04 by [to-be 41](41-the-shell-and-the-accounts-environment.md).* A review before
assigning found that the shell module would duplicate every machine's existing startup file, drop
lines from it, leave the prompt uninstalled, and could not be unassigned
([issue 228](../../04-ISSUES/228-a-login-the-mesh-set-is-never-given-back/00-report.md)). The shell,
its environment, the modules that plug into it, and the host's fix are built and proven there. What
follows is the original plan, kept for the record.
*mesh-catalog #224, already written. Half a day to assign and prove.*
**Order.** Assign `zsh` to one server; push; `login-shell.execute@<server> command="uptime"`
@@ -0,0 +1,238 @@
---
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:<shell>:<slot>}` 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@<server> 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.
+1
View File
@@ -43,6 +43,7 @@ document is written and this one's status becomes `implemented`.
| [`32-what-a-module-declares.md`](32-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md), [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md), superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) |
| [`37-the-operators-machine.md`](37-the-operators-machine.md) | **In progress.** Every configurable thing on a node is a module, the home included; one default per module varied by settings or kept regions; roles a machine has once as seats with tool contracts; one tool runtime per node on the host side | [ADR 0173](../../02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md), [0174](../../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md), [0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md), [0176](../../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md), [0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md) |
| [`38-building-the-operators-machine.md`](38-building-the-operators-machine.md) | **In progress.** The work of design 37 as packages: the runtime serves many modules, the controller composes one per node, the console becomes its serving mode, the packet filter moves first, then the shell and the service manager — tested on the live mesh by the operator's decision | [ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md), [0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md), [0149](../../02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md) |
| [`41-the-shell-and-the-accounts-environment.md`](41-the-shell-and-the-accounts-environment.md) | **In progress.** The shell and the account's environment as modules: an environment module every module contributes variables and `PATH` entries to, shell code contributed to the login shell in named slots, the prompt and plugins as modules, the host giving a login back, and the service manager's module finished | [ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md), [ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md), [ADR 0205](../../02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md) |
## Not yet written
@@ -0,0 +1,52 @@
---
status: located
opened: 2026-10-04
located-in:
- mesh-host
fixed-by:
amended-design:
---
# 228 — A login the mesh set is never given back, and undeclaring one stops the node applying
## What was observed
2026-10-04. Before the shell module of to-be 38 WP5 was assigned anywhere, a review traced what the
host does with the `user` shape the module declares (the operator account, with the login shell zsh)
on three events: first assign, a later push, and unassign.
1. **Undeclaring a `user` stops the node applying anything, for good.**
- The host's removal has no case for a `user`, so the orphaned record fails with "no way to remove".
- Orphans are removed before the declaration's first resource, and that failure aborts the apply.
- The record stays in the host's store, so every later apply fails the same way.
This was reproduced in a throwaway test against the host's code: the apply produced no outcomes,
and an unrelated file in the same declaration was not written. Renaming the resource's id has the
same effect. A showcase module carries a `user` today and is exposed to it too.
2. **The shell the account had is never recorded.** [ADR 0176](../../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
§2 says the host "gives back [the login shell] when the holding moves". The host keeps nothing to
give back.
3. **A login shell is set whether or not it exists.** A failed package install does not stop the
resources after it. `usermod --shell` on the distribution only warns about a missing or
non-executable shell, and succeeds. The host's read-back compares the user database's string,
which matches. So an account can be pointed at a shell that is not there, and console, ssh and
display-manager logins then fail. No machine hit this, because zsh was already installed on all
four.
## Why it matters beyond this instance
Unassigning any module with a login in it, the case the mesh promises is ordinary, wedges the
machine's applies until a person edits the host's store. It is the same class of failure as an
earlier archive that could not be removed: a shape the host can create and cannot take away.
## Located
mesh-host, `internal/apply`: `remove()` has no `user` case, and `applyUser` neither records the shell
it replaced nor checks the shell it sets. The fix is set out in
[to-be 41](../../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md) WP1:
- a removal that never deletes an account;
- the login shell given back, if it is still the one the mesh set and the recorded one still exists;
- a shell refused before it is set unless it is executable and listed among the machine's shells
(a shell that refuses logins need only be executable, since the distribution does not list it and
the controller's own account uses one).