The /api/mesh REST Contract

POST /api/mesh/* mirrors the MCP tools one-to-one: every verb runs one MeshOperations method and ships its result. Two hosts carry the mirror — the portal (Memex.Portal.Shared's MeshApiEndpoints) and the local sidecar (Memex.LocalMesh's LocalMeshApiEndpoints in MeshWeaver.Plugins) — and both are consumed by code that is not a language model: the grpc-web SDK, portal-next's server-side render, the memex CLI, and every e2e that reads a node back over HTTP. This page is the contract those callers may rely on.

The contract

the verb produced status body
the verb's success answer 200 application/json verbatim — a JSON document from the read verbs (get, search, query-nodes, resolve, content/list, …); the mutation verbs' acknowledgement as they write it today (Created: {path}, Updated: …, Patched: …, Deleted: …, Moved: …)
Not found: … 404 { "error": "Not found: …", "kind": "NotFound" }
Unavailable: … 503 { "error": "Unavailable: …", "kind": "Unavailable" }
Error: … 500 { "error": "Error: …", "kind": "Error" }
Refused <verb> … (a permission verdict — "do not retry", with the GUI URL to hand the user) 403 { "error": "Refused deleting …", "kind": "Refused" }

So response.ok means exactly one thing — the verb succeeded — and a caller that checks the status before parsing is correct by construction. For a read verb the 200 body is the document; for a mutation verb it is the prose acknowledgement those verbs have always written, which is a separate contract question (a JSON acknowledgement would be the next step, not a status code) and is deliberately unchanged here. The sentence is kept verbatim in error because it is the part that names the path and the cause; kind is the machine-readable half.

Two things are deliberately NOT part of this contract:

A verb's deadline that expires is a 504, not a crash

Many verbs bound a wait with an Rx .Timeout(…). If a verb lets that TimeoutException escape instead of turning it into a sentinel, the shared body behind every verb (MeshApiEndpoints.RunString) answers it like /render-area answers its own deadline:

what escaped the verb status body
TimeoutException 504 { "error": "Timed out: POST /api/mesh/<verb> did not complete in time (…)", "kind": "Timeout" }
any other exception 500, from the portal's exception handler the error page; the log line names the request (below)

The expiry is logged once, at Warning, with {RequestMethod} {RequestRoute}. Before this change the exception left as a faulted Task, and ASP.NET reported an unhandled 500 (MeshWeaver#6790). Other faults are deliberately NOT mapped. A fault that is not a deadline is a defect, and turning it into a response here would hide it. Timeout is not one of the three sentinel kinds, so the CLI treats the envelope as an ordinary HTTP failure.

The unhandled-exception line names the request

ExceptionHandlerMiddleware's own line, "An unhandled exception has occurred while executing the request.", names no request. When the exception has no frames of its own, which is always the case for an Rx timeout because Rx creates the exception without throwing it, the endpoint that failed cannot be recovered from the incident. That is how #6790 stayed undecidable. MeshWeaver.Hosting.AspNetCore.Portal.RequestNamingExceptionHandling.UseRequestNamingExceptionHandler(path) is the drop-in replacement for UseExceptionHandler(path):

The portal host's call site lives in MeshWeaver.Plugins (MemexPortalComposition.StartMemexApplication). It switches to this extension once a sealed platform set carries it.

Why the raw string was a defect, not a style

MeshOperations verbs return a string that is either a JSON document or one of three prose sentinels, and until 2026-09-13 both hosts shipped that string as 200 application/json. The comment beside it said the sentinel was "just a JSON-quoted value the client can branch on". It was never quoted: a bare Unavailable: Doc/Guide — this read reached no verdict … is not JSON.

The measured failure (MeshWeaver.Plugins#1699, Education's install e2e, 2026-09-11): the harness posted /api/mesh/get, asserted response.ok() — which passed — and then died in JSON.parse with Unexpected token 'U', "Unavailabl"... is not valid JSON, naming neither the path nor the reason. Every programmatic consumer had to parse-and-catch to tell "here is the node" from "I could not answer", and the ones that did not were failing with the least useful message available.

The fix is a status code, because that is the one signal every HTTP client already reads first. Measured across the consumers before changing it: the grpc-web SDK (rest.ts, mesh.ts), portal-next's SSR (snapshot.ts) and the CLI all check the status before sniffing the prefix, so a non-2xx lands each of them on the branch it already had for the sentinel — and none of them handled Unavailable: at all, so for that sentinel the old contract could only ever produce the parse error.

Where the one definition lives

MeshWeaver.AI.OperationSentinel (in MeshWeaver.Mesh.Operations, beside the verbs that write the sentinels) owns the three prefixes and the classification Classify(string) → SentinelVerdict? carrying the kind AND the HTTP status. The portal maps through it (MeshWeaver#4143) and the Plugins sidecar adopts the same function once a sealed set carries it (MeshWeaver.Plugins#1767), so the two cannot disagree; the prefix test is exact rather than heuristic — a JSON document begins with {, [, a quote, a digit or a keyword, never with Not found:. The verbs write the sentinels as interpolated literals at some forty sites; UnavailableMessage writes through the constant, and OperationSentinelPrefixTest ties the other two: every Error: / Not found: literal in MeshOperations.cs must classify, so a writer that drifts from the classifier is red in CI.

The CLI is the one consumer that does not link it — memex is deliberately dependency-free — so it recognises the envelope by its wire shape (error beside a kind naming one of the three) and unwraps it back to the sentence. Its own contract is therefore unchanged: the sentence verbatim on stdout, Error: to stderr with exit 1, any other non-2xx a MemexCliException.

What pins it

Memex.Portal.Shared.Test/MeshApiSentinelStatusTest executes MeshApiEndpoints.Ship through a real HttpContext and asserts the status and the bytes for all four rows — with the negative control that a document whose content mentions the words still goes out verbatim with a 200 — plus the CLI unwrap in both directions. With the classifier disabled, the three sentinel rows and the classifier case go red; the document row stays green (the mapping never touches it).

Memex.Portal.Shared.Test/UnhandledRequestIsNamedTest runs a real TestServer host over the real mesh. It checks four things:

With the TimeoutException catch removed, the first check goes red with a 500. When the callback does not replace the middleware's line, the third goes red, either on the category or because two lines were written.