Issue 356: the operator's command ran a stale engine, and a hand-over had no channel
The engine on every node's PATH was the one a person copied at installation, a build behind every delivery; and the way out of issue 339 was a terminal act at the machine, which ADR 0272 and the operator's rule of the same day make impossible to type. The record, located in the engine, the controller and the catalogue's check, with the fixes' pull requests; ADR 0272 gains the rule as a dated insight: the operator types one command, nox, and every text addressed to the operator names a nox line. words.py now refuses a daemon's line, with --list lines for the catalogue's copy; the lifecycle and the operator's-machine designs and issue 343's quotations are reworded.
This commit is contained in:
@@ -4,8 +4,9 @@
|
||||
python3 00-META/checks/records.py structure: links, citations, supersession, topics
|
||||
python3 00-META/checks/index.py every record's topic is in the reading order, and no list of records is stored
|
||||
python3 00-META/checks/index.py --print the records, by topic, in reading order (generated, never written)
|
||||
python3 00-META/checks/words.py the glossary's retired words are not used, and no word is defined twice
|
||||
python3 00-META/checks/words.py the glossary's retired words are not used, no daemon's line is written, and no word is defined twice
|
||||
python3 00-META/checks/words.py --list tools the words the catalogue's copy must list
|
||||
python3 00-META/checks/words.py --list lines the daemons' lines the catalogue's copy must list
|
||||
```
|
||||
|
||||
Non-zero exit on any problem, so it can be a gate rather than a report.
|
||||
@@ -75,6 +76,14 @@ never checked. It also fails when a glossary head word heads two entries or is a
|
||||
a graduated research effort kept as written. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
|
||||
of the tools' retired words (`retired-words`) with the glossary.
|
||||
|
||||
It also fails on a **daemon's line** ([ADR 0272](../../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md),
|
||||
its insight of 2026-10-09): the operator types one command, `nox`, and a document names a `nox` line, never the
|
||||
controller's or the node-engine's binary followed by one of its commands. The lines are the check's own list
|
||||
(`DAEMON_LINES`); a command that is a line only with an argument after it is matched as a line is written. Looked
|
||||
for in the same documents, from 2026-10-09 for research and issues, in the document's own words — code blocks and
|
||||
spans included, quotations and block quotes taken out. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
|
||||
(`retired-lines`) with the list.
|
||||
|
||||
It failed on something real before it passed: 581 uses of retired words in 63 documents on its first run, besides the glossary itself and research 034 —
|
||||
"the host" for the node-engine in 30 designs, "control plane" in 10, the glossary's own entry for the tool runner —
|
||||
and two glossary contradictions (the "console", the deprecated broker's seat), fixed in the change that
|
||||
|
||||
+114
-13
@@ -28,9 +28,23 @@ What is enforced:
|
||||
copy the catalogue's own check keeps (mesh-catalog `retired-words`). With
|
||||
MESH_CATALOG_DIR set to a checkout of the catalogue it also fails when that copy differs.
|
||||
|
||||
lines no daemon's line in a checked document (ADR 0272, its insight of 2026-10-09). The operator
|
||||
types one command, `nox` (`mesh-cli`); the node-engine (`nox-mesh-host`) and the
|
||||
controller (`mesh-controller`) are daemons, and a person never types their names — so a
|
||||
document names a `nox` line, never `mesh-controller push` or `mesh-host hand-over`. The
|
||||
lines are DAEMON_LINES below: each daemon's commands, and which of them are a line only
|
||||
with an argument after them ("the mesh-controller module" is prose; `mesh-controller
|
||||
module check …` is a line). A line is looked for in the document's own words — code
|
||||
blocks and code spans included, since that is where a line to type is written — with
|
||||
quotations, block quotes, link targets and comments taken out. `--list lines` prints the
|
||||
list, which the catalogue's own check keeps a copy of (mesh-catalog `retired-lines`); with
|
||||
MESH_CATALOG_DIR set it also fails when that copy differs. The node-engine's install-time
|
||||
subcommands (`nox-mesh-host enrol`, `key`, `reconcile`, …) are not lines here: they are
|
||||
typed at a machine no mesh reaches yet, and the engine's usage says so.
|
||||
|
||||
Checked documents: 00-META/, 03-DESIGN/ (both layers), AGENTS.md and README.md, always; a research
|
||||
effort initiated, or an issue opened, on or after FROM. Never 02-DECISIONS/: a decision record keeps the
|
||||
words it was written with.
|
||||
effort initiated, or an issue opened, on or after FROM (on or after LINES_FROM for the lines). Never
|
||||
02-DECISIONS/: a decision record keeps the words it was written with.
|
||||
|
||||
A document the check fails on that cannot be reworded in the change that found it is named, with a date
|
||||
and a reason, in words-allowed.md beside this file. An entry past its date fails like the word itself, and
|
||||
@@ -39,6 +53,7 @@ graduated research effort, which is a record of what was said, like a decision r
|
||||
|
||||
python3 00-META/checks/words.py
|
||||
python3 00-META/checks/words.py --list tools
|
||||
python3 00-META/checks/words.py --list lines
|
||||
"""
|
||||
|
||||
import datetime
|
||||
@@ -55,6 +70,25 @@ FROM = "2026-10-07"
|
||||
|
||||
ALWAYS = ("00-META/", "03-DESIGN/", "AGENTS.md", "README.md")
|
||||
|
||||
# The daemons' lines (ADR 0272, its insight of 2026-10-09), and the day that rule began. "alone" is a
|
||||
# command that is a line by itself; "with an argument" one that is a line only when written as one.
|
||||
LINES_FROM = "2026-10-09"
|
||||
DAEMON_LINES = {
|
||||
"mesh-controller": {
|
||||
"alone": ("adopt assign broker cancel cleanup collect conditions converge doctor durations hand-act "
|
||||
"hand-acts healers identity kill licence migrate operator pause pin plan prepare push rebuild "
|
||||
"rehearse replay resume retire rollout rotate secret settings status token unassign unpin "
|
||||
"upgrade").split(),
|
||||
"with an argument": ("artifacts ask builds bus collection data delivery images mirrors module node "
|
||||
"overlay plans queue seat seats take version").split(),
|
||||
},
|
||||
"mesh-host": {
|
||||
"alone": "apply bundle enrol enroll hand-over help inventory key overlay owned profile reconcile run version".split(),
|
||||
"with an argument": [],
|
||||
},
|
||||
"nox-mesh-host": {"alone": ["hand-over"], "with an argument": []},
|
||||
}
|
||||
|
||||
NOT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Not:\*(.*)$", re.M)
|
||||
STRUCK = re.compile(r"~~([^~]+)~~(?:\s*\((hq|tools)\))?")
|
||||
IDENT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Identifier until renamed:\*(.*)$", re.M)
|
||||
@@ -134,8 +168,8 @@ def prose(text, glossary_file=False):
|
||||
return text
|
||||
|
||||
|
||||
def research_and_issues():
|
||||
"""The research efforts and issue reports dated on or after FROM, file by file."""
|
||||
def research_and_issues(since=FROM):
|
||||
"""The research efforts and issue reports dated on or after `since`, file by file."""
|
||||
out = []
|
||||
for folder, key, first in (("01-RESEARCH", "initiated", "00-overview.md"), ("04-ISSUES", "opened", "00-report.md")):
|
||||
base = os.path.join(ROOT, folder)
|
||||
@@ -144,7 +178,7 @@ def research_and_issues():
|
||||
if not os.path.isfile(head):
|
||||
continue
|
||||
date = frontmatter_field(read(rel(head)), key)
|
||||
if not date or date < FROM:
|
||||
if not date or date < since:
|
||||
continue
|
||||
for name in sorted(os.listdir(os.path.join(base, effort))):
|
||||
if name.endswith(".md"):
|
||||
@@ -152,7 +186,7 @@ def research_and_issues():
|
||||
return out
|
||||
|
||||
|
||||
def checked_documents():
|
||||
def checked_documents(since=FROM):
|
||||
docs = []
|
||||
for entry in ALWAYS:
|
||||
path = os.path.join(ROOT, entry)
|
||||
@@ -164,7 +198,7 @@ def checked_documents():
|
||||
for name in sorted(files):
|
||||
if name.endswith(".md"):
|
||||
docs.append(rel(os.path.join(base, name)))
|
||||
return docs + research_and_issues()
|
||||
return docs + research_and_issues(since)
|
||||
|
||||
|
||||
def allowed():
|
||||
@@ -223,6 +257,60 @@ def check_retired(retired, identifiers):
|
||||
return problems
|
||||
|
||||
|
||||
def lines_list():
|
||||
"""The daemons' lines as the catalogue's copy lists them: `<daemon> <command>`, ` …` when an argument must follow."""
|
||||
out = []
|
||||
for daemon, kinds in DAEMON_LINES.items():
|
||||
out += ["%s %s" % (daemon, c) for c in kinds["alone"]]
|
||||
out += ["%s %s …" % (daemon, c) for c in kinds["with an argument"]]
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def line_pattern(daemon, command, with_argument):
|
||||
"""The line whole: the daemon, white space, the command. One that is a line only with an argument is matched
|
||||
as a line is written — at the start of a line or a code span, a prompt allowed, an argument after it."""
|
||||
d, c = re.escape(daemon), re.escape(command)
|
||||
if with_argument:
|
||||
return re.compile(r"(?m)(?:^|`)[ \t]*(?:\$[ \t]*)?(" + d + r"\s+" + c + r"\s+[^\s`])")
|
||||
return re.compile(r"(?<![\w-])" + d + r"\s+" + c + r"(?![\w-])")
|
||||
|
||||
|
||||
def own_words(text):
|
||||
"""The text with what is not the document's own words blanked: as prose(), but a code block and a code span
|
||||
are kept, since a line to type is written in one."""
|
||||
if text.startswith("---\n"):
|
||||
end = text.find("\n---", 4)
|
||||
if end != -1:
|
||||
text = re.sub(r"[^\n]", " ", text[: end + 4]) + text[end + 4:]
|
||||
for step in (
|
||||
r"(?s)<!--.*?-->", # comments
|
||||
r"(?m)^\s*>.*$", # block quotes
|
||||
r"\]\([^)\s]*\)", # link targets
|
||||
r"~~[^~\n]+~~", # struck through
|
||||
r"\"[^\"\n]*\"", # "quoted"
|
||||
r"\u201c[^\u201d]*\u201d", # curly double quotes
|
||||
r"\u2018[^\u2019\n]*\u2019", # curly single quotes
|
||||
):
|
||||
text = re.sub(step, blank, text)
|
||||
return text
|
||||
|
||||
|
||||
def check_lines():
|
||||
problems = []
|
||||
patterns = []
|
||||
for daemon, kinds in DAEMON_LINES.items():
|
||||
patterns += [(daemon, c, line_pattern(daemon, c, False)) for c in kinds["alone"]]
|
||||
patterns += [(daemon, c, line_pattern(daemon, c, True)) for c in kinds["with an argument"]]
|
||||
for doc in checked_documents(LINES_FROM):
|
||||
text = own_words(read(doc))
|
||||
for daemon, command, rx in patterns:
|
||||
for m in rx.finditer(text):
|
||||
line = text.count("\n", 0, m.start()) + 1
|
||||
problems.append("%s:%d: `%s %s` is a daemon's line — the operator types `nox …` (ADR 0272)"
|
||||
% (doc, line, daemon, command))
|
||||
return problems
|
||||
|
||||
|
||||
def check_unique(retired, heads):
|
||||
problems = []
|
||||
seen = {}
|
||||
@@ -253,29 +341,42 @@ def check_copy(retired):
|
||||
except OSError as err:
|
||||
return ["the catalogue's copy of the retired words cannot be read: %s" % err]
|
||||
want = tools_list(retired)
|
||||
problems = []
|
||||
if sorted(copy, key=str.lower) != want:
|
||||
return ["the catalogue's retired-words differs from the glossary: it should list %s" % ", ".join(want)]
|
||||
return []
|
||||
problems.append("the catalogue's retired-words differs from the glossary: it should list %s" % ", ".join(want))
|
||||
path = os.path.join(catalogue, "retired-lines")
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
copy = [l.strip() for l in handle if l.strip() and not l.startswith("#")]
|
||||
except OSError as err:
|
||||
return problems + ["the catalogue's copy of the daemons' lines cannot be read: %s" % err]
|
||||
if sorted(copy) != lines_list():
|
||||
problems.append("the catalogue's retired-lines differs from this check's list: regenerate it with "
|
||||
"`python3 00-META/checks/words.py --list lines`")
|
||||
return problems
|
||||
|
||||
|
||||
def main(argv):
|
||||
retired, identifiers, heads = glossary()
|
||||
if argv[1:2] == ["--list"]:
|
||||
scope = argv[2] if len(argv) > 2 else "tools"
|
||||
words = tools_list(retired) if scope == "tools" else sorted({w for w, s in retired if s in ("all", scope)})
|
||||
if scope == "lines":
|
||||
words = lines_list()
|
||||
else:
|
||||
words = tools_list(retired) if scope == "tools" else sorted({w for w, s in retired if s in ("all", scope)})
|
||||
print("\n".join(words))
|
||||
return 0
|
||||
if not retired:
|
||||
print("words: the glossary names no retired word on a *Not:* line — the check would pass on anything")
|
||||
return 1
|
||||
problems = check_unique(retired, heads) + check_retired(retired, identifiers) + check_copy(retired)
|
||||
problems = check_unique(retired, heads) + check_retired(retired, identifiers) + check_lines() + check_copy(retired)
|
||||
for p in problems:
|
||||
print(p)
|
||||
if problems:
|
||||
print("words: %d problem(s)" % len(problems))
|
||||
return 1
|
||||
print("words: %d retired words and %d identifiers, none in running prose; %d head words, each once"
|
||||
% (len(retired), len(identifiers), len(heads)))
|
||||
print("words: %d retired words and %d identifiers, none in running prose; %d daemons' lines, none written; "
|
||||
"%d head words, each once" % (len(retired), len(identifiers), len(lines_list()), len(heads)))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+5
-1
@@ -466,7 +466,11 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 0272.
|
||||
([ADR 0272](../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md)).
|
||||
- **nox** — `mesh-cli`'s alias, in interactive zsh only. A script, `sudo` and a document meant to work everywhere
|
||||
say `mesh-cli`. The operator also calls the mesh itself *nox mesh*; on this page and in the records *nox* is the
|
||||
alias alone.
|
||||
alias alone. It is the one command the operator types: every line the mesh addresses to the operator — a
|
||||
condition's words, a verb's answer, a README, a design — names a `nox` line, never a daemon's (the controller's
|
||||
or the node-engine's binary followed by a command), which `words.py` and the catalogue's check refuse
|
||||
([ADR 0272](../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md),
|
||||
its insight of 2026-10-09).
|
||||
- **channel / intake** — the two kinded benches the mesh talks to its operator through: `channel`
|
||||
sends, `intake` turns what arrives into one envelope. A holder of either declares **capabilities** from
|
||||
the fixed vocabulary `channel-capabilities/1`. Not *notifier* (that is the desktop's node seat, one
|
||||
|
||||
+21
@@ -210,6 +210,27 @@ fails if one changes alone: if the operator's account lost its passwordless `sud
|
||||
> 3. **An ordinary line is reads and settings.** §4 is exact as it stands: an ordinary line is the generic verb's
|
||||
> reading forms, or a `settings` line composed as the settings verb composes it — nothing else runs.
|
||||
|
||||
> **Progressive insight — 2026-10-09, the operator's rule.** The operator types **one** command, `mesh-cli`
|
||||
> (`nox`). `nox-mesh-host` (the node-engine) and `mesh-controller` (the controller) are daemons, and a person
|
||||
> never types their names. The consequence above — a record that says to type `mesh-controller <command>` means
|
||||
> `mesh-cli <command>` — is from this day a rule on every line the mesh addresses to the operator: a condition's
|
||||
> words, a verb's answer, a module's README, a design document name a `nox` line, never a daemon's. Found on
|
||||
> 2026-10-09 at 18:01 by the operator typing the node-engine's `hand-over` of
|
||||
> [issue 339](../04-ISSUES/339-a-verb-can-place-a-directory-at-the-machines-own/00-report.md), which a condition
|
||||
> had told them to run "at the machine": the command on the machine's `PATH` was a copy of an engine from before
|
||||
> that subcommand existed, and the act had no channel
|
||||
> ([issue 356](../04-ISSUES/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel/00-report.md)).
|
||||
> Now `nox node hand-over <node> <path>` on the control-node is that act, terminal-only, carried by the controller
|
||||
> to the node's engine over the bus. **How it is checked:** hq's `words.py` refuses a daemon's line —
|
||||
> `mesh-controller` or `mesh-host` followed by one of its commands, and `nox-mesh-host hand-over` — in the
|
||||
> documents it checks from this day, quotations excepted, code blocks and spans included; the catalogue's
|
||||
> `checks/words` refuses the same lines in every string a module's tools can show and every manifest description,
|
||||
> from its copy `retired-lines`, which `words.py --list lines` prints and compares. **What is not a line here:** the
|
||||
> node-engine's install-time subcommands (`nox-mesh-host key`, `enrol`, `overlay take`, `reconcile`, `apply`,
|
||||
> `profile`, `inventory`, `owned`, `bundle`, `version`) are typed as root at a machine no mesh reaches yet, or at
|
||||
> one no mesh can reach any more; the engine's usage says so. The daemons' binaries keep their names. The decision
|
||||
> stands.
|
||||
|
||||
### 5. What comes back
|
||||
|
||||
**Every line is a call**, run, recorded and answered as a seat's verb is: the bus lets a request be answered for a
|
||||
|
||||
@@ -223,8 +223,9 @@ The same path, with the mesh built in the middle of it.
|
||||
# 1 — raise the foundation and the controller from the carried bundle
|
||||
nox-mesh-host reconcile
|
||||
|
||||
# 2 — the controller now exists, and issues the first token
|
||||
mesh-controller token issue
|
||||
# 2 — the controller now exists, and issues the first token (nox, the operator's command,
|
||||
# at the controller's terminal: ADR 0272)
|
||||
nox token issue
|
||||
|
||||
# 3 — the machine joins the mesh it just raised
|
||||
nox-mesh-host enrol --token <token>
|
||||
@@ -618,8 +619,8 @@ rather than left to be worked out.
|
||||
**A token is issued *for* a node record**, and that is where a re-enrolment is decided.
|
||||
|
||||
```
|
||||
mesh-controller token issue --node workstation # this machine is that node again
|
||||
mesh-controller token issue --new # a machine the mesh has not seen
|
||||
nox token issue --node workstation # this machine is that node again
|
||||
nox token issue --new # a machine the mesh has not seen
|
||||
```
|
||||
|
||||
The node-engine does not need to know which it is. It presents a token and receives an identity; what
|
||||
@@ -699,7 +700,7 @@ keeps cataloguing.
|
||||
|
||||
### Where the enrolment token comes from
|
||||
|
||||
**`mesh-controller token issue` prints it once**, to the person running it. Single-use, and it
|
||||
**`nox token issue` prints it once**, to the person running it at the controller's terminal. Single-use, and it
|
||||
expires whether used or not ([ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)).
|
||||
|
||||
It is carried by hand — read off a screen, pasted into a terminal. That is the design rather than
|
||||
|
||||
@@ -113,7 +113,7 @@ resolver is asked by the second graphical module; the display server itself is g
|
||||
|
||||
## 6. The order of the build
|
||||
|
||||
1. **The operator account on every node** — `mesh-controller node` with the login name; empty on
|
||||
1. **The operator account on every node** — `nox node account <node> <login>` at the controller's terminal; empty on
|
||||
all four today. Nothing home-scoped composes before it.
|
||||
2. **The tool runner** — `mesh-host` supervises it; mesh-tools serves bundles from memberships
|
||||
and reloads; mesh-controller composes the bundle into the declaration and the memberships to one
|
||||
|
||||
@@ -23,8 +23,8 @@ The same day, read through the mesh's tools: the controller runs on the control-
|
||||
|
||||
**The records name a command that does not exist.** Issue 339, issue 340, ADR 0266 (hq pull request 225, not yet
|
||||
merged) and the agent module's README say that certain changes are made "at the controller's terminal" alone, and
|
||||
some give the line to type, such as `mesh-controller settings set claude-code '<json>'` and
|
||||
`mesh-controller node agent-account <node> <account>`. None of them says how the operator reaches that terminal, and
|
||||
some give the line to type, such as "`mesh-controller settings set claude-code '<json>'`" and
|
||||
"`mesh-controller node agent-account <node> <account>`". None of them says how the operator reaches that terminal, and
|
||||
nothing in the mesh provides it:
|
||||
|
||||
- The controller is a mesh process. The node-engine unpacks its bundle under the mesh's own directory and writes a
|
||||
|
||||
@@ -58,12 +58,12 @@ Four repositories, in the order they merge:
|
||||
and runs the line through `runVerb`'s path, without `MESH_VERB` for the terminal and with
|
||||
`MESH_VERB=mesh-cli` otherwise, after the `command` verb's refusals.
|
||||
4. **mesh-catalog:** the module `mesh-cli` (its program, its archive into `/usr/local/bin`, its zsh alias `nox`),
|
||||
assigned to every node; and the agent module's README, whose `mesh-controller settings set …` becomes
|
||||
assigned to every node; and the agent module's README, whose "`mesh-controller settings set …`" becomes
|
||||
`mesh-cli settings set …`.
|
||||
|
||||
Each code change is a branch and a pull request, merged by the operator after ADR 0272 is accepted. The records
|
||||
that tell the operator to type `mesh-controller <command>` at the terminal mean `mesh-cli <command>` on the
|
||||
control-node once phase 1 is live; ADR 0266's `mesh-controller node agent-account` line is changed on its own
|
||||
control-node once phase 1 is live; ADR 0266's "`mesh-controller node agent-account`" line is changed on its own
|
||||
branch, which is not yet merged.
|
||||
|
||||
## Later
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
---
|
||||
status: located
|
||||
opened: 2026-10-09
|
||||
located-in: [mesh-host (cmd/mesh-host, internal/upgrade, internal/link), mesh-controller (cmd/mesh-controller/nodes.go, internal/broker, internal/link), mesh-catalog (checks/words)]
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 356. The operator's command on a node ran a stale engine, and a hand-over had no channel
|
||||
|
||||
**Severity: high.** A command on every node answered for a build that was not running, and the one act a
|
||||
condition asked of the operator could not be done through the mesh.
|
||||
|
||||
## Symptom
|
||||
|
||||
On 2026-10-09 at 18:01 (local) the operator, on the control-node, did what a module's condition told them to:
|
||||
the messenger module was waiting for a person, its data directory used as found
|
||||
([issue 339](../339-a-verb-can-place-a-directory-at-the-machines-own/00-report.md)), and the condition said to
|
||||
run the node-engine's `hand-over` "at the machine", as root. They typed it, with the directory's path, and the
|
||||
node-engine answered **"unexpected argument"**.
|
||||
|
||||
Measured on that machine, right after:
|
||||
|
||||
- The command on `PATH` is the engine placed by hand at installation (`/usr/bin/nox-mesh-host`, the launcher's
|
||||
fallback). Its `help` lists `profile`, `inventory`, `apply`, `reconcile`, `bundle`, `overlay`, `owned` and
|
||||
`version`, and no `hand-over`: it is a build from before that subcommand existed.
|
||||
- The engine running the machine is a delivered one, under the engine's own versions directory, started by its
|
||||
launcher ([ADR 0141](../../02-DECISIONS/0141-the-host-delivers-its-own-successor.md)). It has `hand-over`.
|
||||
Nothing had ever written the delivered build over the one placed by hand: the launcher runs the fallback only
|
||||
while nothing is delivered, and the engine's retirement of old versions never touches it.
|
||||
- Meanwhile the messenger's process crash-looped: its data directory was owned by an account of the machine
|
||||
(uid 1000, mode 700) and left so, as issue 339 decides, while the module now runs as an account of its own.
|
||||
|
||||
So two faults, one visible through the other:
|
||||
|
||||
1. **The command the operator types on a node is not the engine that runs it.** After every delivery of the
|
||||
engine, the file on `PATH` falls one build further behind, and answers — with `help`, `version`, `owned`,
|
||||
or a refusal — for a build the machine is not running.
|
||||
2. **The hand-over of issue 339 was a terminal act at the machine with no channel.** The condition told the
|
||||
operator to go to the machine, as root, and type the daemon's name. [ADR 0272](../../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md)
|
||||
had given the operator one command, `nox`, on every node, and the operator's rule of the same day says a
|
||||
person never types a daemon's name; the way out of issue 339 was written before either.
|
||||
|
||||
## Why it is an issue
|
||||
|
||||
- **A tool that answers for a build that is not running is the lie this mesh exists to refuse.** The mesh's own
|
||||
words: evidence over assertion, failure must be loud. The file on `PATH` asserted a version, listed commands,
|
||||
and refused one that exists, with a successful-looking refusal.
|
||||
- **An act the mesh asks of its operator must have a channel the mesh provides.** ADR 0272 made "at the
|
||||
controller's terminal" a real place. "At the machine, as root" is the opposite: `ssh` and `sudo`, which the
|
||||
operator's rules forbid for a mesh machine, and which no agent may do either.
|
||||
- **The texts the mesh addresses to its operator named the daemons' lines** — the condition, the engine's
|
||||
statement, the apply's log, READMEs and design documents — and nothing checked them.
|
||||
|
||||
## Impact
|
||||
|
||||
- On every node, from its first delivered engine on, `nox-mesh-host …` typed at the machine ran a stale build.
|
||||
On the four nodes today that is a build from before `hand-over`, the tunnel take's `--tunnel`, and the
|
||||
judgements of ADR 0266.
|
||||
- The messenger module stayed down for as long as its directory stayed as found, and the only way out the mesh
|
||||
named could not be typed.
|
||||
|
||||
## The acceptance test (the operator's view)
|
||||
|
||||
- After a delivery of the node-engine proves itself on a node, `nox-mesh-host version` typed at that machine
|
||||
answers the build the launcher is running, and its `help` lists what that build serves.
|
||||
- `nox node hand-over <node> <path>` on the control-node, from the operator's own account, hands the directory
|
||||
the module's condition names to the mesh; the engine on that node says so in the answer; the condition clears
|
||||
at the node's next apply, which gives the directory its declared owner and mode. The same line through any
|
||||
verb, or from another node's `nox`, is refused and nothing is recorded.
|
||||
- No condition, verb answer, README or design document tells the operator to type the node-engine's or the
|
||||
controller's binary; `merge-check.sh` here and in the catalogue fail on one that does.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **The engine placed by hand becomes the proved build** (`mesh-host` `internal/upgrade`, `cmd/mesh-host`): on the
|
||||
evidence known-good is written on — the mesh has taken a report under this build — the engine copies itself
|
||||
over the fallback when the bytes differ; a copy, renamed into place, never a link. Tests: a stale fallback
|
||||
becomes the proved engine's bytes with mode 0755 and the running file is untouched; one already a copy is
|
||||
left as it is (same inode, no temporary file left); an engine running from the fallback itself renews nothing;
|
||||
a missing fallback is placed; a link at the fallback's path is refused and its target untouched; an
|
||||
unreadable running engine is said, not copied as nothing; the fallback's path is the launcher's variable or
|
||||
its default. Each fails with its check taken out.
|
||||
- **The hand-over is the controller's terminal's, carried to the engine** (mesh-controller
|
||||
`cmd/mesh-controller/nodes.go`, `internal/link`, `internal/broker`; `mesh-host` `internal/link`,
|
||||
`cmd/mesh-host`): `node hand-over <node> <path>` is a `node` subcommand that is not a read, so the controller's
|
||||
terminal-only rule refuses it through every verb and through `mesh-cli` outside the terminal — tested
|
||||
through `terminalOnly`, the generic command verb's composition and the ordinary mesh-cli line. The line's
|
||||
node and path are judged before anything is asked (absolute, clean, no `..`, no doubled or trailing
|
||||
separator; a node the mesh knows). The ask is a request on the machine's own subject, which only the
|
||||
controller may publish and only that machine's engine may hear and answer (the node's grant gains the subject
|
||||
and the right to answer what it was asked, and the golden composition shows exactly that); a test asks over a
|
||||
bus and reads the engine's record and its refusal back, says "not answering" for a node with no engine
|
||||
listening, and refuses an answer that is neither. The engine judges every value it is sent before it records
|
||||
anything — the ask's size, its shape, who asked (one short line), the path (absolute, clean, one its state
|
||||
records as used as found, on disk a directory and not a link, reached through no link) — and a refusal
|
||||
records nothing; nine refusals and the one acceptance are tested, and the ask's judgement separately with a
|
||||
handler that counts what it was given. The request's and the answer's field names are held by a test on each
|
||||
side.
|
||||
- **The words**: the engine's statement, the controller's wait and condition name
|
||||
`nox node hand-over <node> <directory>` on the control-node, and never the engine's binary; the condition's
|
||||
words are plain (a path is in the evidence, never in the summary). hq's `words.py` and the catalogue's
|
||||
`checks/words` refuse a daemon's line (ADR 0272, its insight of 2026-10-09); each has a test of its pattern,
|
||||
and `words.py` compares the catalogue's copy.
|
||||
- **Live, at acceptance:** after the controller and then the engine are delivered, the four nodes' journals say
|
||||
"renewed the engine placed by hand … to this build", and `nox-mesh-host version` at a machine answers the
|
||||
delivered build; `nox node hand-over <control-node> <the messenger's data directory>` answers that it is handed
|
||||
to the mesh, the messenger's condition clears at the next apply, and the module runs.
|
||||
|
||||
The diagnosis is in [01-diagnosis.md](01-diagnosis.md).
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
# Diagnosis — issue 356
|
||||
|
||||
## 2026-10-09, 18:01 — what was measured
|
||||
|
||||
On the control-node, the operator typed the node-engine's `hand-over` with the messenger's data directory and was
|
||||
answered "unexpected argument". `help` from the same command listed no `hand-over`. The launcher's journal named
|
||||
the engine it runs: a delivered build under the engine's versions directory. The messenger's unit crash-looped;
|
||||
its data directory was used as found, owned by a machine account (uid 1000) with mode 700, while the module had
|
||||
moved to an account of its own.
|
||||
|
||||
## The causes, from the code
|
||||
|
||||
**1. Nothing ever wrote a delivered engine over the one placed by hand.** The launcher (`mesh-host`
|
||||
`packaging/nox-mesh-host-launch`, delivered as the engine's module file) picks the newest delivered version that
|
||||
was not rolled back, and falls back to `/usr/bin/nox-mesh-host` — the file a person copied at installation
|
||||
([ADR 0141](../../02-DECISIONS/0141-the-host-delivers-its-own-successor.md)) — only while nothing is delivered.
|
||||
The engine's own upgrade code (`internal/upgrade`) delivers under the versions directory, proves a build by
|
||||
writing `known-good`, and retires versions older than the running one's predecessor; it never reads or writes the
|
||||
fallback. The fallback is also the one `nox-mesh-host` on any `PATH`, so from the first delivery on, what a person
|
||||
typed was a build older than what ran. Nothing said so: the engine's `version` answers from its own path, and the
|
||||
stale file answers its own.
|
||||
|
||||
**2. The hand-over was a local subcommand only.** Issue 339's way out, `hand-over <dir>` (`mesh-host`
|
||||
`cmd/mesh-host/found.go`), reads the node's state and writes `handed-over.json` beside it; the next apply reads
|
||||
that file. It had no bus side: the engine hears the mesh on its declaration subject and on
|
||||
`mesh.node.<node>.ask.report` only, and the controller had no `node hand-over`. The engine's statement, the
|
||||
controller's wait text (`module_health.go`) and the condition's "needs" line all told the operator to run it "at
|
||||
the machine", as root.
|
||||
|
||||
**3. No check held the texts to ADR 0272.** `words.py` reads the glossary's retired words; a daemon's command
|
||||
line is not a word.
|
||||
|
||||
## Ruled out
|
||||
|
||||
- A delivery that failed half way (the versions directory holding an older build): the launcher's journal named
|
||||
the delivered build, and `known-good` named it.
|
||||
- The launcher running the fallback: it does so only while nothing is delivered, and three versions were.
|
||||
|
||||
## The fix
|
||||
|
||||
**Which fix for the stale command.** Two were weighed. (a) `/usr/bin/nox-mesh-host` becomes a small program that
|
||||
runs the launcher's current version. Rejected: the launcher runs that very file as the engine while nothing is
|
||||
delivered, so a shim there would run itself; and the choice of version (newest delivered, never one rolled back,
|
||||
a rollback's pin) is the launcher's policy, which a shim would copy. (b) The proved engine writes itself over the
|
||||
file when the bytes differ: a copy renamed into place, never a link, on the same evidence `known-good` is written
|
||||
on. Chosen. The launcher's fallback is then the newest proved build, which is also the best one to fall back on,
|
||||
and the command on the machine is the engine that runs it.
|
||||
|
||||
**The hand-over's channel.** `nox node hand-over <node> <path>` on the control-node. The controller's `node`
|
||||
subcommands that are not reads are terminal-only already (ADR 0266's allow list), so the line is refused through
|
||||
every verb and through `mesh-cli` from any other node without a new rule. The controller judges the line, asks
|
||||
the node's engine on `mesh.node.<node>.ask.hand-over` — a request on core NATS, which only the controller may
|
||||
publish and only that node's engine may hear and answer — and prints the engine's answer. The engine judges every
|
||||
value and records the hand-over, or refuses and records nothing. The engine's local `hand-over` stays, for a
|
||||
machine no mesh can reach; its usage says the operator's line.
|
||||
|
||||
**The words.** Every text addressed to the operator names the `nox` line. hq's `words.py` gains the daemons'
|
||||
lines (ADR 0272's insight of 2026-10-09 says the rule and the check); the catalogue's `checks/words` refuses the
|
||||
same lines in module strings and manifest descriptions, from its copy `retired-lines`.
|
||||
|
||||
The pull requests: `mesh-host` #74 (the fallback renewed, the engine's side of the ask, its words),
|
||||
mesh-controller #187 (the `node hand-over` line, the node's grant, its words),
|
||||
mesh-catalog #165 (the catalogue's check and its copy of the lines), and this one (the record, `words.py`, the
|
||||
documents' lines).
|
||||
|
||||
**Order of delivery.** The controller first: the node's bus grant (the subject, and the right to answer) is in
|
||||
the controller's composition of the user list, and an engine delivered before it would have its subscription
|
||||
refused and say so. Then the engine. The catalogue's check and this record in any order.
|
||||
|
||||
## What remains
|
||||
|
||||
- A mesh-lab replay of the stale command (an engine delivered twice, the fallback compared) and of the hand-over
|
||||
(asked, refused through a verb, recorded through the terminal) is owed before this issue resolves: a core
|
||||
issue resolves with `replay:` ([ADR 0237](../../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md)).
|
||||
- The engine's other local subcommands (`key`, `enrol`, `overlay take`, `reconcile`, `apply`, `profile`,
|
||||
`inventory`, `owned`, `bundle`, `version`) stay install-time and rescue commands, typed as root at a machine no
|
||||
mesh reaches; the engine's usage and README say so. A `nox` line for any of them is a later decision.
|
||||
Reference in New Issue
Block a user