Your agent needs a ledger, not a better prompt
My scheduled job published the same item twice. The model was wrong about state, not language. The fix is an ontology, checked as a ledger before side effects.
A scheduled job in my system published the same item twice, an hour apart. The model was right about language and wrong about state, so no prompt could stop it. I argue the fix is an ontology: declared classes, relationships and constraints, checked as a ledger before any side effect runs.
1. The job that ran twice
A scheduled job in my own system published the same piece of content twice, an hour apart, to the same channel. The job was simple. It picked the next unpublished item, called an API, marked the item done. The API call succeeded. The write recording that success did not. The next run looked at the queue, saw an item still marked “pending”, and did exactly what it was told to do.
Nothing about that failure lives in language. The model driving the job did not misunderstand the instruction, hallucinate a channel, or invent a caption. It read a state that was true in the store and false in the world, and acted correctly on a false state. A longer system prompt with a firm paragraph about not posting duplicates changes nothing. At the moment of the second call there was no duplicate anywhere the model could see. The duplicate existed only in the outside world, which the model does not read.
The class of bug is worth naming carefully: the model was not wrong about language, it was wrong about state. Prompt engineering is the discipline of getting a model to be right about language, and it has no purchase on the second problem. Every mitigation reached for first (retry limits, idempotency keys, a stern instruction) is either a patch on one code path or a request that the model be careful. Neither is a rule the system can enforce.
What would have caught it is dull and structural. A publication event has exactly one target item. An item can be published at most once. If the store cannot hold “at most once” as a fact about the domain rather than as an if-statement inside one script, every new caller reintroduces the bug. The fix is a place where the rules of the domain are written down as data. It also needs a step in the loop that consults that place before anything irreversible happens.
That place has a name. It is an ontology, and most engineers building agentic systems already have three quarters of one without calling it that.
2. What an ontology actually is
An ontology is a description of the entities in a domain, the properties those entities can have, and the relationships they can hold with each other. That is the entire definition. Everything else is notation.
The word carries more freight than it deserves because of where it comes from. Aristotle’s categories enumerated the ways a thing can be said to exist: substance, quantity, quality, relation, and the rest. Willard Van Orman Quine dragged that into logic with a criterion sharp enough to compute against. What a theory says exists is whatever its quantified variables must range over for its statements to be true. Ontology stopped being a question about being in general and became a question about the commitments of a particular description. Then in 1993 Tom Gruber pulled it into engineering, defining an ontology as an explicit specification of a conceptualization. The phrase most people quote today, a formal specification of a shared conceptualization, is the refinement that stuck. The two extra words carry the weight: formal, meaning a machine can check it, and shared, meaning more than one program agrees to it.
That is the artefact you want to hand an agent. Not a description of the world, which is not your problem, but your conceptualization of your domain. That means the handful of kinds of thing your system deals with, the properties each may carry, and the relationships that are legal between them. Suppose your domain has never been written down anywhere except in the heads of the people who wrote the scripts. Then the agent is guessing at a specification that does not exist, and so is every human who joins the project.
3. Why graphs turned up
Relational tables are the obvious place to put this, and for most of what any system does they remain the right answer. Constraints, transactions and joins over stable shapes are what relational engines are exceptionally good at. A schema is itself a kind of ontology: tables are classes, columns are properties, foreign keys are relationships. Nobody should abandon that because a graph sounds more modern.
The friction shows up when the shape is not stable. A new kind of fact means a new column and a decision about what it holds for every row that will never have a value. Then comes a migration, and an edit to the code that reads the table. A new kind of relationship usually means a new join table. Neither is hard, but both ripple, and the cost is paid every time the domain grows a wrinkle. Where the domain is still being discovered, and an agent’s domain almost always is, that is weekly.
A graph inverts the default. The unit is a statement about one thing, so a new property or relationship is another statement added beside the existing ones, touching nothing already there. Entities that have a property carry it, those that do not simply do not, and the absence costs nothing. This is fit for purpose, not superiority: you trade strong guarantees about shape for freedom to change shape without a migration. Most working systems want both, and the real question is not which store wins but where the rules live.
4. The layer people miss
Here is the part that gets skipped. A graph on its own is only assertions. It says Bob teaches Scooter. It does not know what “teaches” means, what may sit on either end of it, or what else follows from it being true. RDFS and OWL are the vocabularies that let you say those things, and they sit beside the graph rather than inside it. They are where derivation and constraint live.
Domain and range. In RDFS you declare that teaches has domain Teacher and range Student. The single assertion “Bob teaches Scooter” now carries two more facts for free: Bob is a Teacher, Scooter is a Student. Nobody wrote those. They are entailed by the meaning of the property. The declaration is the definition of the edge, and every use of that edge inherits it.
Transitive properties. Mark hasAncestor as an owl:TransitiveProperty. Assert that Rita has ancestor Mo, and Mo has ancestor Jill, and the ancestor of an ancestor is derivable without anyone writing a traversal by hand. One declaration replaces a class of code.
Functional properties. Mark hasBiologicalMother as an owl:FunctionalProperty and you have said it takes at most one value. That does something stranger than rejecting a second value. If the graph says Kim’s biological mother is Mary, and also Maria, the reasoner does not throw an error. It concludes Mary and Maria are the same individual under two names. Identity resolution falls out of a constraint rather than a matching script.
Disjointness and enumerated ranges. Declare Person owl:disjointWith Product and no individual can be both, ever: a graph claiming otherwise is inconsistent, not merely odd. Constrain a status property to an enumerated set and any other value is not an unusual status, it is a contradiction.
Now translate each into an agent that touches money. A refund action whose “refunds” edge is functional cannot produce a second refund on one order. The second assertion collapses into the first or blows up as inconsistent, rather than quietly issuing another payment. Take a payout edge whose range is Buyer, with Buyer disjoint from SupportAgent. It cannot route money to the support representative who happened to be the last entity mentioned in the conversation. A status property with an enumerated range cannot hold “probably shipped”. That is exactly the plausible, well formed, entirely invented value a language model produces when asked to fill a field it has nothing to fill.
None of these are prompt problems. All of them are declarations, and every one of them is checkable by a machine before the side effect happens.
5. Pydantic at the door, ontology at the ledger
It helps to be honest about what an agent actually does. The model cannot execute anything. It emits text, and some of that text is shaped like a request: here is a tool name, here are parameters. Everything after that happens because code you wrote decided to honour the request. The agent’s power is entirely borrowed from the loop around it, so the loop is where safety goes. It has two checkpoints that people routinely collapse into one.
The first is the door. A schema validator (Pydantic in Python, or your language’s equivalent) checks that the proposed call is well formed. The fields exist, the types are right, the enum member is a real member. Cheap, early, and blind to your domain, because it has never heard of your domain. refund(order_id="A-1", amount=40.00) is perfectly valid at the door whether or not A-1 was already refunded yesterday.
The second is the ledger. Before committing, you express the proposed action as the statements it would add to your graph and check those against the declared rules. Does it violate a functional property? Put an entity on the wrong end of an edge? Make two disjoint classes overlap? Only if the answer is no does the side effect happen. Frank Coyle, who teaches computer science at Berkeley, frames the loop this way in his talks on agents and ontologies. The model proposes, types are validated at the boundary, and the result is validated against the domain before anything is committed.
The rule is short. An agent should have no side effects until its proposed action has passed the ledger.
proposal = model.propose_tool_call(context) # text, nothing has happened yet
call = ToolCall.model_validate(proposal) # door: shape and types
facts = as_statements(call) # what this action would assert
violations = ledger.check(facts) # domain rules, not types
if violations:
return retry_with(violations) # the model sees why, in domain terms
result = execute(call) # the only line with a side effect
ledger.commit(facts, result) # the record IS the guard next time
The last two lines are where my duplicate publication died. The commit is not bookkeeping after the fact, it is the input to the next run’s check.
6. You probably already have one
Almost nobody who needs an ontology starts from zero. They start from one written in a form that cannot be reasoned over, unrecognised because it is scattered across config files and validation code.
Look for these four, because they are always there. A controlled vocabulary in a config file, the allowed values for a “kind” or “type” field, is a class hierarchy flattened into a list. A fold table mapping variants onto a canonical term, “howto” and “how-to” and “guide” all onto guide, is a set of rdfs:subClassOf statements in a dictionary literal. An alias field is skos:altLabel. A “source” or “derived from” field is dcterms:source and prov:wasDerivedFrom, doing provenance whether or not anyone used the word.
My own vault is the example I know best: 5,187 markdown files, each carrying a required type field constrained to a controlled enum of 21 values. Beside that sits a fold table mapping common variants onto canonical members. A lint gate fails a commit if a file’s type is not in the enum. That is a class hierarchy with an enforced range check, running in CI, built without the word ontology appearing once.
And it did not save me from anything, because the classes were the easy half.
When I audited the task state in that same vault I found 1,589 open tasks across 126 files. One file whose heading says “max 3 priorities” was holding 113 open items. The same task, in my own words, was written out in three separate files with nothing connecting the three lines. Every one of those files passed the lint gate. Every one had a valid type. The classes were declared, enforced and correct.
What was missing was the edges. Nothing in the system could express “this line and that line are the same task”, so nothing could count them once. The controlled vocabulary told me what kind of thing each file was. It could say nothing at all about how any two things related, which is where every interesting question lives and where every duplicate hides.
That is the shape of the gap. Most systems declared their classes, because a validator forced them to. Almost none declared their relationships, because nothing forces you to until an agent starts acting on them.
7. What I built, and the threshold
I should confess the failure before the fix, because the failure is more instructive.
In August I built a knowledge graph layer for this vault. Seven scripts and 3,897 vectors in a vector database. It worked, in the sense that it ran and produced output. Nothing scheduled it. Nothing consumed it. No part of the system asked it a question it alone could answer. It was dead within three weeks, and it went quiet rather than breaking, which is worse, because nothing alerted. The lesson generalises past graphs: a component with no named consumer is not an asset, it is future cleanup with a good story attached.
What replaced it is smaller and duller. One authored file holds the contract. It has the classes, the typed edges (what may connect to what), and the constraints (which edges are functional, which classes disjoint, which properties enumerated). Beside it sits one derived table, rebuilt from the markdown on every run and never hand edited, because authored and derived data must never mix. Every task carries two identities: one for this task as written in this file, one for this task wherever it appears. Exact duplicates now fall out of a group by, which is not clever, and that is the recommendation. The interesting part was never the query.
So if a table and a group by solve it, when do you actually need the full RDFS and OWL stack?
Four conditions, and it is worth being strict about them. When you need to merge an ontology published by someone else, because that is what the standard formats exist for. Rebuilding their model by hand is the expensive path. When you need open world reasoning over incomplete facts, where “not asserted” must mean unknown rather than false, which relational thinking gets backwards by design. When a third party will consume your graph, because then the shared half of Gruber’s definition is doing real work and private conventions stop being enough. And when an agent takes an irreversible action that a declared constraint could have blocked. That last one is what promoted this from an interesting topic to a thing I had to build.
Note what is not on that list. Transitive closure is a recursive query. Subsumption is a join against a hierarchy table. Consistency checking is a set of assertions on a schedule. All three are answerable in SQL, so none is a reason on its own to adopt a reasoner. Anyone selling you a triple store on the strength of transitive closure is selling you a WITH RECURSIVE you already have.
The move that pays regardless of engine is underneath all of this. Write your constraints down as data instead of scattering them through code. Once the rules of your domain live in one declared place a program can read, adding a check is an edit to one file. It is no longer a change to five call sites, and the choice of engine becomes a later decision, made with evidence, and cheap. Leave the constraints in the code and every new caller is another opportunity to publish the same thing twice.