From 19776115efce0b3810d58edb502d527fb15a03a9 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 19:45:54 +0200 Subject: [PATCH] Issue 356: the subject proves nothing, the signature does; the apply refuses a link at use The review of 2026-10-09 proved a responder can reach the hand-over's subject through a reply, and that the apply followed a link the hand-over had refused. The record no longer says only the controller may publish there: it says the ask is signed with the mesh's key and verified by the engine, and that a handed-over directory is changed through a descriptor that followed no link. To-be 45's writers table gains the row for a hand-over asked of a machine. --- .../45-a-core-that-cannot-fail-silently.md | 4 ++- .../00-report.md | 27 ++++++++++++------- .../01-diagnosis.md | 14 +++++++--- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md b/03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md index 6a593c44..ae0ae8d2 100644 --- a/03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md +++ b/03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md @@ -72,6 +72,7 @@ Every kind of state the core keeps, and the one component that writes it. Anyone | State | Writer | Kept in | Others | |---|---|---|---| | a machine's declaration | controller (lease holder) | the bus, last per subject | read | +| a hand-over asked of a machine ([issue 356](../../04-ISSUES/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel/00-report.md)) | controller, at its terminal | the machine, beside its state | the engine verifies the mesh's signature and records it, or refuses | | a machine's applied state and its report | the node-engine's apply queue (§6) | the machine; the report on the bus | the reconcile and a delivery *enqueue*, never apply | | the controller lease | the controller instance holding it | key-value `mesh-controller_lease` | a candidate waits | | plans and their tiers | controller (lease holder), compare-and-set on the plan's revision | the controller's store | read through `plans` | @@ -96,7 +97,8 @@ this table marks it shared. Changing a writer is a change to this table, through **How it is enforced** ([ADR 0229](../../02-DECISIONS/0229-the-cores-order-is-a-lease-the-store-remembers-and-an-epoch-a-machine-is-sent-once-it-reads-one.md)): the table is compiled into the controller, each row the bus carries with the subjects a write of it -publishes to — a declaration `mesh.node.*.declare`, a report `mesh.control.*.report` (its own machine's +publishes to — a declaration `mesh.node.*.declare`, a hand-over asked `mesh.node.*.ask.hand-over` (signed as +well, because a responder can reach a subject through a reply, which no grant refuses), a report `mesh.control.*.report` (its own machine's only), the controller's buckets, stream creation, update and deletion and durable consumer creation, a build's outcome, a merge announced, a provider's standing — and its writer among the principals. A grant that overlaps another writer's subject is refused at composition, naming the state and its 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 index 0c9b4e29..b5a5c89a 100644 --- 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 @@ -87,15 +87,24 @@ So two faults, one visible through the other: 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 + separator; a node the mesh knows). The ask is a request on the machine's own subject. **The subject + proves nothing about who asked**: only the controller is granted a publish there (a row of the writers table, + held at composition), but the bus lets any principal allowed to answer reply to a message it received on the + reply subject that message named, so a tool server running as the operator's account could deliver a body there + (found by the review of 2026-10-09). So the controller **signs the ask** with the mesh's key — the node, the + path, who asked, an expiry a minute ahead and a fresh nonce, over a fixed context so it is never a declaration's + signature — and the engine verifies it with the key it verifies declarations with before it reads anything out + of it, then refuses an ask for another node, expired, good for too long, without a nonce of its own, or taken + before. Tests on the engine's side refuse each of those (unsigned, another key, signed as a declaration, the + bare ask, another node, expired, no expiry, too long, no or a short nonce, a replay) with the handler given + nothing; the controller's side holds the signature, the context, the nonce and the expiry. The node's grant gains + the subject and the right to answer what it was asked (the golden composition shows exactly that). The engine + then judges every value before it records anything — 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. **At the apply, the link is refused again at the moment of use**: a handed-over + directory must still resolve to itself, and is changed only through one descriptor opened from `/` with no + link followed on any component; a test swaps its parent, its grandparent and itself for links to a victim + tree after the hand-over, and the victim keeps its mode. 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 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 index 22390216..e8c35452 100644 --- 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 @@ -49,9 +49,17 @@ 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 +the node's engine on `mesh.node..ask.hand-over` — a request on core NATS — and prints the engine's answer, +failing on a refusal. Only the controller is granted a publish on that subject (a writers-table row), but that +does not say who the engine hears: the bus lets a principal allowed to answer reply to a message it received on +any reply subject the message named, and the tool runner, which runs as the operator's account and so as every +agent, is allowed to answer (the review of 2026-10-09 proved it on a permissioned server). So the ask is signed +with the mesh's key (node, path, who asked, a minute's expiry, a nonce; over a fixed context, so the signature is +never a declaration's), and the engine verifies it before reading anything, refusing another node's, an expired +one and a replayed nonce. The engine judges every value and records the hand-over, or refuses and records +nothing. **The apply checks again**: a component of the path may have become a link after the hand-over was +recorded, so a handed-over directory must still resolve to itself and is changed only through a descriptor opened +from `/` that followed no link. 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'