SolomonGraph

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.

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:

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:

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 licence

Something 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