Skip to content

Operations

Every write request contains one or more operations. There are six operation types — add, revise, retract, rename, reaffirm, and restore — across shape, thing, assertion, and collection records.

A write request can include multiple operations. CLI (wh commit submit) and SDK (client.commit.apply) writes can mix operation types in one commit. Each MCP write tool carries one type: warmhub_thing_create takes add operations as items without their operation field, and warmhub_thing_revise, warmhub_thing_retract, warmhub_thing_restore, warmhub_thing_rename, and warmhub_assertion_reaffirm do the same for theirs. All of them apply operations one at a time — if a later operation fails, earlier ones are still in place, and you can retry just the failed ones. See Writes.

Every add and revise names its target by its bare name and states its Shapes in shapes ([] for a shapeless Thing). A Shape is the Thing hub/shapes/<Name>, written with shapes: []. Operations carry no kind or shape field: the write format refuses both. An assertion is a Thing one of whose declared Shapes composes the built-in Assertion Shape, with its target in data.about. Collections are covered below.

An ADD claims a name, and two distinct failures can refuse that claim:

  • Same shape, same name — re-adding a name that already exists under the same shape fails with CONFLICT. See Add if missing to make that a no-op instead.
  • Different shape, same name — thing names are unique across the whole repository, so an ADD whose name is already held by an active record under a different shape is refused with NAME_TAKEN. This refusal is terminal: the same name cannot succeed on retry, so treat it as a naming decision to make rather than a race to retry.

The NAME_TAKEN error reports the contested name; see error responses for the field contract. Shape names are claimed in a separate namespace and do not contend with thing names — see Shape Names in Wrefs.

A name held by a retracted entity is free for reuse. A name freed earlier in the same commit — by a RETRACT, or by renaming its holder away — is likewise available to a later operation in that same commit.

Create a new shape with field definitions:

{
"operation": "add",
"name": "hub/shapes/Location",
"shapes": [],
"data": { "fields": { "x": "number", "y": "number", "label": "string" } }
}

A Shape can compose other Shapes with composes; see Composite Shapes.

Create a new thing under an existing shape:

{
"operation": "add",
"name": "cave",
"shapes": ["Location"],
"data": { "x": 3, "y": 7, "label": "Dark Cave" }
}

To create a thing with data: null, declare no Shape (or only Shapes with no required field, because Location requires x, y and label):

{
"operation": "add",
"name": "placeholder",
"shapes": [],
"data": null
}

Create an assertion about a shape or shaped thing. Observation here is a Shape that composes the built-in Assertion Shape, and the target goes in data.about, which is required:

{
"operation": "add",
"name": "cave-safe",
"shapes": ["Observation"],
"data": { "about": "cave", "safe": true, "confidence": 0.8 }
}

An ADD assertion payload may also include affirmedTargets — an array of pinned thing wrefs (in name@vN form) that records the specific versioned things whose state the assertion is vouching for. In practice, these are the things whose current state informed the assertion’s conclusion — for example, the exact versions of locations an agent inspected before concluding a route was clear. affirmedTargets differs from about: about identifies what the assertion is about (its subject), while affirmedTargets records the specific versioned things the assertion is vouching for. This is optional; omit it when you have no targets to affirm.

