Property lifecycle
A property definition is a small synced record inside its type. Its id is content-addressed, its structural facts are pinned by the first write, its display facts merge per path, and its removal is a tombstone that leaves values untouched. Knowing which is which is what keeps concurrent schema edits from fighting.
1. Define
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/types/$MOVIE/properties \
-H 'Content-Type: application/json' \
-d '{"name": "Year", "xKey": "year", "kind": "number", "scope": "synced",
"meta": {"index": "props"},
"xFormat": {"type": "number", "pos": "a1", "icon": "calendar"}}'
# → 201 {"propId": "EwyHGrtTdxB"}
any type property add $SPACE $MOVIE --name Year --xkey year --kind number
The propId is derived from the change that created the record (base58(xxh3-64(changeId)), up to 11 chars) — the same id on every peer, and the field key under which values are stored. Built-in properties on any use readable ids (name, description, icon, tags) that an 11-char base58 string can never collide with.
xKey is your stable code-side handle: clients resolve xKey → propId from GET …/properties and write by propId. It is metadata — unique within the type by a read-then-create preflight (409 property.xkey_conflict), never seen by storage, mutable — and GET …/types requires a type-level xKey for the same reason (types resolve by handle, not by display name).
2. What the first write pins
| Field | Mutability | Why |
|---|---|---|
id |
immutable | it is the record id and the storage key |
kind (string / number / boolean / array / object / datetime) |
first-write-wins | values are validated against it on every peer |
scope (synced / account / local) |
first-write-wins | it selects the write route and version domain; a route change would strand values |
items, properties (nested shapes) |
pinned | narrowing would invalidate stored values |
name, description, xKey |
freely mutable | labels and the handle |
meta.index |
freely mutable | the search flag |
xFormat and every path under it — type, icon, pos, options.<key>.{name,color,pos,meta.<k>}, relation.{targetTypes,filter}, config.<k>, vendor keys |
freely mutable per leaf; an option key itself immutable | the descriptor is a hint, not a guarantee; the option key IS the stored value |
Pins are enforced at apply time on every peer, convergently: an op that tries to change kind is dropped, not merged. Changing a pinned fact means defining a new property with a new id — the old one keeps its values. The slug (xFormat.type) is mutable but the server only lets it move within the pinned kind — text to email, never text to number.
kind is always explicit; nothing is defaulted from the descriptor. Value conventions per slug: Data types.
3. Values
Values live on objects at {typeId}.{propId}, written through the typed set route, which auto-routes by the declared scope:
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/properties/$OBJ/set/$MOVIE \
-H 'Content-Type: application/json' \
-d '{"EwyHGrtTdxB": 1995}'
Three rules connect values to definitions:
- The server validates shape, not membership. A number for a
numberkind, a well-formed{"$date": …}for an instant, a plainany://<objectId>for a relation, aperiod/money/geocompound in its exact shape — against the property's current slug (400 property.format_violationotherwise). Achoicevalue is not checked againstxFormat.options— options are dangling-tolerant by design. - Read tolerance. A value that violates the current definition, or sits under an unknown propId, is returned as-is. There is no
validflag and no re-validation cascade; clients decide how to render out-of-spec data. - Same-name properties are not a conflict. Two peers concurrently adding "Rating" produce two ids, both fully real. Consolidating is a user or agent decision, never a merge rule.
Why it matters. Hosted schemas migrate tables in one transaction the server controls. Here two devices can edit a schema offline and both edits must converge without a coordinator — so the design moves every "breaking" fact into an immutable pin and everything else into per-path LWW. Nothing ever needs a migration, and nothing that merged is ever rolled back.
4. Patch
PATCH …/properties/:propId is one CRDT change with per-path set / unset. It covers rename, the handle, reorder, icon, the index flag and the whole choice-option lifecycle:
curl -X PATCH http://127.0.0.1:7001/v1/spaces/$SPACE/types/$MOVIE/properties/$GENRE \
-H 'Content-Type: application/json' \
-d '{"set": {"name": "Genre",
"xFormat.options.noir.name": "Noir",
"xFormat.options.noir.color": "gray",
"xFormat.options.noir.pos": "a2"},
"unset": ["xFormat.options.western"]}'
# → 204
any type property option set $SPACE $MOVIE $GENRE noir --name Noir --color gray
settargets a leaf and never carries an object; a container (meta,xFormat,xFormat.options,xFormat.options.<key>,xFormat.relation,xFormat.config) is rejected onsetbut allowed onunset— unsetting an option key deletes the option. That is what keeps one client from replacing the bag and dropping keys another client added.- Pinned paths answer
400 property.immutable; unknown paths or wrong leaf types400 request.invalid_field; a slug that does not fit the kind, a reserved key or an unparseablexFormat.relation.filter400 property.format_invalid; a takenxKey409 property.xkey_conflict. - Deleting then re-adding the same option key works: it is a field unset, not a tombstone.
- Definitions on registered built-in types are frozen:
400 type.registered.
Because each leaf merges independently, two members renaming different options at once both win; two renaming the same option converge on the later write.
5. Remove
curl -X DELETE http://127.0.0.1:7001/v1/spaces/$SPACE/types/$MOVIE/properties/$PROP # → 204
any type property remove $SPACE $MOVIE $PROP
Removal tombstones the definition record. Stored values are not cleaned up — they stay in the object rows as orphan data, read-tolerant — and later writes to the removed id are dropped op by op. The same holds one level up: dropping a type from an object's any.types orphans that namespace's values rather than deleting them.
There is deliberately no "rename or delete a value across N objects" operation: values store option keys, not labels, so a rename is one definition write and zero object writes, and a delete leaves keys dangling by design.
6. Observe
Definitions are synced records, so a schema change is visible live to every member the same way data is — subscribe to the type object's properties dataset, or re-read GET …/types/:typeId/properties. Search indexing of values follows meta.index (props by default, a named scope, or none) — Indexing.
Related
- Types and properties — the endpoint walkthrough.
- Data model — where definitions and values sit.
- Runtime datasets — the same pin/patch discipline applied to dataset fields.