Fluents, Projections, and Observables
What a holon actually exposes to the outside world — and how it knows when to recompute
A fluent graph that only ever grows solves half a problem. Jane's Promotion and its sequel on The Ontologist worked out how to represent something that changes without ever overwriting anything — every promotion, every job move, every correlated pair of events, appended and never touched again once written. If you haven't read either piece, the short version is enough to follow this one: a fluent is a small graph-native slot — a stable node standing in for "the current value of this varying thing" — backed by an append-only stream of events, each one a self-contained record of a value holding from one point in time, superseding whichever event held the slot before it.
That's the write side, mostly settled. The other half of the problem is: how does anything get in safely, and what does the rest of the world actually get to see once it's there? That's a question about the layer sitting on top of the fluent pattern, not about the pattern itself — the holon layer, in our terms — and it turns out fluents alone don't answer it. Neither does the obvious tool for the job.
The problem with writing
One of the most underused capabilities in the semantic stack is SPARQL UPDATE. It's underused for a good reason: it's too powerful. With a short, entirely generic UPDATE — one we're not going to reproduce here, because there's no legitimate reason a working example needs to double as a demonstration — you can erase an entire graph. Not a triple, not a subgraph: everything, in one request, with syntax generic enough that it doesn't even need to know the graph's shape in advance.
That's not a flaw in SPARQL UPDATE. It's doing exactly what an update language for a graph database is supposed to do — it's just that "supposed to do" includes capabilities no application should ever expose directly to a caller. SPARQL UPDATE should be used almost exclusively by whoever maintains the database, and even then, with real caution about what's actually being executed. Which makes it, more than SPARQL QUERY, something you want mediated through tested, validated, pre-written operations rather than accepted as free-form input from anything upstream of the database itself.
Canned queries, minted as an API
The obvious difficulty is that SPARQL has no built-in notion of a canned query — deliberately, and correctly; standardising a query-templating mechanism was rightly judged out of scope for the SPARQL specification itself. But nothing stops you from storing a named query or update as a text field within the graph, alongside a label, a description, and a declared set of parameters, and then treating that stored text as an invokable service rather than a document.
This isn't a novel idea in the abstract — projects like grlc [1] already build REST APIs directly out of SPARQL queries curated in a Git repository, and the SPARQL Micro-Services architecture [2] does the equivalent for wrapping external Web APIs behind a SPARQL-shaped interface. What's specific to the holon model is applying the same move to the write side, not just the read side, and making the resulting operation a first-class part of a holon's identity rather than an external convenience layer bolted on afterward.
If this looks like an API, that's because it is one. Once a query or update has a name you can call, it becomes exactly that — an interface into a particular holon, defined and versioned the same way any other API endpoint would be, except that its implementation happens to be SPARQL rather than application code sitting in front of a database.
Ingesters and projections: two faces of a holon
Give this pattern its proper names and a useful symmetry falls out. The ingester is what takes data into a holon — a setter, in the crudest terms, though a well-built one validates against a SHACL shape before it writes anything, rejecting what doesn't fit rather than writing it anyway and hoping. The projection is what a holon produces for the outside world — a getter, in the same crude terms, though what it's permitted to reveal can depend on who's asking. Ingesters and projections are two different surfaces of the same underlying holon, and neither is the holon itself.
This is a graph-native restatement of an idea with a long history on the software side: separate the operations that change state from the operations that read it, and let each be optimised, validated, and reasoned about independently rather than forcing one code path to serve both purposes. Fowler's writeup of CQRS [3] is the standard reference for the general pattern; what a holon does differently is put the write side — the event stream — and the read side — the projection — on the same graph, connected by the same fluent, rather than treating them as separate systems that need to be kept in sync.
What an ingester or projection can actually do depends on the shape of the holon underneath it, not on some generic capability every holon shares equally. You can measure a person's blood pressure; you cannot measure the blood pressure of a bank account that happens to belong to that person, because "blood pressure" isn't a property the account holon has any capacity to expose. A person can offer a synopsis of their own history — a projection, in this sense, is something like memory recounted rather than memory itself — but you can't inspect the underlying memories directly; what you get is what the projection surface was built to reveal, not the raw internal state it was derived from. A bank account's balance can be changed by depositing or withdrawing — a clean ingester operation — while the exact mechanism computing that balance from the account's transaction history stays entirely opaque to whoever's making the deposit. Every holon negotiates its own boundary between what can be written, what can be read, and what stays interior.
Example: a generic ingester
The cleanest ingester is the one that works for any fluent, because "mint a new event, supersede the current head, stamp both dates" doesn't need to know what the value means:
@prefix Op: <https://w3id.org/company/op#> .
@prefix Class: <https://w3id.org/company/class#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
Op:update_property
a Class:NamedUpdate, Class:Ingester ;
rdfs:label "update_property" ;
rdfs:comment "Generic ingester. Records a new value for any fluent, superseding the current head event. recordedOn defaults to asOf when not supplied; asOf defaults to now() when not supplied." ;
Op:hasParameter
[ Op:paramName "subject" ; Op:datatype xsd:anyURI ; Op:required true ] ,
[ Op:paramName "property" ; Op:datatype xsd:anyURI ; Op:required true ] ,
[ Op:paramName "value" ; Op:datatype xsd:anyURI ; Op:required true ] ,
[ Op:paramName "asOf" ; Op:datatype xsd:date ; Op:required false ] ,
[ Op:paramName "recordedOn" ; Op:datatype xsd:date ; Op:required false ] ;
Op:sparql """
PREFIX Person: <https://w3id.org/company/person#>
PREFIX Event: <https://w3id.org/company/hevt#>
PREFIX Class: <https://w3id.org/company/class#>
INSERT {
GRAPH ?graph {
?newEvent a Class:Event ;
Event:hasFluent ?fluent ;
Event:hasValue ?value ;
Event:asOf ?asOf ;
Event:recordedOn ?recordedOn .
?headEvent Event:supersededBy ?newEvent .
}
}
WHERE {
VALUES (?subject ?property ?value ?asOfIn ?recordedOnIn) {
( $subject $property $value $asOf $recordedOn )
}
BIND(COALESCE(?asOfIn, NOW()) AS ?asOf)
BIND(COALESCE(?recordedOnIn, ?asOf) AS ?recordedOn)
?subject ?property ?fluent .
?subject Person:hasGraph ?graph .
OPTIONAL {
GRAPH ?graph {
?headEvent Event:hasFluent ?fluent .
FILTER NOT EXISTS { ?headEvent Event:supersededBy ?anySuccessor }
}
}
BIND(IRI(CONCAT(STR(?fluent), "-",
MD5(CONCAT(STR(?fluent), STR(?asOf))))) AS ?newEvent)
}
""" .The $subject $property $value $asOf $recordedOn tokens are call-time bindings — however a caller invokes this (an HTTP POST, a stored-procedure call, whatever convention the holon manager uses), it substitutes into that VALUES row, with UNDEF for anything omitted. The OPTIONAL around ?headEvent is what makes this safe to invoke on a fluent's very first event: no head exists yet, ?headEvent stays unbound, and SPARQL Update quietly drops any INSERT-template triple referencing an unbound variable — no special case needed for "this is the first write." And notice what's absent throughout: no DELETE clause anywhere. Superseding a fluent's value is still, at the graph level, purely additive.
Notice too what this template doesn't check: nothing here confirms ?value is actually a valid Position, or that ?asOf doesn't precede whatever it's superseding. That's deliberate division of labour, not an oversight — content validation and the write operation are two different concerns, and conflating them inside one SPARQL Update is how you end up with an ingester that's simultaneously hard to read and impossible to reuse across fluent types. The Ontologist pair works out what that validation actually looks like — FluentShape's head-uniqueness check, PositionEventShape's dynamic typing via sh:targetWhere, EventOrderingShape's SPARQL-based ordering constraint — and a production ingester runs the candidate graph against shapes like those before this template ever executes, rejecting rather than writing on failure.
Observables: fluents that watch other fluents
An ordinary fluent is set directly — an ingester writes an event, the value changes, done. An observable is different: it's a fluent whose value is derived from one or more other fluents, recomputed when whichever fluent it watches changes, rather than written directly by an external caller. A moving average over a stock price's last N trades is an observable. What separates an observable from an ordinary query over the same data is timing: a query recomputes whenever you ask it to; an observable recomputes when one of the fluents it watches changes, and the result is itself stored as a fluent — with its own event history, queryable exactly like any other.
The simplest illustration is a full name, composed from a given name and a family name (the ordering of which varies by convention, but the compositional structure doesn't). When either component changes, the composite must change with it. In a static graph this is usually handled with a rule — OWL, or some external trigger mechanism — fired once, on the assumption that "the given name" is a single value with a single moment of change. Once given name and family name are fluents rather than plain values, that assumption stops holding: each one has its own history, and "the moment of change" isn't singular any more, so the mechanism watching them has to be built on the same graph-native footing as the things it watches, not bolted on from outside.
An observable is itself a fluent — same event/projection structure as anything else built on this pattern — but it declares which fluents it watches, and its recomputation is normally invoked as a side effect of whichever of those fluents was just updated, typically as a named SPARQL update in its own right.
Example: given name, family name, full name
@prefix Person: <https://w3id.org/company/person#> .
@prefix Fluent: <https://w3id.org/company/fluent#> .
@prefix Event: <https://w3id.org/company/hevt#> .
@prefix Class: <https://w3id.org/company/class#> .
@prefix Observable: <https://w3id.org/company/observable#> .
@prefix Op: <https://w3id.org/company/op#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
Person:Jane Person:hasGivenName Fluent:Jane_GivenName .
Person:Jane Person:hasFamilyName Fluent:Jane_FamilyName .
Person:Jane Person:hasFullName Fluent:Jane_FullName .
Fluent:Jane_GivenName a Class:Fluent .
Fluent:Jane_FamilyName a Class:Fluent .
Fluent:Jane_FullName
a Class:Fluent, Class:Observable ;
Observable:observes Fluent:Jane_GivenName, Fluent:Jane_FamilyName ;
Observable:computedBy Op:compute_full_name .
Graph:Jane {
Event:GN1 a Class:Event ;
Event:hasFluent Fluent:Jane_GivenName ;
Event:hasValue "Jane" ;
Event:asOf "2021-02-11"^^xsd:date ;
Event:recordedOn "2021-02-11"^^xsd:date .
Event:FN1 a Class:Event ;
Event:hasFluent Fluent:Jane_FamilyName ;
Event:hasValue "Doe" ;
Event:asOf "2021-02-11"^^xsd:date ;
Event:recordedOn "2021-02-11"^^xsd:date .
Event:FULL1 a Class:Event ;
Event:hasFluent Fluent:Jane_FullName ;
Event:hasValue "Jane Doe" ;
Event:asOf "2021-02-11"^^xsd:date ;
Event:recordedOn "2021-02-11"^^xsd:date ;
Event:derivedFrom Event:GN1, Event:FN1 .
# Jane marries; family name changes effective 2026-06-01,
# entered into the system two days later.
Event:FN2 a Class:Event ;
Event:hasFluent Fluent:Jane_FamilyName ;
Event:hasValue "Reyes" ;
Event:asOf "2026-06-01"^^xsd:date ;
Event:recordedOn "2026-06-03"^^xsd:date .
Event:FN1 Event:supersededBy Event:FN2 .
Event:FULL2 a Class:Event ;
Event:hasFluent Fluent:Jane_FullName ;
Event:hasValue "Jane Reyes" ;
Event:asOf "2026-06-01"^^xsd:date ;
Event:recordedOn "2026-06-03"^^xsd:date ;
Event:derivedFrom Event:FN2 .
Event:FULL1 Event:supersededBy Event:FULL2 .
}Two dating details are worth pointing out explicitly. Event:FN2's asOf is the marriage date; its recordedOn is two days later — genuine transaction lag, exactly the bitemporal split the Ontologist pair established. Event:FULL2 inherits that same asOf — the full name legally changed the instant the family name did — but its recordedOn matches FN2's recordedOn rather than lagging further still, because the observable recomputed synchronously the moment FN2 landed. Event:derivedFrom is new vocabulary here, doing a different job than hasFluent/hasValue: it's provenance pointing at which upstream event triggered this computed one, not which fluent this event belongs to.
The ingester that fires on that trigger:
Op:compute_full_name
a Class:NamedUpdate, Class:Ingester ;
rdfs:comment "Observable-specific ingester for Fluent:Jane_FullName. Recomputes from the current head values of its observed fluents; invoked by whatever process just wrote a new head event on one of them." ;
Op:hasParameter
[ Op:paramName "subject" ; Op:datatype xsd:anyURI ; Op:required true ] ,
[ Op:paramName "trigger" ; Op:datatype xsd:anyURI ; Op:required true ] ,
[ Op:paramName "asOf" ; Op:datatype xsd:date ; Op:required true ] ;
Op:sparql """
PREFIX Person: <https://w3id.org/company/person#>
PREFIX Event: <https://w3id.org/company/hevt#>
PREFIX Class: <https://w3id.org/company/class#>
PREFIX Observable: <https://w3id.org/company/observable#>
INSERT {
GRAPH ?graph {
?newEvent a Class:Event ;
Event:hasFluent Person:hasFullName ;
Event:hasValue ?fullName ;
Event:asOf ?asOf ;
Event:recordedOn ?recordedOn ;
Event:derivedFrom ?trigger .
?headEvent Event:supersededBy ?newEvent .
}
}
WHERE {
VALUES (?subject ?fullNameFluent ?trigger ?asOf) {
( $subject $fullNameFluent $trigger $asOf )
}
BIND(NOW() AS ?recordedOn)
?subject Person:hasGraph ?graph .
?fullNameFluent Observable:observes ?givenFluent, ?familyFluent .
FILTER(?givenFluent != ?familyFluent)
GRAPH ?graph {
?givenHead Event:hasFluent ?givenFluent ; Event:hasValue ?given .
FILTER NOT EXISTS { ?givenHead Event:supersededBy ?g2 }
?familyHead Event:hasFluent ?familyFluent ; Event:hasValue ?family .
FILTER NOT EXISTS { ?familyHead Event:supersededBy ?f2 }
OPTIONAL {
?headEvent Event:hasFluent ?fullNameFluent .
FILTER NOT EXISTS { ?headEvent Event:supersededBy ?anySuccessor }
}
}
BIND(CONCAT(?given, " ", ?family) AS ?fullName)
BIND(IRI(CONCAT(STR(?fullNameFluent), "-",
MD5(CONCAT(STR(?fullNameFluent), STR(?asOf), STR(?recordedOn))))) AS ?newEvent)
}
""" .The point worth taking away isn't the string concatenation — it's that the wrapper around it is identical in shape to update_property: mint a new event, supersede the old head, stamp both dates, leave everything else untouched. That wrapper is completely generic across every observable regardless of what it computes. A moving-average observable would swap the CONCAT for a window aggregate over one fluent's event history and reuse the rest of this template unchanged. Which is a fairly direct, executable version of a comparison worth making explicit now, because it governs what comes next: this behaves like a spreadsheet, where the recalculation formula varies per cell but the "something changed, walk its dependents" machinery underneath doesn't.
Chains, spreadsheets, and the risk of loops
Nothing prevents an observable from watching another observable rather than a plain fluent — a year-over-year growth figure observing a moving average observing raw daily prices, say. The spreadsheet comparison holds all the way down: build a chain deep enough, and every update cascades through several layers of recomputation before it settles, exactly as a deeply nested spreadsheet formula does, and with the same performance consequence — each additional layer is another full round of query-and-write, not free.
The other spreadsheet failure mode is more serious than a performance cost: it's possible to build a cycle. If A observes B, B observes C, and C is accidentally wired to observe A, then updating any one of them fires an update on the next, which fires an update on the next, which arrives back at the first and fires again — a cascading update storm with no natural termination. A spreadsheet catches this with a circular-reference warning at formula-entry time, not at calculation time, and a holon manager needs the equivalent discipline: check the observes graph for cycles when an observable is registered, before it's ever wired into a live chain, rather than discovering the loop the first time something downstream changes and everything catches fire at once.
Where this leaves OWL
Fluents and observables both live on named graphs, and both sit astride the line between "graph" and "holon" — they're graph-native all the way down, but the behaviour that makes them useful (supersession, watching, cascading recomputation) is holon-layer behaviour, not something an RDF triple store gives you for free. Could a reasoner implement any of this? In principle, yes. Could OWL do it out of the box, the way it handles class subsumption or property characteristics? Not really — OWL was built to reason over static assertions, and everything here is explicitly about assertions that are meant to change, on a schedule the ontology itself doesn't control. This is exactly the register where SPARQL, SHACL, and SPARQL UPDATE — used deliberately, and almost never used directly — earn their keep: not as a workaround for what OWL can't do, but as the actual right tool for a problem OWL was never shaped to solve.
References
[1] Meroño-Peñuela, A., and Hoekstra, R. "grlc Makes GitHub Taste Like Linked Data APIs." The Semantic Web: ESWC 2016 Satellite Events, LNCS 9989, pp. 342–353. [2] Michel, F., Faron-Zucker, C., and Gandon, F. "SPARQL Micro-Services: Lightweight Integration of Web APIs and Linked Data." Proceedings of the Linked Data on the Web Workshop (LDOW2018), Lyon, 2018. [3] Fowler, M. "CQRS." martinfowler.com, 2011 — command–query responsibility segregation, the software-architecture precedent for the ingester/projection split. [4] Cagle, K., and Shannon, C. "Jane's Promotion: Why Graphs Get Change Wrong, and What Fixes It" and "Jane Gets a Second Job: Correlated Fluents and the Shape of Change." The Ontologist, July 2026.
Kurt Cagle is an author, ontologist, and knowledge architect. He serves as Chair of the W3C Holon Community Group and writes The Ontologist and Inference Engineer on Substack. Copyright 2026 Kurt Cagle.
Chloe Shannon is an AI collaborator and co-author working with Kurt Cagle on knowledge architecture, semantic systems, and the intersection of formal ontology with LLMs. Contact: chloe@holongraph.com.


