This is part 5. Part 3 was how you read the ontology — build an object set, ask it a question. This is the other half: how you change it. The two are mirror images, and together they're the whole verb surface of the system. Part 1 promised that the only sanctioned way to write is an action — now we follow one all the way through, from the button click to the audit entry, with the rollback in between. The running example is the same little construction-site world from the playground: acknowledging an alert.
You don't write to the database. You propose an action.
Here's the rule that shapes everything else: in an ontology, you never write directly. There's no "set this field to that value." You invoke a named action — acknowledgeAlert, cancelOrder, reassignWorker — with typed parameters, and the action does the writing.
await client.actions.acknowledgeAlert({ alert: "alr_22", assignee: "wkr_8" });
That feels like a restriction. It's the whole point. Compare two ways the same change could land in your data:
- A raw write:
UPDATE alerts SET status='ack' WHERE id='alr_22'. Six months later, nobody knows who ran it, why, or whether they were allowed to. - An action:
acknowledgeAlert. It has a name (so the audit log reads like sentences, not field diffs), typed inputs (so it can't be called wrong), and one place to put the rules.
👩💻 Mai (junior): So I can't just set the field, even when I know exactly what I want to change?
👨🏫 Hamed (mentor): Right — and that constraint is the feature. The moment writes have to go through named actions, you get governance for free: every change is intentional, attributable, and checkable. A bare
UPDATEthrows all of that away. From Part 1: actions are the only governed door into your data. Everything below is what happens once you walk through it.
The journey of a write: validate, check, commit, audit
When you invoke an action, it runs a short pipeline before anything is saved. Four stages, and the order matters.
Validate asks: is this a sensible request at all? Are the parameters the right types, are required fields present, is the reason non-empty? It's the structural check — cheap, instant, the same spirit as form validation. It looks only at the request, never at the world.
Check asks: is this allowed, right now? Two questions bundled together — permission (are you allowed to do this? — the security gate from Part 1) and business rule (does the current state of the world permit it? — you can't acknowledge an alert if your safety cert has expired, can't cancel an order that already shipped). Check looks at the world and the caller, which is why it runs second: no point asking the expensive question until the request is well-formed.
Commit is where it becomes real. The action computes a set of edits and writes them to the writeback store — that layer from Part 1 that sits over your pristine source data, never touching it. Your warehouse stays untouched; the change lives in the overlay.
Audit appends the receipt: who ran what, when, and the before→after. This is the reason the whole "named actions only" rule exists — you can answer "who changed this, and why" months later, which a raw UPDATE can never tell you.
// Anatomy of an action (conceptual — not a real API)
action("acknowledgeAlert", {
params: { alert: Alert, assignee: Worker }, // typed inputs
validate: (p) => p.assignee != null, // shape: sensible request?
check: (p, ctx) => // world: allowed now?
ctx.user.can("ack", p.alert) && p.assignee.certValid,
apply: (p) => [ // the edits to commit
edit(p.alert, { status: "ack", assignee: p.assignee }),
],
});
👩💻 Mai: Validate and check feel like the same thing. Why two stages?
👨🏫 Hamed: Different questions. Validate is about the request — "is this even a coherent thing to ask?" Check is about the world — "is it allowed, by this person, in this state, right now?" The first is cheap and stateless; the second may hit the database and the security policy. Separating them means you fail the cheap way before paying for the expensive way.
Making it feel instant: optimistic writes and rollback
That pipeline takes a round-trip to the server, and users hate waiting for a button. So the UI plays a trick — the same one the playground uses when you acknowledge an alert.
The UI applies the edit optimistically — it shows the alert as acknowledged the instant you click, before the server has answered. Then one of two things happens:
- Accepted. The server's committed edit quietly replaces the optimistic one. The user never saw a spinner; the change was real all along.
- Rejected. A check failed — say you assigned a worker whose cert expired. The optimistic edit is rolled back: the row snaps back to its old value, nothing reaches the writeback store, and no audit entry is written. The user sees the rejection. (Try it in the playground — acknowledge with the expired-cert worker and watch the row revert.)
👩💻 Mai: So the optimistic update is basically a lie we tell the screen?
👨🏫 Hamed: A hopeful bet. It's right the overwhelming majority of the time, so the UI feels instant. When it's wrong, you undo it cleanly and tell the truth. The honesty lives in the rollback — the optimistic edit only ever existed on the client, and nothing touched writeback or the audit log until the server said yes.
One action, many edits — all or nothing
A real-world change is rarely one field. Cancelling an order isn't just status = cancelled — it also restocks the inventory and creates a refund. An action bundles all of those into one transaction: every edit commits together, or none do.
This is why the action, not the field, is the unit of change: it groups the edits that have to stay consistent with each other. You never end up with a cancelled order that has no refund, or restocked inventory for an order that's still active. The batch is atomic.
👩💻 Mai: What if two of us cancel the same order at the same moment?
👨🏫 Hamed: The check runs against the current state each time. The first one wins; the second one finds the order already cancelled and gets rejected. A stale edit — one based on a version of the world that's already moved on — is caught at the check, not blindly applied. The world stays consistent.
After the commit: the ripples
A committed action isn't the end of the story — it's a stone in a pond. Once those edits land:
- The serving index updates, so the next object set you build in Part 3 sees the change.
- Automations may fire — Part 1's condition→effect rules. A new refund might notify the finance team; a critical alert might page a supervisor.
- Derived properties recompute — Part 4's
daysOpen, ariskScore— because their inputs just changed. - Lineage records it. The audit entry plus the writeback overlay mean the path from "today's value" back to "who changed it, from what, when" is always traceable.
So a single write fans out into reads, automations, and recomputations — all of them downstream of one named, audited action.
The one-paragraph summary
You never write to the data directly — you propose a named action. It's validated (is the request well-formed?), then checked (are you allowed, and does the world permit it right now?), and only then are its edits committed to the writeback store, leaving your source data pristine, and recorded in an audit log. The UI applies the change optimistically so it feels instant, and rolls it back cleanly if the action is rejected — nothing reaches writeback or audit until the server says yes. One action can bundle many edits that commit all-or-nothing, and once committed, the change ripples out to reads, automations, and recomputed values. Part 3 was the read verb; Part 5 is the write verb. Learn both and you can drive any ontology — which is exactly the ground the frontend stands on.
That's reads and writes both. Caught up on the rest? Part 1 (the big picture), Part 2 (reuse and agents), Part 3 (how you read), Part 4 (objects, properties, links up close). Next, the capstone: with the read and write models in hand, how you build a frontend on top of an ontology — the builder that edits definitions, the client that edits data, and the one foundation they share.