Skip to content

Validate Architecture Models with SHACL — Ontology Syntax and Governance Checks

Architecture governance is only useful if it's enforced. Most organizations have rules like "every application must have an owner" or "cross-domain relationships must be typed" — but these live in documents nobody reads, and violations are only caught in quarterly reviews (if ever).

SHACL shapes turn those rules into executable constraints that run automatically. This guide covers how to validate Linked.Archi ontology files for syntax correctness and SHACL conformance — and how to add your own governance rules to the pipeline.


Overview

The validation script (.scripts/validate.sh) provides two levels of validation:

  1. Turtle syntax — parses every .ttl file using the RDF4J Rio Turtle parser. Catches malformed IRIs, missing prefixes, unclosed strings, and other syntax errors.

  2. SHACL conformance — loads ontology data into an RDF4J ShaclSail and validates it against the SHACL shapes. Catches missing required properties, wrong types, cardinality violations, and domain/range constraint violations.

Both levels use Eclipse RDF4J 4.3.14. The SDK is downloaded automatically on first run (~50MB, cached in .scripts/.lib/).

Prerequisites

  • Java 17+ (tested with OpenJDK 21)
  • curl and unzip (for SDK download)

Usage

Run these commands to validate your Turtle files:

# Syntax-check all .ttl files in the repo
./validate.sh

# Syntax-check a single file
./validate.sh --syntax path/to/file.ttl

# SHACL validate a preset profile
./validate.sh --shacl core-vis
./validate.sh --shacl archimate
./validate.sh --shacl archimate-derived
./validate.sh --shacl archimate-principles
./validate.sh --shacl c4
./validate.sh --shacl backstage
./validate.sh --shacl bpmn
./validate.sh --shacl uml
./validate.sh --shacl uml-instances
./validate.sh --shacl uml-publication

# SHACL validate all presets
./validate.sh --shacl all

# SHACL validate custom data against custom shapes
./validate.sh --shacl mydata.ttl core-shapes.ttl my-shapes.ttl

# Check that every namespace in docgen-index.yaml is declared by a source file
./validate.sh --registration

# Full CI run (syntax + registration + all SHACL profiles)
./validate.sh --ci

Exit Codes

Code Meaning
0 All validations passed
1 One or more validations failed
2 Usage error (wrong arguments)

Asset Registration — --registration

Syntax and SHACL check the content of an asset. This stage checks that the asset is wired into the site: every namespace listed in .scripts/.tools/docgen-index.yaml must be declared as vann:preferredNamespaceUri by some source .ttl, and where a file declares both, that namespace must match its own @prefix :.

It exists because the declaration drifted silently across 22 assets — every */deliverable-templates#, */reference-data# and */presentation-contexts# document, plus the five example models. They declared their namespace only through @prefix :, and both the docgen index matcher and copy-ttl-sources.sh accept that as a fallback, so the documentation pages rendered and the ontology.ttl downloads resolved. Nothing looked wrong.

What broke was quieter. generate-llms-txt.sh discards any asset with no dcterms:title, so none of the 22 reached llms.txt or llms-full.txt, none contributed a row to the canonical namespace table, and inject-jsonld.sh emitted a schema.org Dataset with no identifier. A missing header is invisible in the place you would look for it and only shows up in the generated artefacts.

Prefix collisions are reported but do not fail the run. Two worked examples legitimately both use acme: for different fictional namespaces.

═══ Asset Registration ═══

  ! prefix "acme" is claimed by more than one namespace (not an error)

Registration: 160 namespaces checked, 0 errors.

What an asset needs, and where to put it. Every documented asset needs vann:preferredNamespaceUri, vann:preferredNamespacePrefix and dcterms:title. They go on the root resource the file already has — not in an owl:Ontology header bolted onto a file that is not an ontology.

File Root resource Where the metadata goes
*-onto.ttl, *-shapes.ttl, *-metamodel.ttl owl:Ontology the ontology header, with owl:imports and owl:versionIRI
*-tax.ttl, *-reference-data.ttl, *-reference-models.ttl, *-presentation-contexts.ttl skos:ConceptScheme on the ConceptScheme itself
*-deliverable-templates.ttl arch:DeliverableTemplate individuals on the first template instance
example models arch:Model on the arch:Model individual

Reference data, presentation contexts, taxonomies and deliverable templates are controlled vocabularies and instance data, not ontologies. Typing them owl:Ontology to give the metadata a home misstates what they are, and for arch:Model and arch:DeliverableTemplate it actively breaks them — MODEL-ASSET-SUPPORT.md classifies those two asset types by the absence of that triple.

