Six questions an industrial twin should answer, and how TvennDB answers them
A hands on tour of TvennDB Free on a small plant: what stops when a robot stops, where a spare part is used and whether stock covers it, which machines are due for service, what changed and why, what the plant looked like before, and how to give a contractor or an AI assistant exactly the view they need. Every query runs as written.
Hubbiot AB5 min read
A digital twin earns its place on the day something goes wrong. A robot trips, a gearbox is late from the supplier, an auditor asks who changed a setting and why. The twin is worth having if it answers those questions in seconds, from one consistent model of the plant, and if it can show what the plant looked like before the change.
TvennDB is the graph database Hubbiot AB built for exactly that job. This post walks through a small plant model and six questions it answers, with the GQL behind each one. Every query below was run against TvennDB Free as written, and the results shown are the ones it returned. The whole walkthrough is also a single script in the repository: examples/python/plant_twin.py.
Start TvennDB in one container
TvennDB Free is free to use, in production too. The repository on GitHub holds compose files, Helm values and examples:
git clone https://github.com/hubbiot/tvenndb.git && cd tvenndb/compose
cp .env.example single/.env # set TVENNDB_ADMIN_PASSWORD
cd single && docker compose up -dThe Explorer is at http://localhost:7000/ui/: sign in as admin and paste any query from this post into its query editor. Applications use the HTTP API: sign in once, then send each query as JSON to POST /v1/graphs/plant/query.
TOKEN=$(curl -s -X POST localhost:7000/auth/login -H 'content-type: application/json' \
-d '{"username": "admin", "password": "<your password>"}' | jq -r .token)
curl -s -X POST localhost:7000/v1/graphs/plant/query \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"query": "MATCH (r:Robot) RETURN r.serial, r.hours ORDER BY r.hours DESC LIMIT 3"}'First, model the plant, and let the model say no
A twin is only as trustworthy as what it accepts. Every graph in TvennDB has an ontology: labels with typed properties and a key, and relations with the labels allowed at each end. Here sites contain lines, lines contain robots, robots feed each other, and robots use spare parts in a given quantity.
{
"labels": {
"Site": {"key": "code", "properties": {
"code": {"type": "string", "required": true}, "name": {"type": "string"}}},
"Line": {"key": "code", "properties": {
"code": {"type": "string", "required": true}, "name": {"type": "string"}}},
"Robot": {"key": "serial", "properties": {
"serial": {"type": "string", "required": true},
"model": {"type": "string"},
"status": {"type": "string", "values": ["running", "idle", "maintenance"]},
"hours": {"type": "float", "min": 0, "index": true},
"notes": {"type": "string", "fulltext": true}}},
"Part": {"key": "number", "properties": {
"number": {"type": "string", "required": true},
"name": {"type": "string"},
"stock": {"type": "int", "min": 0}}}
},
"relations": {
"HAS_LINE": {"from": ["Site"], "to": ["Line"], "containment": true},
"HAS_ROBOT": {"from": ["Line"], "to": ["Robot"], "containment": true},
"FEEDS": {"from": ["Robot"], "to": ["Robot"]},
"USES": {"from": ["Robot"], "to": ["Part"],
"properties": {"qty": {"type": "int", "min": 1}}}
}
}Containment does more than describe the plant. A robot lives in the partition of its site, so a site can be placed, backed up and access controlled as one unit. Anything that does not fit the ontology is refused before it reaches the data, with a reason a person can act on:
MATCH (r:Robot {serial: 'r4'}) SET r.status = 'broken'422 ontology violation: 'status' = "broken" is not one of the allowed valuesOne INSERT builds the rest: two sites, three lines, six robots, the material flow between them, and two spare parts with the robots that use them. Every write is a commit, so the import carries a message and tags of your own choosing, here {"source": "erp-import"}.
INSERT (n:Site {code: 'north', name: 'Plant North'}),
(n)-[:HAS_LINE]->(body:Line {code: 'north-body', name: 'Body shop'}),
(body)-[:HAS_ROBOT]->(r1:Robot {serial: 'r1', model: 'KR-210', status: 'running',
hours: 4620.0, notes: 'Gearbox vibration above limit at last inspection'}),
(body)-[:HAS_ROBOT]->(r2:Robot {serial: 'r2', model: 'KR-210', status: 'running',
hours: 4310.0}),
(r1)-[:FEEDS]->(r2),
(g:Part {number: 'G-200', name: 'Gearbox G-200', stock: 2}),
(r1)-[:USES {qty: 1}]->(g), (r2)-[:USES {qty: 1}]->(g)1. If this robot stops, what stops with it?
Material flows from robot to robot and from line to line. When one stops, everything downstream of it stops too, however many steps away. A GQL quantifier follows FEEDS one or more steps, and the line of each robot reached tells the shift lead which areas go quiet:
MATCH (r:Robot {serial: 'r1'})-[:FEEDS]->+(d:Robot)<-[:HAS_ROBOT]-(l:Line)
RETURN l.name AS line, collect(d.serial) AS stops ORDER BY lineline stops
Body shop ["r2"]
Paint shop ["r3", "r4"]And the route itself, for whoever has to explain why the paint shop stopped when the body shop did:
MATCH p = shortestPath((a:Robot {serial: 'r1'})-[:FEEDS*]->(b:Robot {serial: 'r4'}))
RETURN [n IN nodes(p) | n.serial] AS route, length(p) AS hopsroute hops
["r1", "r2", "r3", "r4"] 32. Where is this part used, and does stock cover it?
A supplier announces that gearbox G-200 will be eight weeks late. Which machines depend on it, where are they, and is there enough on the shelf?
MATCH (s:Site)-[:HAS_LINE]->(l:Line)-[:HAS_ROBOT]->(r:Robot)
-[u:USES]->(p:Part {number: 'G-200'})
RETURN s.name AS site, l.name AS line, r.serial AS robot, u.qty AS qty
ORDER BY site, robotsite line robot qty
Plant North Body shop r1 1
Plant North Body shop r2 1
Plant South Packaging r5 1MATCH (r:Robot)-[u:USES]->(p:Part)
WITH p, sum(u.qty) AS installed, count(r) AS robots
WHERE installed > p.stock
RETURN p.name AS part, robots, installed, p.stock AS in_stockpart robots installed in_stock
Gearbox G-200 3 3 2Three robots across two sites depend on a part with two in stock. The same walk, from the thing that changed to everything that depends on it, answers a recall, a supplier risk review or a software rollout: change the starting point, keep the pattern.
3. Which machines are due for service?
Operating hours carry an index, so on a large fleet the planner can start from the robots past the threshold rather than walk every site; EXPLAIN in front of a query shows the plan it chose. Grouping by site turns the list into a plan for the week:
MATCH (s:Site)-[:HAS_LINE]->(:Line)-[:HAS_ROBOT]->(r:Robot)
WHERE r.hours > 4000
RETURN s.name AS site, count(r) AS due, collect(r.serial) AS robots ORDER BY due DESCsite due robots
Plant North 2 ["r1", "r2"]
Plant South 2 ["r5", "r6"]A line with two robots due at the same time is a line to plan one stop for, not two. A COUNT subquery asks that of every line:
MATCH (l:Line)
WHERE COUNT { (l)-[:HAS_ROBOT]->(r:Robot) WHERE r.hours > 4000 } >= 2
RETURN l.name AS lineline
Body shop
PackagingService notes are free text. A full text index on notes makes them searchable next to the structure, ranked by relevance:
CALL text_search('Robot', 'notes', 'gearbox vibration', 5) YIELD node, score
RETURN node.serial AS robot, node.notes AS notes, scorerobot notes score
r1 Gearbox vibration above limit at last inspection 1.62
r5 Vacuum gripper loses suction, gearbox noise 0.59For manuals and procedures, vector search finds passages by meaning rather than by words, and can be confined to one manual or one site. examples/python/search.py shows both kinds of search side by side.
4. What changed, when, and why?
Every write in TvennDB is a commit with an author, a message and tags the application chooses. Nothing is overwritten in place. Here the maintenance system takes r1 out of service for its gearbox, records the work order, and says when it happened on the floor, which may be earlier than when the system got round to writing it:
{
"query": "MATCH (r:Robot {serial: 'r1'}) SET r.status = 'maintenance'",
"message": "Gearbox replacement",
"event_time": "2026-10-24T06:30:00Z",
"tags": {"source": "cmms", "actor": "work-order-4711", "reason": "gearbox vibration"}
}The history of a node lists every version with the commit that made it, newest first:
curl -s "localhost:7000/v1/graphs/plant/history?id=Robot/r1" -H "authorization: Bearer $TOKEN"{"versions": [
{"changed": {"status": ["running", "maintenance"]},
"commit": {"author": "user:admin", "message": "Gearbox replacement", "event_ms": 1792823400000,
"tags": {"source": "cmms", "actor": "work-order-4711", "reason": "gearbox vibration"}}},
{"changed": {"serial": [null, "r1"], "status": [null, "running"], "hours": [null, 4620.0], ...},
"commit": {"author": "user:admin", "message": "Initial plant model", "tags": {"source": "erp-import"}}}
]}That is the audit trail a regulated site needs, and the context an engineer needs when a machine behaves differently from last week: not only what changed, but who changed it, through which system, and for what reason.
5. What did the plant look like before?
Every query response carries read_ts, the snapshot it read, and GET /v1/time gives the current one. Pass an earlier timestamp as as_of and the same query reads the plant as it was:
{"query": "MATCH (r:Robot {serial: 'r1'}) RETURN r.status", "as_of": 117494287684075520}now maintenance
with as_of runningAnd the difference between two points in time, node by node, with each changed property before and after:
curl -s "localhost:7000/v1/graphs/plant/diff?from=117494287684075520&mode=nodes" \
-H "authorization: Bearer $TOKEN"{"nodes": [{"id": "Robot/r1", "kind": "updated", "changed": {"status": ["running", "maintenance"]}}]}Any query works this way, including every query above: which robots fed the paint shop last Tuesday, which parts were short at the end of last quarter. There is no separate change log to maintain and nothing to rebuild; the past is simply another timestamp.
6. Who may see what?
A service contractor maintains the packaging line at Plant South. They should see that site, and nothing about other sites or about spare part stock. A membrane bounds what a principal can observe. It is enforced on the shards, so filtered data never leaves them:
{
"kind": "user", "name": "contractor", "password": "...",
"access": {
"grants": {"plant": "read"},
"membranes": {"plant": {"allow_roots": ["Site/south"], "deny_labels": ["Part"]}}
}
}With an API key of that principal, the same queries answer differently:
MATCH (r:Robot) RETURN r.serial ORDER BY r.serial
MATCH (s:Site) RETURN s.name
MATCH (p:Part) RETURN count(p)robots r5, r6
sites Plant South
parts 0The contractor cannot even learn that Plant North exists. Roles per graph, API keys and membranes are all part of TvennDB Free.
And let an AI assistant ask
Every TvennDB gateway is also a Model Context Protocol server. Give an assistant an API key of a principal with read access, membrane included, and it can read the ontology, run GQL and read history as that principal, and nothing more:
claude mcp add --transport http tvenndb http://localhost:7000/mcp \
--header "Authorization: Bearer tvk_..."Asked which robots at Plant South are due for service and what their notes say, it can write the queries above for itself, and it only ever sees what its key allows. examples/mcp covers Claude Code, VS Code, Cursor and others.
Try it
TvennDB Free runs on one machine with Docker or on Kubernetes with the Helm chart, from the repository on GitHub. The walkthrough in this post is examples/python/plant_twin.py: with TvennDB running, one command builds the plant, asks every question and prints the answers. TvennDB Enterprise, not yet available, will add single sign on, the audit log, usage reports, limits per key and online rebalancing for larger installations.
Get TvennDB Free
Container images, a Helm chart, compose files and examples, free to use in production.
TvennDB on GitHub