Issue 356: the subject proves nothing, the signature does; the apply refuses a link at use
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed

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.
This commit is contained in:
jochen
2026-10-09 19:45:54 +02:00
parent 858e5413fa
commit 19776115ef
3 changed files with 32 additions and 13 deletions
@@ -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
@@ -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 <node> <directory>` on the control-node, and never the engine's binary; the condition's
@@ -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 <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
the node's engine on `mesh.node.<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'