SolomonGraph
A single-tenant graph instance that runs in your own AWS account. You declare what your data means, POST the JSON your systems already emit, and query the relationships in Cypher.
There is no cluster, no coordinator, and no connection pool. The graph engine runs inside the API process, so the whole thing is one service and one file on a disk you own. Nothing leaves your VPC.
1. How it works
Three steps, and the middle one is data you already have.
Declare an ontology. A JSON document describing your object types, their properties, and the relationships between them. This is the only part that needs thought, and it is the part that makes everything else work.
POST your JSON. Nested documents, flat records with foreign keys, or anything in between. The ontology tells the system what the nesting means, so you do not have to reshape your data first.
Query it. Cypher, over HTTP, returning JSON.
Why declare anything?
Because the shape of a document does not tell you what it means.
An incident record might contain the deploy that caused it. The deploy is nested inside the incident, but causation runs the other way: the deploy caused the incident, not the reverse. Tools that infer relationships from document structure get this backwards, and no query will warn you.
The ontology separates the two. Nesting is how the data arrives; direction is what it means.
2. Getting started
What you have
A running instance in your AWS account and a bearer token. Every request needs the token:
export TOKEN="your-token-here"
export HOST="https://graph.yourcompany.com" # or http://<ip>:8080
If you have lost the token, it is on the instance:
sudo grep SOLOMON_TOKEN /etc/solomongraph.env
or in the console output from the instance's first boot:
aws ec2 get-console-output --instance-id i-xxxx --latest --output text \
| grep -A3 "Bearer token"
Check it is alive
curl -s $HOST/health
{"status": "ok", "db": "/var/lib/solomongraph/graph"}
/health is the only endpoint that does not need a token, so it is safe
to point a load balancer or monitoring check at it.
Your first five minutes
# 1. Declare a model
curl -X PUT $HOST/ontology/document \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"objects": {
"customer": {
"properties": {"id": "string", "name": "string"},
"relationships": {}
},
"order": {
"properties": {"id": "string", "total": "number", "placed_at": "timestamp"},
"relationships": {
"customer": {"edge": "PLACED_BY", "direction": "out", "id": "id"}
}
}
}
}'
{
"applied": true,
"objects": ["Customer", "Order"],
"edges": ["PLACED_BY"],
"schema_changes": [
"created node Customer",
"created node Order",
"created edge PLACED_BY (Order->Customer)"
],
"warnings": []
}
# 2. Send a record
curl -X POST $HOST/ingest \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"type": "Order", "data": {
"id": "ORD-1", "total": 249.99, "placed_at": "2026-08-01T10:30:00",
"customer": {"id": "CUST-1", "name": "Ada Lovelace"}
}}'
{"root_type": "Order", "nodes_written": 2, "edges_written": 1,
"stubs_created": 0, "warnings": []}
# 3. Ask a question
curl -X POST $HOST/query \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"cypher": "MATCH (o:`Order`)-[:PLACED_BY]->(c:Customer) RETURN c.name AS customer, o.total AS total"}'
{"row_count": 1, "rows": [{"customer": "Ada Lovelace", "total": 249.99}]}
That is the whole loop.
3. The ontology
Objects
Each object becomes a node type.
"order": {
"key": "id",
"properties": {
"id": "string",
"total": "number",
"placed_at": "timestamp"
},
"business_context": "A completed purchase. One per checkout.",
"relationships": { }
}
| Field | Meaning |
|---|---|
key |
Which property holds identity. Defaults to id. |
properties |
Property name to type. Anything not listed is ignored on ingest. |
business_context |
Free text describing what this object means. Optional, but see below. |
relationships |
Edges owned by this object. |
Types: string, int, number, double, boolean, timestamp,
string[], int[], double[]. Also accepted as synonyms: integer,
float, bool, date, datetime.
Naming. Object names are converted to a label: order_line becomes
OrderLine. Use that label in queries.
business_context is worth filling in. It is stored alongside the
model and describes each object in plain language. When you point a
language model at your graph to help write queries, this is the difference
between it guessing and it knowing.
Relationships
Declared on the object that holds them:
"order": {
"properties": { "id": "string" },
"relationships": {
"customer": {"edge": "PLACED_BY", "direction": "out", "id": "id"}
}
}
| Field | Meaning |
|---|---|
edge |
Name of the relationship in queries. Convention is upper case. |
direction |
out points this object at the target. in points the target at this object. |
id |
Which property on the target the reference value matches. Defaults to id. |
field |
Which payload key holds the data. Defaults to the target object's name. |
exclusive |
See tracking change over time. |
at |
Property holding the moment the edge became true. |
Direction is independent of nesting
This is the point of declaring a model at all.
"incident": {
"properties": {"id": "string", "summary": "string"},
"relationships": {
"deploy": {"edge": "CAUSED", "direction": "in", "id": "id"}
}
}
The deploy arrives nested inside the incident. direction: in means the
edge runs Deploy → Incident, because causation runs from the change to
the failure. Swap to out and you would get Incident → Deploy, which
reads as the incident having caused the deploy.
Same bytes, opposite meaning. Get this right and every query afterwards is correct by construction.
Self-referential edges
An object may point at its own type:
"service": {
"key": "name",
"properties": {"name": "string", "tier": "string"},
"relationships": {
"service": {"edge": "DEPENDS_ON", "direction": "out", "id": "name"}
}
}
Changing the ontology
Additive changes apply cleanly: new objects, new properties, new edges.
Changes that would contradict data already in the graph are refused with
409 and a list of the specific conflicts — retyping a property, changing
a key, repointing an edge, removing an object. You find out immediately,
rather than months later when a query returns something impossible.
If a conflict is genuinely what you intend, resend with "force": true.
4. Sending data
POST /ingest
{"type": "Order", "data": { ... }}
data may be a single object or an array of them. type names the object
type of the root.
Every way to reference a related object
All four attach the same edge:
"customer": {"id": "CUST-1", "name": "Ada"} // embedded in full
"customer": {"id": "CUST-1"} // identifier only
"customer": "CUST-1" // bare reference
"customer_id": "CUST-1" // flat foreign key
<name>_id and <name>Id are recognised automatically, because that is
how flat exports usually carry foreign keys. Use field in the ontology
if yours is named something else.
Records that arrive out of order
An order can reference a customer whose own record has not arrived yet.
The customer is created as a stub: a real node carrying only its
identifier, flagged is_stub. When the full record arrives, the
properties fill in and the flag clears.
Two guarantees:
- A stub never overwrites a real record. An order mentioning CUST-1 cannot blank out that customer's name.
- Arrival order does not matter. Whichever way round the data comes, you end up with the same graph.
GET /stubs shows what is still unresolved. A stub that never resolves
means something upstream is not sending you a record it should:
{"unresolved": {"Customer": 3}}
Worth watching. It is the cheapest data quality signal you have.
What the response tells you
{
"root_type": "Order",
"nodes_written": 3,
"edges_written": 2,
"stubs_created": 1,
"stubs_resolved": 0,
"edges_opened": 1,
"edges_closed": 0,
"warnings": []
}
Undeclared fields
Ignored silently by default. Send "strict": true while developing to
have them reported instead:
{"type": "Order", "strict": true, "data": { ... }}
{"warnings": ["Order.discount_code is not in the ontology"]}
Useful for catching a typo in a field name, or a source system that has started sending something new.
Ingesting in bulk
Batch records into arrays rather than sending one request each. A few thousand per request is comfortable. The engine takes one writer at a time, so parallel requests will not go faster than sequential ones.
5. Querying
POST /query
{"cypher": "...", "params": { ... }}
Cypher, returning rows as JSON:
{"row_count": 1, "rows": [{"customer": "Ada Lovelace", "total": 249.99}]}
Use parameters
{
"cypher": "MATCH (o:`Order`)-[:PLACED_BY]->(c:Customer) WHERE c.id = $id RETURN o.total AS total",
"params": {"id": "CUST-1"}
}
Not string interpolation. Parameters are bound, so they cannot alter the query, and repeated queries can reuse a plan.
Backtick reserved words
Order, Group, Key, Start and similar are keywords in the query
language. Wrap them:
MATCH (o:`Order`) RETURN o.id
GET /schema returns a cypher_ref for every label with the exact form
to paste.
Traversals
Where a graph earns its keep — following relationships several hops out:
MATCH (s:Service {name: "checkout"})-[:DEPENDS_ON*1..3]->(dep:Service)
RETURN DISTINCT dep.name, dep.tier
MATCH (c:Customer)<-[:PLACED_BY]-(o:`Order`)
RETURN c.name, count(o) AS orders, sum(o.total) AS lifetime
ORDER BY lifetime DESC LIMIT 20
Writes
/query refuses anything that looks like a mutation unless you ask:
{"detail": "statement appears to mutate; resend with allow_writes=true"}
Add "allow_writes": true when you mean it. The guard exists because an
unbounded DELETE with a slightly wrong WHERE clause is unrecoverable.
Prefer /ingest for normal writes.
6. Tracking change over time
Some relationships are mutually exclusive: an order has one status, a customer one lifecycle stage, a subscription one plan. Declare that and the history is maintained for you.
"order": {
"properties": {
"id": "string",
"stage_changed_at": "timestamp"
},
"relationships": {
"stage": {
"edge": "HAS_STAGE", "direction": "out", "id": "id",
"exclusive": true, "at": "stage_changed_at"
}
}
}
Send stage changes as ordinary ingests:
curl -X POST $HOST/ingest -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"Order","data":{"id":"ORD-1",
"stage_changed_at":"2026-08-03T14:00:00","stage":"shipped"}}'
The previous stage is closed, not deleted:
curl "$HOST/history/HAS_STAGE/ORD-1" -H "Authorization: Bearer $TOKEN"
{
"edge": "HAS_STAGE",
"source": "ORD-1",
"intervals": [
{"target": "placed", "iv_from": "2026-08-01T10:30:00", "iv_to": "2026-08-03T14:00:00"},
{"target": "shipped", "iv_from": "2026-08-03T14:00:00", "iv_to": null}
]
}
iv_to: null means still current. Now "how long do orders sit in placed"
is a query rather than a guess.
Current state only:
curl "$HOST/state/HAS_STAGE" -H "Authorization: Bearer $TOKEN"
{"edge": "HAS_STAGE",
"current": [{"source": "ORD-1", "target": "shipped", "since": "2026-08-03T14:00:00"}]}
What this is, and is not
It is a cardinality rule: at most one live edge at a time. It is not a workflow, and it never says which transitions are legal. That matters:
- Going backwards needs no special case. Customer back to lead, shipped back to processing, an employee rehired. Just another interval.
- Nothing is terminal. There is no final state to define, and therefore none to undo when something turns out not to be final.
- Late data repairs itself. An event from Tuesday arriving on Friday sorts into place and the intervals either side are corrected. Replaying a feed changes nothing.
The one thing to watch
Raw Cypher returns closed intervals too. Nothing rewrites your query, so:
MATCH (o:`Order`)-[:HAS_STAGE]->(s:Stage) RETURN count(*)
counts every stage the order has ever held. Filter explicitly:
MATCH (o:`Order`)-[r:HAS_STAGE]->(s:Stage)
WHERE r.valid_to IS NULL
RETURN o.id, s.id
Or use /state/{edge}, which does it for you.
7. API reference
Every endpoint except /health requires Authorization: Bearer <token>.
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness. No token required. |
PUT |
/ontology/document |
Declare or extend the model |
GET |
/ontology |
Current model, with resolved edge directions |
POST |
/ingest |
Send data |
POST |
/query |
Run Cypher |
GET |
/schema |
Tables as they exist on disk, with cypher_ref |
GET |
/stats |
Row counts per label and edge |
GET |
/stubs |
Unresolved references |
GET |
/state/{edge} |
Live edges only, for exclusive edges |
GET |
/history/{edge}/{key} |
Every interval for one node |
Interactive API documentation is served at /docs on your instance.
Status codes
| Code | Meaning |
|---|---|
200 |
Fine |
400 |
Bad request. Invalid Cypher, or an unflagged write on /query. |
401 |
Missing or wrong bearer token |
409 |
No ontology defined yet, or an ontology change that conflicts with existing data |
422 |
The ontology or payload is not valid — the message says why |
Errors are specific rather than generic:
{"detail": "unknown type 'Widget'. Declared types: ['Customer', 'Order', 'Stage']"}
8. Operating it
What is running
One systemd service, solomongraph, with the graph engine inside it. The
data lives on a separate EBS volume mounted at /var/lib/solomongraph, so
you can resize it, snapshot it, or replace the instance entirely without
touching the graph.
sudo systemctl status solomongraph
sudo journalctl -u solomongraph -f
Backups
The graph is a file on a volume you own, so an EBS snapshot is a complete backup:
aws ec2 create-snapshot --volume-id vol-xxxx \
--description "solomongraph $(date -u +%F)"
Stopping the service first gives a guaranteed-consistent snapshot. For a regular schedule, point AWS Data Lifecycle Manager at the volume.
Growing
More memory or CPU: stop the instance, change the type, start it. The data volume reattaches. A few minutes of downtime and no data movement.
Traversals get much faster when the working set fits in memory, so memory optimised instance types give better value here than compute optimised ones.
More disk: expand the volume in the console, then on the instance:
sudo resize2fs /dev/nvme1n1
No restart needed.
Upgrading
Replace the instance from a newer machine image and reattach the volume. The graph is untouched. Take a snapshot first.
Monitoring
Point a check at /health. Beyond that, the two numbers worth watching
are disk usage on /var/lib/solomongraph and unresolved stubs from
/stubs, which is your upstream data quality signal.
9. Security
Your data never leaves your account. The instance runs in your VPC, on storage you own, in the region you chose. There is no phone-home, no telemetry, and no external dependency at runtime. It works in a subnet with no outbound internet access.
Authentication is a bearer token generated on the instance at first
boot, unique to that instance. Rotate it by editing
/etc/solomongraph.env and restarting the service.
Transport. Put TLS in front of it before sending anything real. Either an application load balancer with an ACM certificate, or the bundled reverse proxy which obtains a certificate automatically when you set a domain. Once TLS is in place, close port 8080 and leave only 443 open.
Network. Scope the security group to the addresses that genuinely need it. If only your backend calls the API, that is one CIDR. For the most security-conscious deployments, give the instance no public address at all and reach it privately from within your VPC.
Injection. Names from your ontology are validated against a strict pattern and quoted before being used, and every value in every query is bound as a parameter. Ontology content is scanned for credential-shaped values and rejected — API keys belong in your ingest layer, never in a model definition.
10. Limits and gotchas
One writer at a time. Writes are serialised. Reads are not affected, but parallel ingest requests will not complete faster than sequential ones. Batch records into arrays instead.
One machine. There is no clustering. Scale up rather than out. This is the trade for having nothing to operate, and for most graphs the ceiling is far away — but it is a ceiling.
Reserved words need backticks. Order, Group, Key, Start.
/schema gives you the exact form.
Names are case-insensitive. An edge called CUSTOMER collides with an
object called customer. The ontology parser rejects this when you
declare it, not later.
History is included by default. Queries over exclusive edges return
closed intervals unless you filter on valid_to IS NULL.
Deleting is manual. There is no delete verb on /ingest. Removing
data means Cypher with allow_writes: true, and getting the WHERE
clause right is on you. Snapshot first.
Absence means nothing. If a record stops appearing in your feed, the graph does not conclude anything from that. Whether a missing row means deleted, corrected, or simply not included in this batch is your business logic, and you have to say so explicitly.
Getting help
Include the output of these when you get in touch — it saves a round trip:
curl -s $HOST/health
curl -s $HOST/stats -H "Authorization: Bearer $TOKEN"
curl -s $HOST/ontology -H "Authorization: Bearer $TOKEN"
sudo journalctl -u solomongraph -n 100 --no-pager
Not running it yet
One annual licence, a private image shared to your AWS account, and a stack that launches in about ten minutes.
Buy a licenceSomething is wrong
Send the four commands under Getting help with your message and we can usually answer in one round rather than three.
Email us