owl:imports and owl:versionIRI are OWL ontology properties and do not belong on a ConceptScheme, a template or a model. owl:versionInfo is an annotation property, so use that to carry the version.

Note what --registration can and cannot tell you: it checks that the declaration exists. It cannot tell you that you put it on an invented resource.

SHACL Validation Architecture — Two-Layer Shapes

Validation uses a two-layer shapes architecture:

flowchart TD
    core["core-shapes.ttl<br/>(base contract — applies to all metamodels)"]
    QRS["QualifiedRelationshipShape:<br/>exactly one arch:source + arch:target"]
    COS["ConceptOwnerShape:<br/>arch:conceptOwner must be a Stakeholder"]

    core --> QRS & COS

    amrel["archimate3.2-relationship-shapes.ttl<br/>11 qualified + 62 unqualified shapes"]
    amelem["archimate3.2-element-shapes.ttl<br/>24 shapes: structural integrity, metamodel patterns"]
    amprin["archimate3.2-principle-shapes.ttl<br/>20 shapes: governance principles"]
    amderiv["archimate3.2-derivation-rules.ttl<br/>SHACL Rules: DR1-DR8 + PDR1-PDR12"]
    c4s["c4-shapes.ttl<br/>4 per-relationship-type shapes"]
    bss["backstage-shapes.ttl<br/>13 per-relationship-type shapes + identity, naming, status, property-placement, and vocabulary-closure shapes"]

    core -->|"imports"| amrel & amelem & amprin & amderiv & c4s & bss

When you run --shacl archimate, the script loads: 1. core-shapes.ttl into the SHACL shapes graph 2. archimate3.2-relationship-shapes.ttl into the SHACL shapes graph 3. archimate3.2-element-shapes.ttl into the SHACL shapes graph 4. archimate3.2-onto.ttl as data

When you run --shacl archimate-derived, it loads all of the above plus: 5. archimate3.2-derivation-rules.ttl (DR1-DR8, PDR1-PDR12 SHACL Rules)

The ShaclSail validates the data against all loaded shapes on commit. Violations are reported as a SHACL validation report in Turtle format.

Label rules are notation-owned

core-shapes.ttl carries no label shape. arch:Element means "a node in a model", and several notations have nodes that are legitimately unnamed — BPMN gateways and events, ArchiMate junctions — so requiring a label on every element is not a sound cross-notation rule. Each metamodel states its own naming rule in its own shapes: bpmnsh:RequiredNameShape, am4elsh:ArchiMateElementShape, c4sh:C4ElementLabelShape, bssh:BackstageElementLabelShape and so on.

Backstage additionally requires bs:name on the same seven element classes, via bssh:BackstageEntityIdentityShape — a separate rule from the label, because bs:name is the catalog identity half of the entity reference (kind:namespace/name), not a human-readable label.

A metamodel shape must not target arch:Element for this. In a merged multi-notation graph that would apply one notation's naming policy to all the others.

Every one of those shapes asserts sh:datatype rdf:langString, so a plain string is rejected:

ex:t1 a bpmn:ServiceTask ; skos:prefLabel "Charge card" .      # FAILS — no language tag
ex:t2 a bpmn:ServiceTask ; skos:prefLabel "Charge card"@en .   # passes

A notation-native name attribute such as bpmn:name does not satisfy them either. Converters emit both the source attribute and a language-tagged skos:prefLabel. See DD-24.

Note that sh:severity does not affect the outcome. Under SHACL a report conforms only when it contains no results at all, so downgrading a shape to sh:Warning reclassifies its output without making the graph conform — RDF4J's ShaclSail does not distinguish severities either. To stop a rule from firing, change what it targets rather than its severity.

SHACL Profiles

