The operator's proposal: one module, holding a mesh seat node-environment, is the only writer of the account's environment. It renders contributed variables and PATH entries as a POSIX file shells source and as environment.d for the graphical session. The shell's contribution shrinks to shell code; the shell render-then-service-manager-again positions become options weighed against it.
15 KiB
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 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:
.zshenvfor 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
PATHentry 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). 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:
- the line sourcing the environment module's file (in
.zshenv, so it runs for every zsh; the rest of this list is.zshrc); - the
firstslot; - the shell module's own defaults;
- the
normalslot; - the
lastslot.
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/receivesshape: facts in the mesh's own format, rendered by the receiver). - Its own code writes the POSIX file and the
environment.dfile 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
PATHrules (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-environmentis new, and is the mesh's from the start.login-shellmoves 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
PATHentry is a visible gap, not a broken machine. - Whether the operator's own variables (the script-library paths in 01) 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
executeverb 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.