Tutorial: a controlled vocabulary with SKOS

A complete, working Cortex setup built on a standard ontology. You write no OWL at all β€” SKOS is the vocabulary β€” so this is the shortest path from an empty project to a real graph an agent can read and write.

Prerequisites. Finish Getting started first β€” it covers adding the starter and running the app. This page replaces only step 2, the ontology, shapes, and rules.

Why SKOS makes a good first graph

SKOS (Simple Knowledge Organization System) is the W3C vocabulary for thesauri, taxonomies, and controlled vocabularies. It models concepts and how they relate β€” broader, narrower, related β€” rather than any particular domain, which is exactly what you want when the thing you are building is a memory of topics, tags, product categories, or subject headings.

It suits Cortex specifically because it is small (a handful of classes and properties, so linting has a tight vocabulary to check against) and because its own rdfs:subPropertyOf declarations do a lot of the inference work for you β€” as the rules below show.

The example app already ships it. cortex-spring-boot-starter-example/src/main/resources/skos.ttl is the SKOS Core vocabulary, merged alongside the example's own ontology. Copy that file into your own src/main/resources/, or download it from w3.org and convert it to Turtle.
  1. Use SKOS as the ontology

    Point cortex.ontologies at the SKOS file. There is nothing to author β€” the classes (skos:Concept, skos:ConceptScheme) and properties (skos:prefLabel, skos:broader, …) come from the standard.

    Remember what the ontology does in Cortex: linting is a closed-world check, so after this step an agent can use SKOS terms and nothing else. A made-up ex:invented predicate is rejected before it ever reaches SHACL.

  2. Write shapes for your vocabulary

    SKOS says what the terms mean; it does not say which of them your data must carry. That is the shapes file β€” and it is where you pin your own IRI scheme, which SKOS has no opinion about.

    ontology.shapes
    @prefix rdf:  <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
    @prefix sh:   <http://www.w3.org/ns/shacl#> .
    @prefix skos: <http://www.w3.org/2004/02/skos/core#> .
    
    @prefix s: <https://example.org/shapes#> .
    
    s:VocabularyShape a sh:NodeShape ;
        sh:targetSubjectsOf rdf:type ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^https://example.org/vocab/[^/?#]+$" ;
        sh:message "vocabulary terms must be https://example.org/vocab/{name} IRIs" ;
        sh:property [
            sh:path rdf:type ;
            sh:minCount 1 ;
            sh:in ( skos:Concept skos:ConceptScheme ) ;
            sh:message "only skos:Concept and skos:ConceptScheme may be asserted" ;
        ] ;
        sh:property [
            sh:path skos:prefLabel ;
            sh:minCount 1 ;
            sh:maxCount 1 ;
            sh:nodeKind sh:Literal ;
            sh:message "every term needs exactly one skos:prefLabel" ;
        ] .
    
    s:ConceptShape a sh:NodeShape ;
        sh:targetClass skos:Concept ;
        sh:property [
            sh:path skos:definition ;
            sh:minCount 1 ;
            sh:nodeKind sh:Literal ;
            sh:message "every concept needs a skos:definition" ;
        ] ;
        sh:property [
            sh:path skos:broader ;
            sh:nodeKind sh:IRI ;
            sh:message "skos:broader must point at another concept" ;
        ] .

    Three decisions worth calling out:

    • One skos:prefLabel, no more. This is SKOS's own integrity condition, and it is the constraint that stops an agent quietly introducing a second name for a concept that already has one.
    • sh:in on rdf:type. SKOS declares classes you probably do not want asserted by hand β€” restrict the assertable set explicitly rather than inheriting whatever the standard happens to define.
    • An IRI pattern. Cortex reserves cortex://; everything else is yours. Pinning a pattern is what keeps an agent from inventing a parallel namespace for the same concepts.
  3. Add rules β€” SKOS does most of the work

    ontology.rules
    @prefix rdf:  <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
    @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
    @prefix skos: <http://www.w3.org/2004/02/skos/core#> .
    
    [subClass:    (?x rdf:type ?c1) (?c1 rdfs:subClassOf ?c2) -> (?x rdf:type ?c2)]
    [subProperty: (?x ?p1 ?y) (?p1 rdfs:subPropertyOf ?p2) -> (?x ?p2 ?y)]
    
    [narrower: (?a skos:broader ?b) -> (?b skos:narrower ?a)]
    
    [broaderTransitive:
      (?a skos:broader ?b) (?b skos:broaderTransitive ?c) -> (?a skos:broaderTransitive ?c)]
    
    [hasTopConcept: (?c skos:topConceptOf ?s) -> (?s skos:hasTopConcept ?c)]
    
    [definitionComment: (?c skos:definition ?d) -> (?c rdfs:comment ?d)]

    The generic subProperty rule is the one that pays off, because SKOS is full of rdfs:subPropertyOf declarations. Three of them matter here:

    Declared in SKOSWhat you get for free
    skos:prefLabel, skos:altLabel βŠ‘ rdfs:labelEvery concept becomes searchable and renders with a name in the web UI β€” Cortex indexes rdfs:label, and the inferred one counts.
    skos:broader βŠ‘ skos:broaderTransitiveThe broaderTransitive rule above only has to chain; the base case is already entailed.
    skos:topConceptOf βŠ‘ skos:inSchemeDeclaring a top concept puts it in the scheme without a second statement.
    Why definitionComment is worth having. skos:definition is a sub-property of skos:note, not of rdfs:comment β€” so without that one rule your definitions never reach the comment field the Describe view renders and the search index covers. It is a one-line rule that makes a SKOS graph feel native in the Cortex UI.
  4. Configure it

    application.yaml
    cortex:
      persistent: true
      ontologies:
        - classpath:skos.ttl
      shapes:
        - classpath:ontology.shapes
      rules:
        - classpath:ontology.rules

    Each property takes a list, merged in order β€” so if you later add domain classes of your own, append classpath:ontology.ttl after skos.ttl rather than editing the standard vocabulary. That is exactly what the shipped example does.

  5. Ingest a vocabulary

    Ask an agent over MCP to add some concepts, or POST this to /import. Either way it is linted, SHACL-validated, and staged on a branch for review β€” nothing below is queryable until you approve it at /branches.

    Turtle
    @prefix skos: <http://www.w3.org/2004/02/skos/core#> .
    @prefix : <https://example.org/vocab/> .
    
    :topics a skos:ConceptScheme ;
        skos:prefLabel "Documentation topics" .
    
    :knowledge-graph a skos:Concept ;
        skos:prefLabel "Knowledge graph" ;
        skos:definition "A graph-structured store of facts about entities and their relationships." ;
        skos:topConceptOf :topics .
    
    :ontology a skos:Concept ;
        skos:prefLabel "Ontology" ;
        skos:definition "A formal vocabulary of classes and properties for a domain." ;
        skos:broader :knowledge-graph .
    
    :shacl a skos:Concept ;
        skos:prefLabel "SHACL" ;
        skos:altLabel "Shapes Constraint Language" ;
        skos:definition "A language for validating the structure of RDF data." ;
        skos:broader :ontology ;
        skos:related :ontology .

