Writes, handles, and identity

Writes, handles, and identity #

Use write to create or update a record in another system.

writes #

company = write crm-[:Companies]-> {
  unique by (Domains)
  Name:    ai("the company name this email is about")
  Domains: [msg-[:Sender]->.domain]
}

what-the-write-did #

A handle reaches the record as it stood before the write through the engine-made edge -[:@old]->, so a later message can say what the write did:

was     = first(company-[:@old]->)
verb    = was == null ? "Added" : "Updated"
changed = was != null && was.Domains != company.Domains
The writecompany-[:@old]->
created the recordempty, so first is null
matched an existing recorda snapshot: a distinct position holding the values as they stood, with the fields the lookup fetched and every field this write set
matched, every ?: declineda snapshot: same values, still its own position

set-if-empty #

?: sets a field only when the target's current value is empty — null or absent; an empty string counts as a value.

write crm-[:Companies]-> {
  unique by (Name)
  Name:          msg.Subject
  Description ?: "Owner: ${@user_email}"
}

Use it for defaults and one-time stamps — owners, "first seen" — and plain : where the source stays authoritative. ?: also takes a value that may not be there, no guard needed: Description ?: first(company.Domains).

?...e applies ?: to every field of e — the whole record merged into what the target already holds, filling only what is empty: write crm-[:Companies]-> { unique by (FUZZY Name), ?...e }.

append-to-a-multi-value-field #

A plain : on a multi-valued field replaces the whole list. To add to it:

Categories  +: ["inbound"]
Categories +?: [ai("a one-word category for this lead")]

+: appends, duplicates and all; +?: appends only what's missing, so re-running never piles up duplicates. Both need a list — on a single-valued field they're flagged at save. Pair them with unique by, or the repeats land on different records with nothing to accumulate into.

A create writes into empty slots, so there every modifier — ?:, +:, +?: — behaves like a plain :.

structured-values #

A few fields take a structured value — an object with named keys, nested as deep as it needs, because the target publishes its own document format. Write it inline as a literal, keys separated by commas:

Layout: {
  heading: "${company.Name} is ready",
  blocks: [{ kind: "text", value: "Approved by ${@user_email}" }]
}

Nothing checks inside it — copy the shape from that system's documentation; a document it dislikes is its complaint at run time. Opaque means pass-through and nothing else: with no known shape the value can't be compared, added to, folded into a message, counted, or used as a condition, and each of those is refused as you write it.

identity #

unique by (`First Name`, `Last Name`)

Identity at write time is the union of the target's own rules and your unique by, so author only the identity the target lacks: compound business keys, parent-scoped identity, fields it treats as ordinary.

match #

Use match to find a record you will not create:

company = match crm-[:Companies]-> {
  unique by (FUZZY Name)
  unique by (Domains)
  Name:    ai("the company name this email is about")
  Domains: [msg-[:Sender]->.domain]
}
if (company == null) {
  write crm-[:Companies]-> { unique by (Name), Name: "Unknown" }
} else {
  write company-[:Notes]-> { Title: "Seen again", Content: company.Name }
}

FUZZY works only on the fields a target lists for similarity, and the save check names them. A valuations Legal Entity lists Name, Legal Name, Also Known As and Other Names: its shortlist searches the words in all four, plus near spellings of Name and Legal Name. Every other field matches exactly, and Website ignores the scheme, www. and a trailing slash.

linked-writes #

When the new record should hang off one you just wrote, target an edge from the handle:

company = write crm-[:Companies]-> { unique by (Name), Name: enquiry.`Company Name` }

write company-[:Team]-> { Name: enquiry.`Contact Name` }

One effect, two assertions: the record and its link from company. The type comes from the edge, so the child can't be authored without its connection. Chains compose, which is why you write parents first: every child hangs off a handle already in hand.

A parent is a name or a member path: write m.k-[:Team]-> { … } hangs the child off the record a map's key holds. Bind any other expression to a name first.

The edge may be declared on either side: where only the child references back, write it at the root and link it instead. Never put a handle in a reference-shaped field (Partner: pa) — connections live in the write target, not the body.

conversation-writes #

Posting to a chat system needs no special "send": a message is a record written along an edge, the same one you read coming in. What changes is only what it hangs under — its channel or thread comes from its parent, never from a field you fill in.

deals = only(chat-[ch:Channels WHERE ch.Name == "deals"]->)
if (deals == null) { fail("no #deals channel") }
write deals-[:Messages]-> { Message: "A new company just landed." }

write msg-[:Replies]-> { Message: "Got it — taking a look now." }

A reply hangs under the message that fired the automation, or one an earlier write sent back. What a system calls its message field, and which further edges it offers, is in that system's own chapter.

multi-parent-writes #

Some records sit at the convergence of several parents, none primary. Write the record once, with a tuple of paths:

write (co-[:agreements]->, pa-[:agreements]->) {
  unique by (co, pa)
  Value: msg.Value
}

Each path is a handle plus one edge its type declares, and the written type must be the same for every path: one record carrying N connections, not N records, created in one effect. unique by (co, pa) scopes its identity by all the parents at once.

link connects two records without creating or modifying either one.

link champion -[:led]-> part

to = match p-[:Company]-> { unique by (FUZZY Name), Name: "Acme" }
if (to != null) { link p -[:Company]-> to }

The first form joins two handles in the same graph, over an edge the from-side declares. To link a record you look up, match it first, test it, then link. Name selects Acme, it doesn't rename anything. A link or unlink whose end may be absent (a match handle or a first(…) result) needs the guard too.

looking-up-existing-records #

Look up a record that must already exist with only, and guard it:

company = only(crm-[c:Companies WHERE c.Domains CONTAINS "acme.com"]->)
if (company == null) { fail("no company at acme.com") }
write company-[:Notes]-> { Title: "Enriched", Content: "found ${company.Name}" }

The hop's WHERE is the criteria, rooted at any instance in scope. The guard ends the run with a reason when nothing matches.

So: a lookup finds a record that must be there, and a miss fails the run; match finds one that may not be, and a miss is absent; write … unique by finds or creates; write <alias> updates the exact record you already hold.

updating-a-record-in-place #

crm-[record:Companies WHERE record.Categories == "Lead"]-> {
  write record { Categories: "Customer" }
}

write <alias> { … } updates the record the alias already stands on — traversed, or a write's handle, which then reads the new values (a match's handle stays the record as found) — and write m.k { … } updates the one a map's key holds, as x = m.k then write x { … } would. Nothing is left to resolve, so unique by is rejected, and there is no create to gate: set only the fields that change, and use link/unlink for connections. A target that can't update by id (an append-only channel, a drive that only creates files) is flagged at save.

removal #

unlink company -[:related]-> stale     # sever the edge; both records remain
delete stale                           # remove the record itself

unlink is the exact inverse of link's handle form; severing an edge that isn't there is a quiet no-op. delete removes the record a position stands on, so it works wherever an in-place update does — reach for it only when removal is the point, never as cleanup for an identity you got wrong. Both are capability-gated: a target that can't sever or delete rejects the statement up front.