Repository navigation
CreateOrUpdateNodeRequest cannot express a counter increment, so the one verb with owner-side existence and the ghost-hydration repair is unavailable to the callers that need it (blocks #1174) #4928
Description
The gap
CreateOrUpdateNodeRequest is the platform's single global upsert verb — the one write path that establishes existence from a persistence read rather than the eventually-consistent query index (MeshExtensions.cs:5851-5868, trail stages UPSERT_READ absent → create / UPSERT_READ existing → update), and the one that carries the durable-truth version floor repairing "a hub that could not hydrate the stored node and started from a blank MeshNode.FromPath" (~6270, the AdoptDurableTruth rebase, #902/#638).
It cannot express a counter increment, so the callers that most need those two properties cannot use it.
Why neither payload mode works
Full-instance mode merges through UpdateAccordingToSourceNode, which takes content wholesale:
Content = sourceNode.Content ?? state.Content,The source's Content was computed by the caller from a read that is stale by construction. Any count + 1 in it is the old count plus one.
Patch mode is documented for exactly this use case — the record's own summary says:
"Used for incremental edits where multiple writers may race on the same node — log lines, view-count bumps, status-flip patterns."
But JSON Patch has no increment operation. replace /content/accessCount <value> still requires the caller to know the current value, which is the stale read again. The documented use case is not served by either mode.
There is no third route: MeshNodeStreamHandle has three Update overloads (MeshNodeStreamExtensions.cs:1246, 1268, 1355) and none takes a create-if-missing seed, so a caller cannot get owner-side folding and create-on-absent from the stream API either.
🚨 Why this is a design call and not a patch
The obvious fix — hand the upsert the same Func<MeshNode, MeshNode> merge lambda that stream.Update takes — is not available. CreateOrUpdateNodeRequest is an IRequest<CreateOrUpdateNodeResponse> routed to the owning hub; a delegate cannot cross the wire. That is why Patch is typed object? with "the patch type owned by the caller's package … handlers cast on receipt".
So the fold has to be expressed declaratively. Roughly the options:
- An increment/numeric-delta operation in the patch payload (a JSON-Patch extension, or a small typed sibling of it) applied owner-side inside the existing
.Update(live => …)lambda atMeshExtensions.cs:~6295. - A typed
UpsertFolddescriptor on the request naming field paths and a combine rule (sum,max,min,first-write-wins). - A registered server-side merge strategy keyed by
NodeType, soUserActivitydeclares its own fold once.
All three are additive. I have not picked one — the choice is a wire-contract decision.
Who is blocked by it today
#1174 (A CreateNodeRequest addressed to the sending hub itself is never answered — 425 occurrences, MeshWeaver.Graph.ActivityTracking). Its failing branch is:
// MeshWeaver.Graph/MeshNodeExtensions.cs
286: var stream = workspace.GetMeshNodeStream(activityPath);
320: workspace.GetQuery($"UserActivity|{activityPath}", $"path:{activityPath} nodeType:UserActivity …")
379: if (existing != null)
392: return AsCaller(() => stream.Update(FoldOntoLive)); // ← 30 s BaseStateTimeoutExceptionIt decides create-vs-update from the query index — precisely the shape MeshExtensions.cs:3315 (#4668) says the upsert verb exists to retire:
"A client-side existence check is precisely the shape
CreateOrUpdateNodeRequestexists to retire on the create side … The operation is where the race is decidable, because the owning hub serialises it."
It cannot move onto the verb because FoldOntoLive (:356, guarding #3001) re-reads AccessCount off the live node inside the owner-serialised lambda, and that is the thing neither upsert mode can carry.
Full source-level analysis: #1174 (comment)
Acceptance
- A counter bump can be issued as one
CreateOrUpdateNodeRequestthat (a) creates the node when absent, (b) folds the increment against the node as the owner holds it when present, and (c) is safe under concurrent writers without the caller reading first. MeshWeaver.Graph/MeshNodeExtensions.cs:379-392no longer chooses its write from a query result.- The
CreateOrUpdateNodeRequestsummary's "view-count bumps" claim becomes true, or is removed.
Filed 2026-09-19 by Claude Opus 5 from a source reading of current main — no production evidence required or claimed.
Activity
rbuergi commentedon Sep 20, 2026
The design is written down —
Doc/Architecture/ExpressingAWrite(PR #4970)This issue asked for a wire-contract decision and listed three options without picking one. The
maintainer's framing reframes it: the choice is context-dependent, and the shapes are four, not
three — and crucially option 1 and option 2 are not alternatives, they are the two ends of a
lowering.
live => live with { … }The blocker this issue states dissolves. The issue says: "The obvious fix — hand the upsert the
same
Func<MeshNode, MeshNode>merge lambda — is not available … a delegate cannot cross thewire." True of a delegate; not true of an
Expression<Func<T,T>>. The tree is analysable, andwhat it lowers to is patch ops. So the caller authors the clear shape, the ops travel, and the owner
applies them inside the
Updatelambda it already runs atMeshExtensions.cs:~6325. Read inreverse, the same relation makes pattern 2 a first-class surface: a patch arriving over MCP, the CLI
or a webhook reaches the identical owner-side path. One execution point, two ways in.
That also means no new execution point is needed — which was not obvious from this issue's
framing. The upsert's update leg is already an owner-side merge lambda; every field except
Contentis already folded owner-side.Contentis the one taken wholesale.Correction to this issue's premise
This issue reasons about patch mode's limitations. Patch mode has none, because it does not
exist: the handler refuses every non-null payload (
MeshExtensions.cs:5836,"Patch-mode upserts are not yet supported"), and across core and MeshWeaver.Plugins there are138 references to
CreateOrUpdateNodeRequestand zero that setPatch. So acceptancecriterion 3 ("the 'view-count bumps' claim becomes true, or is removed") was understated — the
summary named three use cases for a mode that serves none of them. #4970 corrects it in place rather
than deleting it, since the property is the declared shape the design lands in.
What #4970 does and does not do
Does: commits the design; makes the limit visible on every caller-facing surface where the
choice is actually made — agent tool / MCP (
Update,Patch,EditContent), the CLI (memex update/memex patch+ README), the request contract, andAGENTS.md+.claude/skills/mesh-data, where "skip the check and useCreateOrUpdateNodeRequest" now carriesthe fold exception so the advice stops leading into #1174.
Does not: implement the lowering. No write behaves differently. This issue stays open for the
implementation, and #1174 stays blocked on it.
Also worth recording — the second text-splice question
Pattern 2's extension needs two addressing schemes and they are not interchangeable. Anchored
(
oldText→newText) is safe always, because the anchor is re-verified against live text —that is what
MeshOperations.EditContentalready does. Positional (row/column, or index + length)is legal only carrying a base fingerprint the owner checks: a bare position is stale the moment
anyone inserts a character above it, and the splice then lands in the wrong place without
erroring. That is the only silent-corruption shape in the design, so it is called out as such.
Design recorded 2026-09-20 by Claude Opus 5, from a source reading of current
main.fix(onboarding): the learning path is a create-time seed — a re-run k…