Every entry in affirmedTargets must be a pinned version reference (name@vN) for the same thing identified by data.about. Pinned references to a different thing identity are rejected with a validation error (... is not a version of the assertion's about identity). Unpinned wrefs (e.g. cave) are also rejected with a validation error:

{
"operation": "add",
"name": "route-clear",
"shapes": ["Observation"],
"data": { "about": "cave", "safe": true, "confidence": 0.8 },
"affirmedTargets": ["cave@v2", "cave@v5"]
}

data.about is an untyped wref, so it accepts local or canonical (wh:org/repo/name) shape and shaped-thing targets. To assert about a collection, add the collection Thing first and point data.about at its name.

Revise only changes data. Like an add, it states the Thing’s Shapes in shapes; on the CLI, --keep-shapes reads the current set and sends it for you. It does not accept an active field — to mark an entity inactive, use a retract operation instead. TypeScript SDK users see this as a compile error because ReviseOperation declares active?: never.

Update a shape’s field definitions:

{
"operation": "revise",
"name": "hub/shapes/Location",
"shapes": [],
"data": { "fields": { "x": "number", "y": "number", "z": "number" } }
}

A revise sends the Shape’s whole new body, so a body without composes authors a Shape that composes nothing. To change fields and keep the composition, repeat composes in the body; wh shape revise does this for you unless you pass --compose or --clear-composition.

Update a thing’s data:

{
"operation": "revise",
"name": "cave",
"shapes": ["Location"],
"data": { "x": 5, "y": 3, "label": "Bright Cave" }
}

To clear a thing’s data payload, set data to null. This works only when no declared Shape requires a field, so this example clears a shapeless Thing:

{
"operation": "revise",
"name": "placeholder",
"shapes": [],
"data": null
}

Update an assertion’s data. Revise replaces data, so it repeats data.about, and the target cannot be changed:

{
"operation": "revise",
"name": "cave-safe",
"shapes": ["Observation"],
"data": { "about": "cave", "safe": false, "confidence": 0.2 }
}

A REVISE assertion payload may also include affirmedTargets — an array of pinned thing wrefs (in name@vN form) that records the specific versioned things the assertion is vouching for. When affirmedTargets is present, it replaces any previously stored set. When affirmedTargets is omitted, the prior affirmation snapshot is cleared — the new version will have no affirmed targets. To preserve existing targets across a data-only revise, either include them explicitly in affirmedTargets or use a reaffirm operation instead. Each entry must be a pinned version reference for the same thing identified by data.about; pinned references to a different thing identity are rejected with a validation error (... is not a version of the assertion's about identity). Unpinned wrefs are also rejected with a validation error:

{
"operation": "revise",
"name": "cave-safe",
"shapes": ["Observation"],
"data": { "about": "cave", "safe": false, "confidence": 0.2 },
"affirmedTargets": ["cave@v3"]
}

A collection is revised like any other Thing: name it, state its collection Shape, and send the new member fields in data:

{
"operation": "revise",
"name": "a-to-b",
"shapes": ["Arc"],
"data": { "from": "a", "to": "c" }
}

See Collections for the full collection model, including wh collection revise and the SDK shorthand.

An ADD or REVISE Shape body may carry composes, an ordered list of Shapes whose fields the new Shape inherits:

{
"operation": "add",
"name": "hub/shapes/Sighting",
"shapes": [],
"data": {
"fields": { "confidence": "number" },
"composes": ["Location", "Player"]
}
}

The commit captures each member’s HEAD and the members’ own composes chains as the Shape’s closure. Things declared under the composite validate against the merged fields. A write is refused with VALIDATION_ERROR when:

  • an entry is not a Shape name (a durable ID, or a name with a path), pins a version such as Location@v2, or uses @ALL;
  • an entry names a Shape in another repository, directly or through a member’s own composes;
  • the Shape composes itself, directly or through a cycle;
  • two members, or a member and the Shape’s own fields, define the same field differently;
  • the list holds more than 32 Shapes, or the closure does.

An empty list ("composes": []) is accepted and composes nothing. A Shape that a composite still composes cannot be retracted. See Composite Shapes for the CLI flags and the versioning of members.

Collections create grouped things (directed arcs, symmetric bonds, sets, and lists). A collection is an ordinary named thing whose Shape is a built-in collection Shape: Arc holds data.from and data.to, Bond holds data.ends, Set holds data.members, and List holds data.items. It is named, revised, and retracted by its bare name like any other Thing.

Create a collection thing explicitly:

{
"operation": "add",
"name": "a-to-b",
"shapes": ["Arc"],
"data": { "from": "a", "to": "b" }
}

The name field is required. Names are chosen by the caller and are not derived from the members. Members are untyped wrefs and may identify shapes or shaped things.

Reference limit: A single thing version may not store more than 10,000 wref-typed field occurrences (including collection members). If an ADD or REVISE collection operation would push the member count past 10,000, or if a thing’s wref-typed data fields would exceed 10,000 total references, the operation is rejected with a PAYLOAD_TOO_LARGE error. Split large collections into smaller ones or reduce the number of wref fields to stay within the limit.

See Collections for the collection model and first-class CLI/SDK operations.

To mark a collection inactive, use a RETRACT operation (see RETRACT Operations):

{
"operation": "retract",
"name": "a-to-b"
}

Mark an entity inactive. The entity’s data and version history are preserved; it is hidden from default HEAD queries.

{
"operation": "retract",
"name": "cave",
"reason": "replaced by dungeon",
"expectedVersion": 3
}

The reason field is optional (max 500 chars). expectedVersion is also optional; when supplied, the retract succeeds only if the target is still at that version.

Retract is the only path to setting an entity inactive. Once retracted, you can add a new entity at the same name to create a fresh identity, or use a restore operation to make the original entity active again by its durable ID. This holds for every Thing, including the built-in collection shapes (Arc, Bond, Set, List, and Pair): re-adding a retracted collection always creates a new identity, even when the members and declared Shapes are identical. A re-add never revives the retracted collection: it stays retracted with its history intact, and any reference to its old durable id stays on that retracted Thing until a restore makes it active again. See Collections for the full collection model.

Change a thing’s, assertion’s, or shape’s name in place. Rename is a version-less metadata operation: it rewrites the name but creates no new version and adds no history entry. The current data and version number are untouched.

{
"operation": "rename",
"name": "cave",
"newName": "cavern"
}
  • name is the current target — the bare name of a thing, assertion, or collection, or hub/shapes/<Name> for a shape.
  • newName is the new name: a bare name for a thing, assertion, or collection, or hub/shapes/<NewName> for a shape (a shape rename names both ends that way). The Shapes of a thing or assertion do not change.
  • Renaming a thing, assertion, or collection follows the ADD collision split: a name already used in the same shape fails with CONFLICT, and a name held by an active record under a different shape is refused with the terminal NAME_TAKEN.
  • Renaming a shape resolves only against other shapes: it fails with CONFLICT when the destination shape name already exists. Shape names sit outside the repository-wide thing-name rule, so a shape rename never returns NAME_TAKEN.
  • Renames are applied before new names are claimed, so a name vacated by an earlier rename in the same commit is available to a later one.

Because stored references are pinned by identity, an assertion, data field, or collection pointing at a renamed shape or shaped thing follows the rename automatically. Every input wref that uses the old name breaks, including old @vN spellings; the new name addresses current and historical versions. A rename mints no version, so it does not trigger subscription matching. See Retract, Rename & Schema Changes for the full ripple-effect rules and the wh thing rename / client.thing.rename shorthands.

An assertion’s affirmedTargets records which pinned thing versions the assertion is vouching for — the specific things whose state informed its conclusion. A reaffirm operation updates that list (adding or removing individual pinned targets) without touching the assertion’s data or about. Use it when the underlying things have advanced to new versions and you want to bring the assertion’s provenance up to date without issuing a full data revise.

A reaffirm edits the stored affirmedTargets list via add and/or remove sublists. When the add and remove lists together make no effective change to the stored set — every target in add is already present and every target in remove is already absent — the operation returns status: "noop" and no new version is created. Otherwise, a new version is created with updated provenance metadata.

At least one of add or remove must be present; a reaffirm with neither is rejected with a VALIDATION_ERROR (Reaffirm requires at least one target in "add" or "remove").

{
"operation": "reaffirm",
"name": "cave-safe",
"add": ["cave@v3"],
"remove": ["cave@v2"]
}
  • name is the wref of the assertion to reaffirm.
  • add is an optional array of pinned target wrefs (name@vN) to add to the affirmation snapshot.
  • remove is an optional array of pinned target wrefs to remove from the affirmation snapshot.
  • expectedVersion is optional; when supplied, the reaffirm succeeds only if the assertion is still at that version.

All entries in add and remove must be pinned version references (name@vN) for the same thing identified by the assertion’s about. Pinned references to a different thing identity are rejected with a validation error (... is not a version of the assertion's about identity). Unpinned wrefs are also rejected with a validation error.

The TypeScript SDK accepts the same reaffirm operation payload described above, and MCP’s warmhub_assertion_reaffirm takes it as an item without the operation field.

Reaffirm is only valid on assertions. To update an assertion’s data, use a revise operation instead.

Make a retracted Thing (including an assertion or a collection) or Shape active again by its durable ID, the identifier that stays fixed across renames. A restore does not create a new identity — it reactivates the original entity, preserving its full version history.

{
"operation": "restore",
"name": "<durable-id-of-retracted-thing>"
}
  • name is the durable ID of the retracted entity to restore. The entity must currently be retracted; restoring an active entity is rejected with a VALIDATION_ERROR.
  • A restore makes the entity visible again in default HEAD queries under its existing name and version history.
  • If the name it would return to is held by a different active entity (for example, a new entity was added at the same name after the retraction), the restore is refused: NAME_TAKEN for a Thing, CONFLICT for a Shape. Pass newName to restore it beside the newer entity.

The restore operation also accepts the optional fields newName, expectedVersion, and leaseId:

  • newName — if supplied, the entity is restored under this name instead of its original name. A Shape’s newName is written hub/shapes/<Name>.
  • expectedVersion — if supplied, the restore succeeds only if the entity is still at that version.
  • leaseId — if supplied, the restore is gated on the named lease.

Restore is the complement to RETRACT: where retract marks an entity inactive, restore makes it active again without discarding its history. To create a fresh identity at the same name instead, use an ADD after the retraction.

  • data is required on revise for shape, thing, and assertion — the field must be present, though it may be null for thing and assertion revises (see below)
  • Revise is a full replacement — you must include all fields in data, not just the ones that changed
  • Omitted fields will be absent in the new version, which will fail shape validation if they are required
  • data.about is required on ADD assertion, and a REVISE assertion repeats the same target — it is immutable
  • data: null is judged as an empty object {}. It is valid on ADD and REVISE when the Thing is shapeless or when no declared Shape (including the Shapes they compose) requires a field, and it creates or clears the data payload. It is refused when any declared Shape requires a field, so an assertion, whose about is required, cannot be written with data: null. data: null is not valid on shape operations; they always require an object payload.
  • A string, number, boolean or array data is refused under any Shape. A shapeless Thing accepts any JSON value.

A null body means data: null — a thing stored with no data payload, as in the examples above. It needs a shapeless Thing or Shapes with no required field.

Writes like that are valid, but you cannot make one with --data. The flag accepts only a JSON object, so --data null fails with --data must be a JSON object, got null. This affects wh thing create, wh thing revise, wh assertion create, wh assertion revise, and wh commit submit --add / --revise. The single-object --file shortcut on wh thing create has the same limitation, so a file containing null fails the same way.

To write a null body from the CLI, pass a full operation payload instead of the shorthand:

Terminal window
# Inline JSON array of operations
wh commit submit --ops '[{"operation":"add","name":"placeholder","shapes":[],"data":null}]' \
-m "Add a placeholder with no body" --repo acme/world
# Or the same operations from a .json file
wh commit submit -f ops.json -m "Add a placeholder with no body" --repo acme/world

The --ops, -f/--file, and newline-delimited .jsonl stream paths all forward data to the backend as written, so null survives. The SDK and MCP surfaces accept data: null directly. See the write submit deep dive for the full operation-payload reference.

Fields typed as wrefs are resolved when the operation is applied. Untyped fields accept shapes and shaped things. A typed constraint passes when the named Shape is in the target’s Shape set: a Shape the target declares, or one reached from those through composition. Any one match is enough; no Shape in the set is primary. A shapeless thing and a Shape definition never pass (see What Wrefs Can Target). A required wref whose target is missing fails that operation with NOT_FOUND and stores no version for it. An optional wref (wref?) coalesces to null only for thing_absent — a named shaped thing missing under an existing active shape. Missing shapes and every other resolution failure remain hard errors for the affected operation. See Optional Wref Fields for the complete rules.

A target your token cannot read answers as a missing one when you name it, but resolves when you address it by durable id. See References in a write.

A single thing version may not store more than 10,000 wref-typed field occurrences. This cap covers all wref-typed data fields across the payload — including collection member lists. An ADD or REVISE operation that would produce a version exceeding this limit is rejected with a PAYLOAD_TOO_LARGE error. If you hit this limit, split the data across multiple things or collections, or reduce the number of wref fields in the payload.

When an optional wref coalesces to null, the operation result carries a coalescedWrefs warning so the substitution is not silent:

{
"opIndex": 0,
"name": "cleanup",
"operation": "add",
"status": "applied",
"warnings": {
"coalescedWrefs": [
{ "fieldPath": "owner", "wref": "nobody", "reason": "thing_absent" }
]
}
}

Each entry names the fieldPath that coalesced, the wref string that was dropped, and the reason (currently always thing_absent). The warnings struct is additive — the same object can also carry undeclared-field warnings and ignoredVersionPins warnings (described below). If more than 500 fields coalesce in one operation, the list is capped, coalescedWrefsTruncated: true is set, and totalCoalescedWrefs reports the full count.

The wh CLI prints each coalesced wref as a warning line under the operation:

+ cleanup@v1
⚠ coalesced wref owner: nobody (thing_absent)

The same warnings.coalescedWrefs array is returned by every write surface — the SDK (client.commit.apply), the CLI, and the MCP write tools such as warmhub_thing_create — which share one commit pipeline. WarmHub has no REST write endpoint; see HTTP Writes.

When a wref-typed field is declared with identity binding and the value you submit carries a version pin (e.g. cave@v2), the pin is dropped. An identity-bound reference follows the thing rather than fixing one version, so a pin has no meaning on that field. The value is stored unpinned, and the operation result carries an ignoredVersionPins warning so the drop is never silent:

{
"opIndex": 0,
"name": "cave-safe",
"operation": "add",
"status": "applied",
"warnings": {
"ignoredVersionPins": [
{ "fieldPath": "location", "wref": "cave@v2", "binding": "identity" }
]
}
}

Each entry names the fieldPath where the pin was dropped — a concrete indexed path, so a field reached through an array reads like sightings[2].location — the wref string whose pin was ignored, and the binding (currently always "identity"). Like coalescedWrefs, ignoredVersionPins is a channel on the shared warnings object, and both channels may appear together on the same operation result. The list is capped at 500 entries and deduped by field path and wref; unlike coalescedWrefs it carries no truncation flag and no total. The wh CLI prints each ignored pin as a warning line under the operation:

+ cave-safe@v1
⚠ ignored version pin location: cave@v2 (field binding is identity; the reference follows the Thing)

The same warnings.ignoredVersionPins array is returned by every write surface — the SDK (client.commit.apply), the CLI, and the MCP write tools such as warmhub_thing_create.

When you retry a write or have more than one writer touching the same data, you often want an operation to apply only under a condition: skip it if the target already exists, reject it if someone changed the target first, or do nothing when the data is unchanged. WarmHub supports three conditional patterns. The first two — skipExisting and expectedVersion — are opt-in fields you set on an operation. The third happens automatically.

By default, adding a name that already exists fails with a CONFLICT error (Thing "cave" already exists). Set skipExisting on an add to return operation: "noop" instead — the existing entity is left untouched and the write does not fail.

{
"operation": "add",
"name": "cave",
"shapes": ["Location"],
"data": { "x": 3, "y": 7, "label": "Dark Cave" },
"skipExisting": true
}

This makes an add idempotent, which is what you want when re-running a seed script or retrying a write safely. skipExisting is available on add for shapes, things, assertions, and collections. On the CLI it is the --skip-existing flag on wh commit submit; in the SDK it is a field on each add operation or a top-level option on client.commit.apply.

By default, a revise, retract, reaffirm, or restore acts on the current version no matter who wrote it last. To guard against a lost update — you read v3, another writer commits v4, and your write would silently revise or withdraw the newer state — pass expectedVersion. The operation applies only if the target is still at that version; otherwise it is rejected with a CONFLICT error whose details.reason is "expected_version_mismatch", carrying the expected and current version numbers so you can refetch and retry.

{
"operation": "revise",
"name": "cave",
"shapes": ["Location"],
"data": { "x": 5, "y": 3, "label": "Bright Cave" },
"expectedVersion": 3
}

The same fence applies to a retract:

{
"operation": "retract",
"name": "cave",
"expectedVersion": 3
}

expectedVersion is available on revise for things, shapes, assertions, and collections, and on every retract, reaffirm, and restore operation. CLI support for --expected-version varies by target kind:

Target kindCLI shorthand with --expected-version
thingwh thing revise alice --shape Player --data '{"score":2}' --expected-version 3 ✓
assertionwh commit submit --revise temp-1-v1 --shape Reading --data '{"about":"sensor-1","value":72.5}' --expected-version 3 ✓
shapeNot available on a shorthand — use wh commit submit with the full operation payload
collectionRetract: wh thing retract edge-1 --expected-version 3 ✓. Revise: wh commit submit --revise edge-1 --shape Arc --data '{"from":"a","to":"b"}' --expected-version 3 ✓

Revise shorthands always need their target, their Shapes, and --data alongside --expected-version; the fence is an addition to a complete revise, not a substitute for it. Use --shape rather than --keep-shapes here: --keep-shapes already sends the version it read as the expected version.

In the SDK and via MCP, expectedVersion is a field on any revise, retract, reaffirm, or restore operation regardless of target kind. When you need exclusive access across a read-modify-write rather than an optimistic check, use a read lease instead.

If a revise produces the same data as the current version, it returns operation: "noop" — no new version is created. (WarmHub compares server-computed data hashes; clients never generate them.) Re-submitting an unchanged revise is therefore safe and leaves version history untouched.

For the full SDK signatures see Write Methods; for the CLI flags see Commands; for the MCP tool schema see MCP Tools Reference.

Within a single chunk, the following sequences on the same target are rejected by preflight:

SequenceAllowed?
ADD + ADDNo — duplicate add
REVISE + ADDNo — can’t add something that already exists
ADD + REVISEYes — create then immediately update
REVISE + REVISEYes — multiple updates in sequence

TypeScript callers binding an inline operation literal to a variable should either annotate it : Operation[] or use satisfies Operation[] to keep the operation discriminant narrowed — see SDK Write Methods — Typing Operation arrays.

The --file flag on wh commit submit accepts a JSON file containing an array of operations. For large datasets, prefer JSONL streaming (one operation per line):

Terminal window
# JSON array (small/medium batches)
wh commit submit --file operations.json --message "batch update" --repo org/repo
# JSONL stream (large datasets — chunked automatically; --stream-id + --skip-existing required)
wh commit submit --file dataset.jsonl --stream-id bulk-import --skip-existing --progress -m "bulk import" --repo org/repo

The JSON file must contain a JSON array of operation objects:

[
{
"operation": "add",
"name": "cave",
"shapes": ["Location"],
"data": { "x": 3, "y": 7, "label": "Dark Cave" }
},
{
"operation": "add",
"name": "cave-safe",
"shapes": ["Observation"],
"data": { "about": "cave", "safe": true, "confidence": 0.8 }
}
]

Use wh shape template to scaffold operations from shape definitions (template generation lives in the shape domain):

Terminal window
# Single shape -> thing scaffold
wh shape template Hypothesis --repo org/repo
# Multiple shapes at once
wh shape template Hypothesis Evidence Decision --repo org/repo
# Assertion scaffold for a shaped claim about a target
wh shape template Hypothesis --kind assertion \
--about example --repo org/repo
# Write to file, then edit and commit
wh shape template Hypothesis Evidence -o experiment.json --repo org/repo
$EDITOR experiment.json
wh commit submit --file experiment.json -m "add experiment" --repo org/repo

The template fills fields with placeholder values ("", 0, false) and FILL_IN: hints for fields with descriptions. Replace placeholders with real data before writing.

For composite shapes, the scaffold includes all fields visible on the shape — not just the fields declared directly on it. Fields inherited from parent shapes — such as aliases, details, title, and toString — appear alongside the fields declared on the shape itself. You will see every field you need to fill in, regardless of which shape introduced it.

Shapes define the payload schema, not whether an operation is a thing or an assertion. To scaffold an assertion, pass --kind assertion; add --about <target> when you want a concrete target instead of the default placeholder.

For large datasets, use JSONL format (one operation per line) with streaming. JSONL paths require --stream-id and --skip-existing:

Terminal window
wh commit submit --file dataset.jsonl --stream-id bulk-ingest --skip-existing --progress -m "bulk ingest" --repo org/repo

WarmHub no longer supports $N/#N commit-token syntax. Create things and collections with deterministic explicit names, then reference those names directly in later operations.

For example, add cave first, then use data.about: "cave" or data.members: ["cave"] in later operations.

Order still matters: if an operation depends on an earlier operation that fails, the dependent operation can fail too.