diff --git a/00-META/checks/README.md b/00-META/checks/README.md index d10dde39..50e6ba0c 100644 --- a/00-META/checks/README.md +++ b/00-META/checks/README.md @@ -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 diff --git a/00-META/checks/words.py b/00-META/checks/words.py index 7d972103..bc4450ef 100644 --- a/00-META/checks/words.py +++ b/00-META/checks/words.py @@ -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: ` `, ` …` 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"(?", # 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 diff --git a/00-META/glossary.md b/00-META/glossary.md index 40f06426..f9292dd4 100644 --- a/00-META/glossary.md +++ b/00-META/glossary.md @@ -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 diff --git a/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 b/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 index 403b8084..e428bf15 100644 --- a/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 +++ b/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 @@ -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 ` means +> `mesh-cli ` — 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 ` 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 diff --git a/03-DESIGN/01-to-be/09-the-node-lifecycle.md b/03-DESIGN/01-to-be/09-the-node-lifecycle.md index b3b217d6..d84620bb 100644 --- a/03-DESIGN/01-to-be/09-the-node-lifecycle.md +++ b/03-DESIGN/01-to-be/09-the-node-lifecycle.md @@ -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 @@ -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 diff --git a/03-DESIGN/01-to-be/37-the-operators-machine.md b/03-DESIGN/01-to-be/37-the-operators-machine.md index 83e728ff..88c2a1f7 100644 --- a/03-DESIGN/01-to-be/37-the-operators-machine.md +++ b/03-DESIGN/01-to-be/37-the-operators-machine.md @@ -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 ` 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 diff --git a/04-ISSUES/343-the-controllers-terminal-has-no-command/00-report.md b/04-ISSUES/343-the-controllers-terminal-has-no-command/00-report.md index 622325b3..81b68b7d 100644 --- a/04-ISSUES/343-the-controllers-terminal-has-no-command/00-report.md +++ b/04-ISSUES/343-the-controllers-terminal-has-no-command/00-report.md @@ -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 ''` and -`mesh-controller node agent-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 ''`" and +"`mesh-controller node agent-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 diff --git a/04-ISSUES/343-the-controllers-terminal-has-no-command/01-diagnosis.md b/04-ISSUES/343-the-controllers-terminal-has-no-command/01-diagnosis.md index 9c1147f4..6e4051c9 100644 --- a/04-ISSUES/343-the-controllers-terminal-has-no-command/01-diagnosis.md +++ b/04-ISSUES/343-the-controllers-terminal-has-no-command/01-diagnosis.md @@ -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 ` at the terminal mean `mesh-cli ` 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 diff --git a/04-ISSUES/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel/00-report.md b/04-ISSUES/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel/00-report.md new file mode 100644 index 00000000..0c9b4e29 --- /dev/null +++ b/04-ISSUES/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel/00-report.md @@ -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 ` 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 ` 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 ` 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 ` 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). diff --git a/04-ISSUES/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel/01-diagnosis.md b/04-ISSUES/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel/01-diagnosis.md new file mode 100644 index 00000000..22390216 --- /dev/null +++ b/04-ISSUES/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel/01-diagnosis.md @@ -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 ` (`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..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 ` 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..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.