Profile Data File Shapes
core-vis core-vis-onto.ttl + diagram-example.ttl core-shapes.ttl + core-vis-shapes.ttl + core-vis-onto.ttl + core-onto.ttl + archimate3.2-onto.ttl (the last three for the subclass closure)
archimate archimate3.2-onto.ttl core-shapes.ttl + archimate3.2-relationship-shapes.ttl (generated) + archimate3.2-element-shapes.ttl
archimate-derived archimate3.2-onto.ttl All archimate shapes + archimate3.2-derivation-rules.ttl (DR1-DR8, PDR1-PDR12)
archimate-principles archimate3.2-onto.ttl core-shapes.ttl + archimate3.2-principle-shapes.ttl (20 governance principle shapes)
archimate-viewpoints archimate3.2-onto.ttl core-shapes.ttl + archimate3.2-viewpoint-shapes.ttl + archimate3.2-viewpoints.ttl
c4 c4-onto.ttl core-shapes.ttl + c4-shapes.ttl
structurizr structurizr-onto.ttl core-shapes.ttl + c4-shapes.ttl + structurizr-shapes.ttl
backstage backstage-onto.ttl + payflow-model.ttl core-shapes.ttl + core-onto.ttl + backstage-onto.ttl + backstage-shapes.ttl
bpmn linkedarchi-bpmn-onto.ttl core-shapes.ttl + linkedarchi-bpmn-shacl.ttl + linkedarchi-bpmn-infra-shacl.ttl
bpmn-lite linkedarchi-bpmn-lite-onto.ttl core-shapes.ttl + linkedarchi-bpmn-lite-shacl.ttl
bpmn-di linkedarchi-bpmndi-onto.ttl core-shapes.ttl + BPMNDI + DI + DC shapes
uml uml-onto.ttl + uml-tax.ttl core-shapes.ttl + uml-shapes.ttl + uml-onto.ttl (for the subclass closure sh:targetClass needs)
uml-instances uml-onto.ttl + uml-example.ttl core-shapes.ttl + uml-shapes.ttl + uml-onto.ttl + core-onto.ttl
uml-publication uml-onto.ttl + uml-example.ttl as uml-instances plus uml-publication-shapes.ttl
arch-decision arch-decision-onto.ttl + decisions-example.ttl core-shapes.ttl + arch-decision-shapes.ttl

Every profile loads core-shapes.ttl, so the labelling contract above applies in all of them. A new metamodel with shapes should be registered here as a profile — bpmn-lite went unvalidated for a while, and in that time its shapes came to require arch:name, a property declared nowhere in the repository.

Validate against instances, not only against the ontology

Most profiles above use a metamodel's own ontology as the data graph. That exercises the shapes constraining enumerated values, because the ontology carries the value individuals — but it reaches no model instance at all, since the rest of the file is class declarations and shapes fire on instances.

This is a real blind spot, not a technicality. umlsh:NamedElementShape required a skos:prefLabel on every uml:NamedElement for two releases; the uml profile passed throughout, because the ontology contains no uml:NamedElement instance for it to fire on. The defect surfaced only when a converted state diagram reported a violation on every unlabelled transition, which is the ordinary way to write one. See UML-DD-14.

Profiles that pair an ontology with a reference example — core-vis, uml-instances, uml-publication, arch-decision, arch-processes, leanix, backstage — do not have that blind spot. Prefer this shape for a new profile, and make the example include the cases that are correct but look wrong: an unlabelled transition, a grouping box with no model element behind it, a reply message with no name. Those are what an over-strict shape reports.

backstage was the most recent to switch, and the switch is instructive about what the blind spot hides. Under the ontology-as-data profile it passed while nothing checked entity identity, naming, relationship endpoints, value-set membership or property placement — every one of those shapes targets instances. Pointing it at payflow-model.ttl immediately produced findings on data that had been considered good, including four APIs with no spec.definition.

Two mechanics are worth copying when you do this:

  • The ontology goes in both positions. As shapes, because RDF4J's ShaclSail reads rdfs:subClassOf from the shapes graph for its subclass reasoning, and without it sh:class arch:Element does not resolve for a bs:Component. As data, because DD-23 value vocabularies are named individuals declared in the ontology and sh:class bs:ResourceType wants an rdf:type triple in the data graph. owl:imports does not help — RDF4J does not follow it.
  • Order the data files with the vocabulary first. ShaclValidator commits each data file in its own transaction and ShaclSail validates per commit, so a vocabulary loaded after the model that references it is not yet visible when the model is checked. The symptom is every correct value-individual reference being reported at once.

The diagram layer needs core-vis-shapes

core-shapes.ttl targets no core-vis class, and neither does any notation's shapes document. Until core-vis-shapes.ttl was published, nothing checked the diagram layer at all: a corpus of 447 malformed diagram resources across 20 diagrams — label nodes typed arch-vis:ArchNode, carrying no arch-vis:archElement and no arch-vis:view, with IRIs minted outside the model namespace — validated clean.