What you get after approving

Four subjects were asserted. Once the branch is merged, the inference closure holds a good deal more β€” all of it queryable through Query, Describe, and the web UI:

Inferred statementFrom
:shacl skos:broaderTransitive :knowledge-graphThe broaderTransitive rule chaining :shacl β†’ :ontology β†’ :knowledge-graph.
:knowledge-graph skos:narrower :ontologyThe narrower rule inverting skos:broader.
:topics skos:hasTopConcept :knowledge-graphThe hasTopConcept rule inverting skos:topConceptOf.
:knowledge-graph skos:inScheme :topicsskos:topConceptOf βŠ‘ skos:inScheme, via the generic sub-property rule.
:shacl rdfs:label "SHACL", "Shapes Constraint Language"skos:prefLabel and skos:altLabel βŠ‘ rdfs:label.
:shacl rdfs:comment "A language for validating…"The definitionComment rule.

Walk the hierarchy with a SPARQL SELECT β€” through the Query MCP tool, or Cortex.query(String):

SPARQL
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
SELECT ?ancestor WHERE {
  <https://example.org/vocab/shacl> skos:broaderTransitive ?ancestor .
}

That returns both :ontology and :knowledge-graph, though only the first was asserted.

Search works on the inferred labels too: Shapes Constraint finds :shacl through its skos:altLabel, and validating finds it through the definition that became an rdfs:comment. Search is fuzzy, so near-misses still land β€” see how search ranks.

What gets rejected

The point of the shapes is that these fail before a human is asked to review anything. Each of these is refused at ingest:

Attempted assertionRejected by
A concept at https://elsewhere.example/thingsh:pattern β€” outside your vocabulary namespace.
A skos:Concept with no skos:definitions:ConceptShape's sh:minCount 1.
A concept with two skos:prefLabelssh:maxCount 1 β€” SKOS's own integrity condition.
A made-up predicate such as ex:inventedLinting, not SHACL β€” the term is not declared in SKOS.
A resource typed skos:Collectionsh:in β€” SKOS declares it, your shapes do not admit it.
Lint and SHACL are different checks. Linting is the closed-world vocabulary check against the ontology β€” is this term defined at all? SHACL is the structural check against your shapes β€” is this data shaped the way I require? An agent that calls Lint first gets the vocabulary error immediately, without staging anything.

Where to go next