Cortex
An ontology-backed knowledge graph memory store with branch-based ingestion, provenance, inference, and full-text search — exposed to AI agents over MCP.
📖 Documentation — getting started, a SKOS tutorial, the architecture, the MCP server guide, the configuration reference, and Javadoc.
What's in the box?
Adding the starter auto-configures a complete knowledge graph memory for your Spring Boot application:
- A
Cortexbean — the entry point to the knowledge graph, backed by Apache Jena. The approved assertions always live in a TDB2 store — in memory by default, or on disk withcortex.persistent=true. The inference closure and the full-text index built over it are derived data: an in-memory cache, rebuilt from the assertions on every startup, regardless ofcortex.persistent. - Ontology-grounded storage — you supply the vocabulary (
ontology.ttl), the SHACL shapes ingested data must conform to (ontology.shapes), and inference rules (ontology.rules) on the classpath. Thecortex.ontologies,cortex.shapes, andcortex.rulesproperties each accept a list of resources, merged in order, so a graph can be built from several ontology, shape, and rule files. - Ontology linting — assertions are checked against the ontology: every class and property used must be defined there, with only
rdf:type,rdfs:label, andrdfs:commentallowed beyond it. Linting returns the validated Turtle, ready to ingest, and ingestion fails fast on anything that does not lint clean. - Branch-based ingestion — incoming assertions are linted, SHACL-validated together with the already approved assertions (so new statements may rely on approved ones to conform), trimmed of triples the graph already contains, and staged on a branch as an RDF patch. Nothing enters the graph until a human approves it; if every incoming triple is already approved, nothing is staged.
- PROV-O provenance — every ingestion is recorded as a
prov:Activity(started when the branch is staged, ended when it is approved), and every approved statement is reified and linked to its activity withprov:wasGeneratedBy. - Rule-based inference — derived statements are recomputed in full on startup, and extended incrementally with each approval's newly-added statements (not recomputed from scratch), and included in all query results.
- Full-text search — a Lucene index over
rdfs:labelandrdfs:comment, held as separate fields so a name outranks a description.rdf:typeis deliberately not indexed: class URIs would mix their tokens into the same relevance space as human-readable prose, giving every typed resource the same filler terms. - An MCP server — tools and resources that let AI agents read and write the graph (see below).
- A web UI — pages to explore the graph, search it from the navbar, and review, edit, and approve pending branches (see below).
- Export and import — the approved assertions can be downloaded as a Turtle file from
/export, and a Turtle document can be uploaded at/import, which stages it on a branch for review like any other ingest rather than replacing the graph. - Scheduled backups to S3 — a Quartz job periodically snapshots the whole assertions dataset with TDB2's own backup and uploads it to S3 or any S3-compatible store (see below). Off by default.
- Restore on startup — the reverse: on boot, download the latest backup from S3 and load it into the store before the application serves traffic, so an instance replaced on a fresh volume comes up with its data. Off by default.
Requirements
Getting started
Add the Spring Boot starter to your project.
Gradle
implementation("ai.chatur:cortex-spring-boot-starter:0.1.4")
Maven
<dependency>
<groupId>ai.chatur</groupId>
<artifactId>cortex-spring-boot-starter</artifactId>
<version>0.1.4</version>
</dependency>
Configuration properties
All properties are bound under the cortex prefix. ontologies, shapes, and rules each accept a list of classpath:/file: resources, merged in order.
cortex.s3.proxyis the whole story. The AWS SDK resolves proxy settings from JVM system properties and environment variables by default, so leavingcortex.s3.proxyunset would not mean "no proxy" — it would mean "whatever proxy the deployment environment happens to name", with backup uploads silently routed through it. Cortex therefore defaults both discovery settings tofalse: an unset proxy is genuinely no proxy. If you want the SDK's ambient discovery — a corporate network that setsHTTPS_PROXYglobally, say — set the corresponding property totrue.
Single-JVM constraint: when
cortex.persistent=true, the TDB2 store is opened withTDB2Factory.connectDataset, which does not support two JVMs concurrently opening the same directory. Run one instance per persistent store directory.
Scheduled backups
A Quartz job can periodically snapshot the whole assertions dataset — the approved assertions, every staged branch, and the provenance graph — and upload it to S3. The snapshot is TDB2's own DatabaseMgr.backup: gzipped N-Quads, taken inside a read transaction, so it is a consistent snapshot that does not lock out concurrent ingestion or approval.
This is the real backup mechanism. /export is not — it carries only the approved assertions, for reading and sharing.
Backups are off by default, so the starter does not bring the dependencies they need. To switch them on, add:
implementation(platform("software.amazon.awssdk:bom:2.46.7"))
implementation("software.amazon.awssdk:s3")
implementation("software.amazon.awssdk:apache-client")
implementation("org.springframework.boot:spring-boot-starter-quartz")
apache-client is not optional: the s3 artifact declares the synchronous HTTP clients at test scope and ships only the asynchronous Netty one, so without it the synchronous S3Client fails to build. It is also what backs cortex.s3.proxy.
Then configure it — this example targets a local MinIO with no authentication:
cortex:
persistent: true
backup:
enabled: true
interval: 6h
s3:
enabled: true
endpoint: http://localhost:9000
bucket: cortex-backups
region: us-east-1
auth: anonymous
path-style-access: true
Backups require
cortex.persistent=true. A TDB2 backup is an admin operation on an on-disk store; the in-memory store used by default has no location to write a backup beside. Enabling backups without persistence fails at startup rather than booting an application that reports healthy and never backs anything up. The same is true of a missing bucket, missing static credentials, a non-positive interval, and the dependencies above.
Backup on shutdown
Backups are scheduled, so a restart discards every approval made since the last one — up to a full cortex.backup.interval, 24 hours by default. Cortex therefore takes one final backup as the context closes and blocks shutdown until it has uploaded. It is on by default whenever backups are enabled; set cortex.backup.onShutdown=false to turn it off without giving up scheduled backups.
The final backup runs after the web server has stopped accepting requests and after the scheduler has quiesced, so it snapshots a store nothing is still writing to — including waiting out a scheduled backup that happens to be in flight. It is an ordinary backup under the same key prefix, so a restore picks it up as the most recent object with no extra configuration.
The wait is deliberately unbounded. A large store on a slow link takes as long as it takes, and a bounded wait would kill the upload partway through on exactly the stores that most need it. A backup that fails — bad credentials, a missing bucket — is logged and shutdown continues, so a misconfigured bucket can never make the application impossible to restart; only a genuinely slow upload holds shutdown open. If your orchestrator sends
SIGKILLafter a grace period, size that grace period against how long a backup of your store actually takes.
Backup files accumulate under <cortex.assertionsLocation>/Backups/ and are never deleted: the upload is a copy, not a move, so a bucket misconfiguration can never destroy data. Pruning them is left to whatever manages that volume — or to an S3 lifecycle rule for the uploaded copies.
Restore on startup
The inverse of a backup: on boot, Cortex can download the most recent backup from S3 and load it into the assertions store before the application serves traffic, so an instance whose local disk was replaced comes up with its data. The inference closure and full-text index are then rebuilt from the restored assertions on startup as usual, with no extra step.
This treats S3 as the source of truth and the local store as replaceable: the restore is a wipe-and-load that runs on every boot — it clears the store and loads the latest backup over it, restoring the approved assertions, every staged branch, and the provenance graph at full fidelity. It needs the same dependencies as backups (spring-boot-starter-quartz is not among them — restore uses no scheduler, but software.amazon.awssdk:s3 and apache-client are required), plus an enabled S3 client:
cortex:
persistent: true
restore:
enabled: true
s3:
enabled: true
endpoint: http://localhost:9000
bucket: cortex-backups
region: us-east-1
auth: anonymous
path-style-access: true
The switch is independent of cortex.backup.enabled: an instance may restore from another's uploads without scheduling backups of its own, or the same instance may do both. When it does both and cortex.backup.keyPrefix is customized, set cortex.restore.keyPrefix to match so the restore looks under the same prefix the backups were written to.
Restore requires
cortex.persistent=true, an enabled and configured S3 client, and the AWS SDK dependencies — each checked at startup, so a half-configured restore fails the context rather than silently booting an empty graph the operator expected to be seeded. The one case that is not an error is an empty bucket: a first-ever deployment with no backup yet logs and starts empty.
A restore is destructive by design — it discards the local store's current contents. That is the intended behavior when S3 is your source of truth (an ephemeral volume, a fresh replica), but it means any local writes not yet captured by a backup are lost on the next boot. If that is not what you want, leave
cortex.restore.enabled=false.
Modules
Cortex is split across five Gradle modules; the first four are published independently to Maven Central.
MCP tools and resources
The starter registers an MCP server (via Spring AI) through which agents interact with the knowledge graph.
Tools
Resources
Claude Code plugin
The repository also ships cortex-schema (cortex-schema-plugin/), a Claude Code plugin that authors the three schema files a Cortex application needs — ontology.ttl, ontology.shapes, and ontology.rules — from a plain-English description of your classes, relations, and rules.
Its /generate-cortex-resources skill gathers requirements interactively, then generates a mutually-consistent set of files: an OWL 2 DL ontology, closed-world SHACL shapes that reject anything the ontology doesn't recognize (including direct assertion of inferred-only classes), and Jena reasoner rules for controlled inference — cross-checked for consistency before being written to your application's src/main/resources/. The namespaces are yours to choose; see the plugin README for the one namespace Cortex itself reserves.
To use it, add the plugin directory as a marketplace in Claude Code and install the plugin:
/plugin marketplace add ./cortex-schema-plugin
/plugin install cortex-schema@cortex-schema
Then invoke /generate-cortex-resources and describe your ontology. See the plugin README for the full workflow and an example.
Web UI
The starter also serves a small UI for exploring the graph and reviewing what agents have staged. The navbar carries a search bar — press Enter to search, no button needed.
Migrating to 0.1.4
- Search is no longer fuzzy, and it now covers SKOS annotations. The text index went from two fields (
rdfs:labelandrdfs:comment, with a name-over-description boost) to a single field fed byrdfs:label,rdfs:comment, and the SKOS labelling and note properties (skos:prefLabel,skos:altLabel,skos:definition,skos:note, …), analysed with Lucene'sEnglishAnalyzer. Matching is now exact after analysis — recall comes from English stemming rather than edit-distance fuzz — so a typo that used to squeak through no longer does, while plurals and word forms still match. Ranking is Lucene relevance weighted by how often a resource is opened; the name-over-description boost is gone. There is no API change. CortexStats.inferenceTriples()renamed toinferredTriples(), and it now counts only derivations. It used to be the size of the whole inference dataset — the approved assertions and the ontology's own axioms included — while the home page labelled it "Inferred triples". It now reports that dataset less the assertions and less the ontology axioms, so it answers "what did the reasoner add?". Expect a smaller number than 0.1.3 reported for the same graph.assertionTriples()is unchanged.ProvenancedStatementgained anorigincomponent.describeused to report every statement without provenance as inferred, which mislabelled the ontology's own axioms: the reasoner is bound to the ontology as its schema, so axioms such asrdfs:domainare materialized alongside the reasoner's conclusions and carry no provenance either. The newStatementOrigin(ASSERTED,ONTOLOGY,INFERRED) tells the three apart, and/describebadges them accordingly. This is source-breaking for code constructing aProvenancedStatement— the canonical constructor now takes a fourth argument. Readingcreated()is unaffected: it is still non-null exactly when the origin isASSERTED.
Migrating to 0.1.1
0.1.1 changes what /export and /import mean. Cortex is pre-1.0, so the following were changed rather than deprecated:
CortexArchive.importAssertions(String)removed./importno longer restores a dataset — it stages the upload for review throughingest. The destructive whole-dataset restore is gone with it, and the replacement for it is the scheduled backup, which captures strictly more (staged branches and provenance, not just approved assertions). To restore one, enable restore on startup, or stop the application and load the.nq.gzinto the store with Jena'stdb2.tdbloader.CortexArchive.getAssertions()removed. It served the approved assertions as TriG and had no caller;exportAssertions()now returns exactly that, as Turtle.CortexArchive.exportAssertions()returns Turtle, not TriG, and only the approved assertions. It previously serialized the entire dataset — every staged branch and the provenance graph included./exportaccordingly servescortex-assertions-<date>.ttlastext/turtlerather than.trigasapplication/trig./importaccepts.ttlonly, and no longer replaces the graph. An uploaded document is linted, SHACL-validated, reduced to what is novel, and staged on a branch; the browser lands on/branches. A.trigupload is rejected with an explanation. Anything automating the old restore behaviour must change: an import can now be rejected by a reviewer, and never overwrites what is already approved.- Approving a branch redirects to
/branches, not/assertions. Update any UI automation following that redirect.
Migrating to 0.1.0
0.1.0 is a breaking release. Cortex is pre-1.0, so the following were changed rather than deprecated:
throws IOExceptionremoved from the entireCortexAPI. Every occurrence was a phantom, traced to serializing into an in-memoryStringWriter/ByteArrayOutputStreamwhoseclose()cannot fail but still declares the checked exception.Cortex,JenaCortex, and every method across all nine role interfaces no longer declare it. This is source-breaking: acatch (IOException)around a call that can no longer throw one is now a compile error, sinceIOExceptionis a checked exception thetryblock no longer produces. Remove thecatch(or thethrowsclause of your own method, if it only existed to relay Cortex's).Cortexsplit into nine role interfaces:CortexOntology,CortexLinter,CortexIngestor,CortexBranches,CortexArchive,CortexQuery,CortexSearch,CortexStatistics, andCortexInference.Cortexstill extends all nine, so existing code that depends onCortexkeeps compiling and the singleCortexbean still satisfies every role by type — this is not source-breaking. New and existing consumers are encouraged to narrow their constructor parameter to the role they actually use (e.g. a search controller takesCortexSearch, notCortex), which is also what makes them trivially fakeable in tests without stubbing the whole surface.Termencoding changed for unprefixed IRIs. For a URI with no matching namespace prefix,Term.localName()is now the full URI — matchingTerm's own documented contract — instead of the bare local name after the last#//. This is user-visible wherever unprefixed resources are rendered:/assertions?type=,/describe?uri=,/searchlink text, andsearchSubjects/describeresults returned to MCP agents. Prefixed IRIs (the common case, if your ontology declares prefixes for its namespaces) are unaffected.cortex.indexLocationremoved. The full-text index is now always an in-memory Lucene index (ByteBuffersDirectory), regardless ofcortex.persistent, since it is derived data rebuilt from the assertions on every startup. If you set this property, remove it — it is silently ignored.OntologyClass'stermcomponent renamed totype. Update any code constructing or destructuringOntologyClasspositionally or by component name.- The
/assertions/{id}route was removed; use/describe?uri={uri}instead (see Web UI). - The branch-discard operation is now called "Reject" everywhere, including in the UI. The API (
CortexBranches#reject) and route (POST /branches/{branch}/reject) were already namedreject; only the web UI button previously read "Delete" and has been relabeled "Reject" to match. No API or route change, but update any UI automation asserting on the old button text.