Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions .claude/skills/mesh-data/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,13 @@ place without erroring** — the one silent-corruption shape here. Use the ancho
(`MeshOperations.EditContent`: exact `oldText` → `newText`, re-verified against live text). A
positional splice is legal only carrying a base fingerprint the owner checks.

**Known gap — do not design around it, it is tracked.** `CreateOrUpdateNodeRequest` cannot express a
fold (full-instance mode takes `Content` wholesale; its `Patch` mode is declared but **refused by the
handler**, zero callers). So a caller needing *both* create-if-missing *and* a fold has no route, and
the tempting fallback — decide create-vs-update from a query, then `stream.Update` — is the
eventually-consistent-positive bug in section 2. That is core#4928, blocking core#1174.
**Create-if-missing plus a fold:** use `CreateOrUpdateNodeRequest.WithFolds<T>` (#4980).
The create leg uses the seed verbatim; the update leg applies `Sum`, `KeepExisting`, `Min` or
`Max` to the live stored content. Carry the rule and operand, never a result computed from a
query. `HandleTrackActivity` is the caller example: seed count one, `Sum(1)` thereafter, preserve
first-access time, advance last-access time. This retires #1174's query-dependent create/update
split. Folds retain the existing cross-mirror patch conflict semantics; they do not promise
cluster-wide atomic arithmetic.

### The 3 rules this unifies

Expand Down Expand Up @@ -319,12 +321,11 @@ hub.GetQuery(id, $"path:{parent} scope:children nodeType:X select:path") // EX
.Select(node => node.ContentAs<X>(hub.JsonSerializerOptions));
```

The index **trails** the store, so "the index has seen it" implies "the store has it" — the point
read opened on that signal can never be early, and never NotFounds. The same lag that disqualifies a
query for CONTENT is what makes it a safe gate. Creating the node anyway? Skip the check entirely
and use `CreateOrUpdateNodeRequest` — **unless the write is a FOLD** (a counter bump, or anything
computed off the node's current value), which that verb cannot express today (core#4928). There is
no correct shape for create-if-missing + fold yet, so say so rather than falling back to the query.
An index row can still be stale after deletion or failed hydration: "the index has seen it"
is not proof that storage currently holds it. A query can gate discovery, but the subsequent
point read must still handle absence. Creating the node anyway? Skip the check entirely
and use `CreateOrUpdateNodeRequest`. For a counter bump or another live-content fold, attach
`WithFolds<T>` rules to the seed instead of deciding create-vs-update from the query.

🚨 **This is about ONE known path whose value you are GATING on — not about node counts.** The
worked counter-example is a token chip that reads `content` out of a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,36 @@ hub.Observe<CreateOrUpdateNodeResponse>(
- The caller doesn't need to check existence — the handler reads persistence and either dispatches `CreateNodeRequest` (when missing) or applies the update branch internally via `stream.Update` (when existing). One audit log. One response shape (`CreateOrUpdateNodeResponse` with `WasCreated`).
- **Never delete-then-create.** That pattern races the per-node hub's disposal — a `GetNode` issued shortly after the create returns null because the new request hits the still-tearing-down hub. The upsert handler applies the update via `stream.Update` instead, which routes the merge patch to the live owning hub and keeps `GetNode` consistent.
- **Permissions stay specific.** Missing target = `Permission.Create` checked by the inner `CreateNodeRequest`. Existing target = `Permission.Update`, enforced on the patch path by the owning hub's `[RequiresPermission(Update)]` pipeline that `stream.Update` routes to (surfaced to the caller as `UnauthorizedAccessException` by `UpdateRemote`). The upsert request itself declares both via `[CreateOrUpdateNodePermission]` so the routing-layer gate still denies callers that have neither.
- **Patch mode is reserved** for incremental edits (log-line append, view-count bump, status flip): set `request.Patch` to a `Json.Patch.JsonPatch` payload. The handler will apply the patch to the existing node (or to `Node` as the seed when missing) and write the result. (Currently surface-only — patch mode lands when its caller does.)
- **Patch mode is reserved and refused.** A non-null `request.Patch` returns `PatchFailed`; it is not an implemented mutation route. Use `WithFolds<T>` for create-if-missing counters and timestamps, as below.

### Create-if-missing with a live content fold

`CreateOrUpdateNodeRequest.WithFolds<T>` carries a rule and operand to the upsert handler.
The create leg takes the supplied seed verbatim; the update leg evaluates the rules against
its live node inside `stream.Update`. No query or caller-side content read decides which leg runs.

```csharp
var request = new CreateOrUpdateNodeRequest(seed)
.WithFolds<UserActivityRecord>(hub.JsonSerializerOptions, folds => folds
.Sum(record => record.AccessCount, 1)
.KeepExisting(record => record.FirstAccessedAt)
.Max(record => record.LastAccessedAt, now));
```

`HandleTrackActivity` uses this shape with a seed count of one. It first checks the user's
partition root in storage so navigation cannot create a partition before onboarding, then issues
one upsert from the stable activity hub with the caller's identity. A stale positive activity
query can no longer select an update whose initial state never arrives (#1174). The tracking
hub's in-flight register still accounts for the detached write until it terminates.

Folds remove the stale caller read; they do **not** add cluster-wide atomic arithmetic. The
cross-hub write still carries an RFC 7396 patch, with the existing conflict and rebase semantics.
`ActivityTrackingUpsertTest` proves the real handler creates despite a stale positive index,
then preserves the first-access timestamp and increments the stored count on later tracks.
A structured conflict remains `FailureKind = NodeUpsertFailureKind.Conflict` across the response
boundary. This open string vocabulary supplements the unchanged legacy rejection enum; unknown
values remain named failures, and a teardown does not claim that nothing was written.
Activity tracking retains its existing conflict and teardown classification without adding retries.

Bulk upserts (e.g. node-tree copy) compose the per-node observable and merge with bounded concurrency so a wide subtree doesn't open every per-node hub simultaneously on the receiving side:

Expand Down
Loading
Loading