Why an operation request can look inert
A field report on 2026-09-06 (#1320)
said the agent-facing half of operation requests does not work: a request
created through the API — the MCP create tool, a well-formed OperationRequestContent with
requestedAction: "Preview" — "was created. It never previewed. steps: [] forever, no error, no
log, nothing to grep." The decisive difference it named was the missing Script child, and the
conclusion drawn was that "the executable half is generated by the creation flow, not by the
request node's own watcher".
That conclusion is wrong, and the page you are reading exists so nobody re-derives it. The
Script child has exactly one author — OperationRequestControlPlane.Run — and the API path works.
What was true is that the control plane could stop watching, permanently and silently, and a
request whose watcher has stopped is indistinguishable from one nobody has picked up yet.
The measurement
Measured on the live memex.systemorph.com portal, 2026-09-06, with no browser involved at any
point — a request created purely through the MCP create tool
(rbuergi/Requests/probe-1320, a plan of one Note step that touches nothing):
| What | When | Elapsed |
|---|---|---|
create (requestedAction: "Preview" already set) |
16:18:01.794Z | — |
| the watcher started the preview | 16:18:01.880Z | 86 ms after the create |
state: "Ready", plan recorded, Script child written by the control plane |
16:18:02.650Z | 856 ms after the create |
patch → requestedAction: "Preview" again |
16:20:34.208Z | re-previewed in 677 ms |
patch → requestedAction: "Approve" |
16:23:48.457Z | — |
state: "Succeeded", message: "Done: 1 step(s) ran." |
16:23:49.366Z | 909 ms after the approve |
So: create → preview → approve → run, entirely through the API, sub-second at every step. The
Script child (nodeType: Code, createdBy: system-security) is generated by the node's own
watcher on the Preview/Approve transition, exactly as the issue's own suggested shape proposed
— it already worked that way.
The request in the field report tells the same story from the other side. Its version history is
v1 at 10:35:37 (the create, requestedAction: Preview), v2/v3 at 11:15:20 and 11:15:32 (the
reporter forcing Preview → None → Preview), then v4–v6 — the control plane's own three
writes, whose content timestamps read 15:59:11–12, and whose Script child carries
createdDate: 15:59:11.286Z. Nothing ran for four hours and forty-four minutes, and then the whole
preview ran in 1.3 seconds. That is not a missing mechanism. That is a mechanism that was not
subscribed, and then was.
🚨 A
MeshNodeStreamHandle.Updatedoes not re-stamplastModified, so the version rowsv3–v6all carryv3's timestamp. Read the content's ownstartedAt/previewedAtand the child node'screatedDatefor when a run actually happened — the version row'slastModifiedis the node's field, not the write's clock.
The defect: the recovery arm was itself a write
The watcher was one subscription for the life of the per-node hub:
workspace.GetMeshNodeStream()
.Where(node => node is not null)
.Select(node => Process(hub, workspace, node!)
.Catch<Unit, Exception>(exception => Stamp(workspace, node!.Path, c => c.Cleared() with
{
State = OperationRequestState.Failed, Error = exception.Message,
})))
.Concat()
.Subscribe(_ => { }, exception => logger?.LogWarning(exception, "watcher stream failed"));
The Catch looks like the guard that keeps the pipeline alive, and its comment said so. It is not:
Stamp is a cold write to the mesh, so the recovery can fail on its own. When it does, the error
leaves the Catch, Concat forwards it, and the subscription's onError runs — which ends the
sequence. From that moment the hub is up, the node is writable, patches land and bump the version,
and no action on that request is ever processed again. There is no resubscribe, and there is
nothing on the node saying so: the terminal Failed stamp is precisely the write that failed. The
only trace is a single LogWarning in one pod's log.
That end state is byte-for-byte the field report: requestedAction still Preview, state absent
(so Proposed), steps: [], scriptHash absent, no activityPath, no Script child, no error.
And it is cured by the one thing the fix does — a fresh hub. Which is what 15:59 was.
What the control plane guarantees now
OperationRequestControlPlane.Watch is the composition, extracted so the invariant is pinned by a
test rather than by review:
- A pass cannot end the watcher. The recovery gets a recovery: a failed pass is recorded on the node, and a failure of that write is reported and contained.
- Containment is not swallowing. Every arm reports. A pass that failed and was recorded logs
at
Warning(the approver can read the error on the page). A pass whose failure could not be recorded logs atError, naming the consequence — the request still reads as pending — because that is the only place the information now exists. - The subscriber's
onErrorstill means something. It is reachable only for a fault of the node stream itself, which is genuinely terminal for that hub, and it logs atErrorsaying that no further action on the request will be processed until the hub restarts. - Passes still run strictly one at a time (
Concat) and in order — they all write the same node.
Two cases in OperationRequest/Test/OperationRequestTests.cs hold it:
AFailedPass_WhoseRecordingAlsoFails_KeepsTheWatcherAlive (the trigger after a failed pass whose
recording also failed is still processed) and AFailedPass_IsRecorded_AndTheWatcherCarriesOn (the
ordinary path: recorded once, reported once, order preserved). Reverting the inner Catch alone
turns the first one red and leaves the other 26 cases green.
What this deliberately does NOT do
It does not declare an interrupted run dead. A request stamped Previewing/Running whose hub
then goes away — a portal roll, a grain deactivation, a pod eviction — is still stuck:
OperationRequestContent.ShouldRun() is false for both in-flight states, so no action, not even
Reject, is ever processed again. That is a second, independent defect and it wants a different
fix from the obvious one:
The run is not owned by the control-plane hub.
Rundispatches anExecuteScriptRequestto theScriptchild's kernel and follows the resulting activity; the kernel keeps going when the request's hub restarts. So a fresh hub that finds an in-flight state must re-attach toActivityPathand keep folding its frames — not stampFailed, which would tell the approver the deletion stopped while it is still deleting. Only an in-flight state with noActivityPathwas genuinely never dispatched.
Filing that as its own change is deliberate: getting it wrong makes the page say one thing while the mesh does another, which is worse than the wedge.
Seven other control planes carry the same shape
This is not one type's mistake — it is the shape every RequestedX watcher in the repo was written
in, copied faithfully, comment and all ("A failed transition must never wedge the pipeline"). A
source scan over every *ControlPlane.cs / *Watcher.cs outside src/ finds the recovery-arm-is-a-write
pattern in:
Store/Order |
OrderControlPlane |
Store/Provision |
ProvisionControlPlane |
Store/Enrollment |
EnrollmentControlPlane |
Store/Maintenance |
MaintenanceControlPlane |
Store/Subscription |
SubscriptionControlPlane |
Hosting/InstanceAction |
InstanceActionControlPlane |
Hosting/InstanceRequest |
InstanceRequestControlPlane |
Each is Process(…).Catch(ex => Stamp(…Failed…)) → .Concat() → .Subscribe(_ => { }, ex => LogWarning),
so each can lose its watcher to a failing write and leave its own node reading as merely pending —
an order stuck before fulfilment, a provision stuck at Requested, an enrollment that never grants.
They were left alone in the #1320 change to keep it to the type the issue is about. The fix for each
is to route it through the same Watch invariant; the reason to do them together is that a shared
helper needs a home both Store/* and Essentials/* can see (Store/Core/Source, which
Essentials/OperationRequest already shares), and moving it there is a decision about the Store
package's surface rather than about this bug.
Reproducing and measuring locally
The in-mesh test bodies run on this laptop — no Docker, no mesh — through the platform's own gate
runner, which compiles each package's Source/ and Test/ with Roslyn and executes every
*Tests case:
mw-plugin-test build <plugins-repo-root> Essentials \
--module <…>/MeshWeaver.AI.dll \
--module <…>/MeshWeaver.Markdown.Collaboration.dll \
--module <…>/MeshWeaver.Payments.Stripe.dll
The three --module arguments are not optional: without them four Store NodeTypes fail to compile
against a reference set that has no module bundles in it (MeshWeaver.Payments missing, then
StripeWebhooks/StripeGateway), Store goes RED, and every package that requires it — Essentials
included — is reported blocked, having compiled and tested nothing. A blocked package is not a
pass.