Skip to content

12. Graph annotation writes — bounded local writes after federated selection

A realm may declare that a type’s REAL nodes accept bounded local annotations: mark each writable property annotatable: true, optionally mark one property annotationVersion: true as the concurrency field, and the type must have an identity: true property. Deployments additionally gate the whole surface off by default. Without both opt-ins, no write is possible.

# types/person.yml (excerpt)
properties:
id: { type: string, identity: true }
reviewStatus: { type: string, annotatable: true }
reviewedAt: { type: string, annotationVersion: true }

An annotation statement is: any read selection (it may traverse fetched, source-backed nodes), then exactly WITH DISTINCT <target>[, <expr> AS <alias>…] ORDER BY … LIMIT n, then exactly SET <target>.<property> = <literal | $parameter | alias>:

MATCH (u:AssistantUser)-[:EMAILED]->(p:Person)-[:HAS_X]->(v:SomeFetchedLabel)
WHERE v.field = 'value'
WITH DISTINCT p ORDER BY p.id LIMIT 50
SET p.reviewStatus = 'follow-up'
RETURN p

Guarantees.

  • The fetched node participates only in selection. The transient materialization rolls back as always; the selected real nodes are then updated in a separate write.
  • The limit must be a positive whole number within the deployment maximum; the ordering is required. Everything else — writing a fetched binding, SET +=, dynamic property names, label changes, CREATE/MERGE/DELETE/REMOVE/FOREACH, procedure calls, subqueries, UNION — is rejected before anything runs. Writing a fetched binding names the durable binding you probably meant, and guarantees no fetch and no write occurred.
  • A write never redirects to the source system, even if the source declares a writable operation. Changing the source record is always its own explicit action.
  • Execution is two-phase: a dry-run returns the exact would-be changes under an expiring plan id; confirming applies that frozen plan — never a re-run selection. An expired plan requires a new dry-run; a plan applies at most once.
  • At apply time each target is re-found by its identity within your scope: a target that vanished or is no longer yours is skipped (one combined count — the two are deliberately not distinguished), and one whose value (or version field) changed since the dry-run is reported as a conflict and left untouched.
  • Results state committed values only, with selected / applied / conflicted / disappearedOrUnauthorized counts.
  • Every applied change is journaled and can be undone. Undo restores the previous value only where your annotation is still the current value — it never overwrites a later edit.
  • Fetched data may flow into an annotation only as a scalar captured at the selection barrier, frozen at dry-run time.