Load core-vis-shapes.ttl whenever the data contains diagram interchange. The two rules that carry the weight are archvissh:ArchNodeShape (a node claiming to stand for a model element must say which one) and archvissh:PlacedDiagElementShape (a shape or edge belongs to exactly one view). It carries no label rule, for the same reason core-shapes does not.

GitLab CI Integration

The .gitlab-ci.yml runs validate.sh --ci on every push that changes .ttl files. It uses a custom Docker image (validator) with Java 21, RDF4J 4.3.14 SDK, and pre-compiled validators baked in — no download or compilation at runtime.

Building the Validator Image

docker build --platform linux/amd64 --no-cache \
  -t linked-archi/validator .scripts/

Push to your GitLab container registry:

docker tag linked-archi/validator registry.gitlab.com/<namespace>/linked-archi-meta/validator:latest
docker push registry.gitlab.com/<namespace>/linked-archi-meta/validator:latest

Why --platform linux/amd64: Apple Silicon Macs build ARM images by default. GitLab shared runners are x86_64 and will reject ARM images.

Why --no-cache: Prevents Docker from reusing cached ARM layers when switching platforms.

The image contains: - Eclipse Temurin JDK 21 - RDF4J 4.3.14 SDK (/opt/rdf4j/lib/) - Pre-compiled TurtleSyntaxCheck.class and ShaclValidator.class (/opt/rdf4j/build/)

The validate.sh script detects the pre-built paths via RDF4J_LIB and RDF4J_BUILD environment variables and skips download/compilation entirely. For local development without Docker, the script falls back to downloading the SDK on first run (cached in .scripts/.lib/).

The CI job: 1. Downloads the RDF4J SDK (cached after first run) 2. Compiles the Java validation tools (cached) 3. Runs syntax validation on every .ttl file in the repository 4. Runs SHACL validation on every profile listed above 5. Reports pass/fail with proper exit codes

Adding a New SHACL Profile (Custom Governance Rules)

To add validation for a new metamodel:

  1. Create a shapes file (e.g., my-metamodel-shapes.ttl) that imports core-shapes. Paste this as a starting point:

    owl:imports <https://meta.linked.archi/core-shapes#> ;
    

  2. Add per-relationship-type shapes with domain/range constraints

  3. Add a new case to the run_profile() function in validate.sh:

    my-metamodel)
        validate_shacl \
            "$REPO_ROOT/path/to/my-metamodel-onto.ttl" \
            "$core_shapes" \
            "$REPO_ROOT/path/to/my-metamodel-shapes.ttl"
        ;;
    

  4. Add the profile name to the PROFILES variable

Files

File Purpose
.scripts/validate.sh Main validation script
.scripts/TurtleSyntaxCheck.java RDF4J-based Turtle parser wrapper
.scripts/ShaclValidator.java RDF4J ShaclSail-based SHACL validator
.scripts/SparqlQuery.java RDF4J SPARQL SELECT runner over Turtle files, TSV output
.scripts/.lib/ Auto-downloaded RDF4J SDK (gitignored)
.scripts/.build/ Compiled Java classes (gitignored)
.gitlab-ci.yml CI pipeline configuration
.scripts/.tools/docgen-index.yaml Asset registry — checked by --registration
.scripts/copy-ttl-sources.sh Deploys each source .ttl to its namespace path for content negotiation
.scripts/generate-llms-txt.sh Builds llms.txt / llms-full.txt from asset headers
core/core-shapes.ttl Base SHACL shapes for all metamodels
core/core-vis-shapes.ttl SHACL shapes for the diagram interchange and visual notation layer
modelingLanguages/uml/uml-shapes.ttl UML conformance shapes — no naming requirement, see UML-DD-14
modelingLanguages/uml/uml-publication-shapes.ttl Opt-in UML naming rules for models that will be published
examples/diagram-example.ttl Reference diagram instance, data graph of the core-vis profile
examples/uml-example.ttl Reference UML instance, data graph of the uml-instances and uml-publication profiles
modelingLanguages/archimate/archimate3.2-relationship-shapes.ttl Generated SHACL shapes for relationship source-target pairs
modelingLanguages/archimate/archimate3.2-element-shapes.ttl SHACL shapes for element/metamodel pattern constraints
modelingLanguages/archimate/archimate3.2-principle-shapes.ttl SHACL shapes for architecture governance principles
modelingLanguages/archimate/archimate3.2-derivation-rules.ttl ArchiMate derivation rules (DR1-DR8, PDR1-PDR12)
.scripts/generate-archimate-shapes.py Generator for relationship shapes from XML matrix