Skip to content

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

Closed

Description

@rbuergi

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:

  1. 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 at MeshExtensions.cs:~6295.
  2. A typed UpsertFold descriptor on the request naming field paths and a combine rule (sum, max, min, first-write-wins).
  3. A registered server-side merge strategy keyed by NodeType, so UserActivity declares 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 BaseStateTimeoutException

It 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 CreateOrUpdateNodeRequest exists 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 CreateOrUpdateNodeRequest that (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-392 no longer chooses its write from a query result.
  • The CreateOrUpdateNodeRequest summary'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

  1. added
    enhancementNew feature or request
    area:dataCore subsystem: data - node content, update pipeline, contexts
    on Sep 20, 2026
  2. rbuergi commented on Sep 20, 2026

    @rbuergi
    ContributorAuthor

    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
    .

    # Pattern Authoring surface Crosses a boundary?
    1 Lambda expression live => live with { … } C#, fluent not as a delegate — as an expression tree
    2 JSON Patch + our text-splice extension JSON yes — this is the wire format
    3 Full entity JSON or C# yes — one-way sync sources only
    4 Other — a finding, not a category

    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 the
    wire."
    True of a delegate; not true of an Expression<Func<T,T>>. The tree is analysable, and
    what it lowers to is patch ops. So the caller authors the clear shape, the ops travel, and the owner
    applies them inside the Update lambda it already runs at MeshExtensions.cs:~6325. Read in
    reverse, 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
    Content is already folded owner-side. Content is 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 are
    138 references to CreateOrUpdateNodeRequest and zero that set Patch. So acceptance
    criterion 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, and AGENTS.md +
    .claude/skills/mesh-data, where "skip the check and use CreateOrUpdateNodeRequest" now carries
    the 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.EditContent already 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:dataCore subsystem: data - node content, update pipeline, contextsenhancementNew feature or requestfeature:node-upsertFeature: node upsert

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions