Part 2: Ontologies, Building the Thing

In Part 1: “Ontologies, Back in Fashion”, there are 2 things traveling under the word “ontology”. The first is the ambitious version (automated reasoning, machines inferring facts nobody stated) which is “roughly where it was 20 years ago: academic, brittle, confined to narrow domains”. The second is the humble version; an agreed vocabulary, stable identifiers, a declared column meaning that two systems can check without holding a meeting. Part 1’s claim is that the humble version is quietly working well in the regulated finance, kept alive by regulatory mandate rather than hype. The LLM angle was flagged as a “plausible mechanism, not a confirmed one”. It reduces the authoring toil that killed the 2005 version, but a human still checks every line.

This post is a walkthrough of a working application that makes that mechanism concrete. The dataset is a ten-column customer accounts CSV. The agents propose the semantic annotations; a SHACL validator either accepts the result or routes it back for revision; the human’s irreducible investment is the authoritative mapping table and the SHACL shapes that were written once and reused for every dataset thereafter. The code is in the tutorial-ontologies directory. We’ll go through it layer by layer.

The Stack

This example tutorial uses 6 standards, each owning exactly one layer of concern. Reading the table bottom-up is the right order: physical data first, governance last.

LayerStandardResponsibility
Catalog & dataset metadataDCAT-AP, schema.org/DatasetDiscovery, publisher, distribution, access rights
Use, rights & termsODRL + dct:licenseMachine-actionable terms of condition
Privacy & lawful useDPV + DPV-PDPersonal-data classification, purpose, legal basis
Domain semanticsschema.org + FIBOMeaning of each data element
Logical structure (binding)CSV-W tableSchemaColumns, datatypes, keys; propertyUrl binding
Physical dataCSV distributionThe tabular bytes

It’s important to be specific about what “published standard” means. The Financial Industry Business Ontology (FIBO) is maintained by the EDM Council and is in production use at major financial institutions. Data Privacy Vocabulary (DPV) is a W3C specification. Open Digital Rights Language (ODRL) is a W3C recommendation. Data Catalog Vocabulary (DCAT) is a W3C recommendation used by public sector data catalogues across the EU. These are not bespoke ontologies someone invented. They are the real thing, in the wild, doing real governance work.

The Input

Single REST call kicks the whole process off. The input is deliberately minimal: a dataset title, a publisher IRI, the purposes the data will be used for, and a column list with names and datatypes. 10 columns. No existing annotations. No schema file. The system doesn’t know anything about this dataset yet beyond what’s in that JSON provided below.

$ curl -X POST http://localhost:8080/api/v1/annotations -H "Content-Type: application/json" -d \
'{
  "datasetTitle": "Customer accounts master",
  "publisherIri": "https://example.org/org-acme-bank",
  "purposeIris": [
    "https://w3id.org/dpv#CreditChecking",
    "https://w3id.org/dpv#FraudPreventionAndDetection"
  ],
  "columns": [
    { "name": "customer_id", "datatype": "string" },
    { "name": "lei",         "datatype": "string" },
    { "name": "legal_name",  "datatype": "string" },
    { "name": "email",       "datatype": "string" },
    { "name": "account_id",  "datatype": "string" },
    { "name": "account_type","datatype": "string" },
    { "name": "balance",     "datatype": "decimal" },
    { "name": "currency",    "datatype": "string" },
    { "name": "opened_date", "datatype": "date" },
    { "name": "jurisdiction","datatype": "string" }
  ]
}'

Step 1: Proposal Agent – the LLM as annotator

The first agent runs once per column. Its job is to produce 3 things: the correct propertyUrl (a full IRI from schema.org or FIBO), the DPV-PD personal data category, and a one-sentence note explaining the choice. Here is the system prompt it receives:

static final String SYSTEM = """
    You are a semantic data architect specialising in finance datasets.
    Given a CSV column name and datatype, you assign the best semantic annotation
    drawn from the following vocabularies:

    schema.org (prefix "schema:"): http://schema.org/
    FIBO FND (prefix "fibo-fnd:"): https://spec.edmcouncil.org/fibo/ontology/FND/
    FIBO BE  (prefix "fibo-be:"):  https://spec.edmcouncil.org/fibo/ontology/BE/
    FIBO FBC (prefix "fibo-fbc:"): https://spec.edmcouncil.org/fibo/ontology/FBC/

    Well-known mappings for finance datasets:
    - customer_id   → schema:identifier (SCHEMA_ORG, IDENTIFIER personal data)
    - lei           → fibo-be:hasLegalEntityIdentifier (FIBO_BE, no personal data)
    - legal_name    → fibo-fnd:hasLegalName (FIBO_FND, NAME personal data)
    - email         → schema:email (SCHEMA_ORG, EMAIL_ADDRESS personal data)
    - account_id    → fibo-fbc:hasAccountIdentifier (FIBO_FBC, FINANCIAL personal data)
    ...

    Respond ONLY with a JSON object - no markdown fences, no extra text:
    {
      "propertyUrl": "<full IRI of the semantic property>",
      "vocabulary": "<SCHEMA_ORG | FIBO_FND | FIBO_BE | FIBO_FBC>",
      "personalDataCategory": "<FINANCIAL | EMAIL_ADDRESS | NAME | IDENTIFIER | NONE>",
      "semanticNote": "<one sentence explaining the mapping choice>"
    }
    """;

Notice what the prompt is doing. It is not asking the model to reason freely about finance semantics. It is giving the model a constrained vocabulary of 4 FIBO modules plus schema.org, a mapping table of the most common cases, and a strict output format. The model’s job here is drafting and lookup, not reasoning. That’s the right job to give it. All 10 columns are proposed in parallel, using Java virtual threads.

List<CompletableFuture<ColumnAnnotation>> futures = descriptor.columns().stream()
    .map(col -> {
        ProposalInput input = new ProposalInput(col, feedbackByColumn.getOrDefault(col.name(), List.of()));
        return CompletableFuture.supplyAsync(() -> proposalAgent.process(input), executor);
    })
    .toList();

The executor is Executors.newVirtualThreadPerTaskExecutor(). 10 LLM calls fire simultaneously. This is the cost argument from Part 1 made concrete – parallel drafting, not sequential toil. A typical proposal for balance looks like this:

{
  "propertyUrl": "https://spec.edmcouncil.org/fibo/ontology/FND/Accounting/CurrencyAmount/hasAmount",
  "vocabulary": "FIBO_FND",
  "personalDataCategory": "FINANCIAL",
  "semanticNote": "The balance column represents a monetary amount on a financial account, mapped to the FIBO FND currency amount property."
}

Correct FIBO module, correct IRI, correct privacy category. That took the model about as long as it takes to call an API.

Step 2: Reviewer Agent – Consistency and ODRL

Once all 10 proposals come back, the full annotation set goes to the ReviewerAgent. Its job is different from the ProposalAgent. Reviewer sees the whole picture and checks for cross-column consistency, FIBO module accuracy, and DPV-PD completeness. The critical part of the reviewer’s system prompt is this section:

── AUTHORITATIVE MAPPING REFERENCE ─────────────────────────────────────────
These are the CORRECT mappings for common finance columns. If a column matches
one of these names, it MUST use exactly this propertyUrl and vocabulary.
Do NOT raise an ERROR for any column that already matches its authoritative mapping.

column_name  | propertyUrl (exact IRI)                                                      | vocabulary | personalDataCategory
-------------|------------------------------------------------------------------------------|------------|--------------------
customer_id  | http://schema.org/identifier                                                 | SCHEMA_ORG | IDENTIFIER
lei          | https://spec.edmcouncil.org/fibo/ontology/BE/.../hasLegalEntityIdentifier    | FIBO_BE    | NONE
legal_name   | https://spec.edmcouncil.org/fibo/ontology/FND/.../hasLegalName               | FIBO_FND   | NAME
email        | http://schema.org/email                                                      | SCHEMA_ORG | EMAIL_ADDRESS
account_id   | https://spec.edmcouncil.org/fibo/ontology/FBC/.../hasAccountIdentifier       | FIBO_FBC   | FINANCIAL
balance      | https://spec.edmcouncil.org/fibo/ontology/FND/.../hasAmount                  | FIBO_FND   | FINANCIAL
...

This table is the ontology asserting ground truth to the LLM. The reviewer is not permitted to raise an ERROR against a correctly-mapped column. What it is permitted to do is catch things the ProposalAgent got wrong on columns not in the table. A financial identifier mapped to a schema.org property when FIBO FBC is the right choice, a balance column missing its FINANCIAL classification, a pair of parallel columns using inconsistent vocabularies. The reviewer always returns an ODRL agreement proposal alongside its feedback. Here is the expected shape:

{
  "approved": true,
  "overallNote": "All columns correctly mapped...",
  "items": [],
  "odrlAgreement": {
    "assignerIri": "https://example.org/org-acme-bank",
    "assigneeIri": "https://example.org/authorised-counterparty",
    "termEndDateTime": "2027-06-30T00:00:00Z",
    "spatialConstraintIri": "https://example.org/region/EEA",
    "permittedPurposeIris": ["https://w3id.org/dpv#CreditChecking", "https://w3id.org/dpv#FraudPreventionAndDetection"],
    "prohibitedActionIris": ["http://www.w3.org/ns/odrl/2/sell", "http://www.w3.org/ns/odrl/2/distribute", "https://example.org/reIdentify"],
    "obligationActionIris": ["http://creativecommons.org/ns#Attribution", "http://www.w3.org/ns/odrl/2/delete", "https://example.org/notifyBreach"]
  }
}

This is not a generic open policy. It is a bilateral agreement: identified assigner, identified assignee, permitted only for the declared purposes, with explicit prohibitions (no selling, no onward distribution, no re-identification) and explicit obligations (attribution, deletion at term end, breach notification). The LLM is proposing the terms based on what the data contains; a human counsel still needs to sign it. One important note about the approved flag: it’s advisory. The decision to actually close the loop is made by a different gate.

Step 3: SHACL – the Deterministic Tollgate

After each review round, the application builds an RDF model with Apache Jena and runs it through four SHACL shapes. This is the deterministic, checkable layer Part 1 argued is needed to convert an agent’s confident guess into a verifiable result.

ex:RestrictedFinanceDatasetShape a sh:NodeShape ;
    sh:targetClass dcat:Dataset ;

    sh:property [ sh:path dct:conformsTo ; sh:minCount 2 ;
                  sh:message "Must conform to both the CSV-W schema and the metadata profile." ] ;
    sh:property [ sh:path dct:license ; sh:minCount 1 ] ;
    sh:property [ sh:path dct:accessRights ; sh:minCount 1 ;
                  sh:hasValue <http://publications.europa.eu/resource/authority/access-right/RESTRICTED> ;
                  sh:message "Restricted posture requires RESTRICTED access rights." ] ;
    sh:property [ sh:path odrl:hasPolicy ; sh:minCount 1 ] ;
    sh:property [ sh:path dpv:hasLegalBasis ; sh:minCount 1 ] ;
    sh:property [ sh:path dpv:hasDataController ; sh:minCount 1 ] .

ex:NoOpenDownloadShape a sh:NodeShape ;
    sh:targetClass dcat:Distribution ;
    sh:property [ sh:path dcat:downloadURL ; sh:maxCount 0 ;
                  sh:message "Restricted distributions must be served via dcat:accessService, not downloadURL." ] ;
    sh:property [ sh:path dcat:accessService ; sh:minCount 1 ] .

ex:AgreementPolicyShape a sh:NodeShape ;
    sh:targetObjectsOf odrl:hasPolicy ;
    sh:class odrl:Agreement ;
    sh:property [ sh:path odrl:assignee ; sh:minCount 1 ;
                  sh:message "Restricted use requires an identified assignee." ] .
  • Shape 1 enforces the required governance metadata on the dataset: 2 conformsTo links (structure and profile), a license IRI, the RESTRICTED access right (not an open dataset, not confidential – the specific EU publication authority vocabulary term), an ODRL policy, a legal basis, and an identified data controller.
  • Shape 2 enforces the restricted posture on the distribution: no downloadURL (open downloads are prohibited), and a mandatory accessService reference. A violation here means the distribution is leaking access rights.
  • Shape 3 enforces that the policy is a bilateral odrl:Agreement with an identified assignee – not an open odrl:Offer or odrl:Set. This is the difference between “terms posted on a website” and “a contract with a named counterparty.”
  • Shape 4 (not shown above) checks that every personal-data category declared on a column is also declared at the dataset level – you can’t have a column tagged dpv-pd:Financial without the dataset acknowledging it processes financial data.

The gate in the orchestrator is unambiguous:

// Approval is deterministic: no ERRORs + SHACL passes.
// The LLM's approved boolean is advisory; hasErrors() is the ground truth.
boolean canApprove = !feedback.hasErrors() && shaclResult.conforms();

The LLM’s vote doesn’t win on its own. SHACL failing is a veto. This is intentional. The LLM can be confidently wrong; the SHACL validator cannot be argued with.

The Feedback Loop

If the round fails, the session doesn’t stop. It routes. SHACL violations are converted into FeedbackItem records at ERROR severity and merged with the reviewer’s feedback:

private ReviewFeedback mergeShaclViolations(ReviewFeedback feedback,
                                             ShaclValidationService.ValidationResult shaclResult) {
    if (shaclResult.conforms()) return feedback;

    List<FeedbackItem> merged = new ArrayList<>(feedback.items());
    shaclResult.violations().forEach(v ->
        merged.add(new FeedbackItem("dataset", FeedbackItem.Severity.ERROR,
            "SHACL violation: " + v, "Fix the RDF structure to satisfy the constraint")));

    return new ReviewFeedback(false, feedback.overallNote(), merged, feedback.odrlAgreement());
}

Only ERROR items route back to the ProposalAgent in the next round. Warnings are informational. They appear in the session output but don’t trigger re-proposals. The feedback each column receives in round N+1 includes both the reviewer’s human-language suggestion and the SHACL violation string. The flow looks like this:

Annotation orchestration flow A POST request enters the annotation orchestrator, which calls the proposal and reviewer LLM agents and then a SHACL hard gate in a loop. On failure the round repeats; once the result is approved and conforms, an annotation session is written with Turtle export. POST /api/v1/annotations Annotation orchestrator runs the round loop Proposal agent column × N, in parallel Reviewer agent feedback + ODRL terms SHACL hard gate Jena · pass or fail ↻ on fail approved && conforms Annotation session rounds + Turtle export LLM agents deterministic gate

The loop runs until the SHACL gate passes and there are no ERRORs, or until max-rounds is reached (default: 5). In practice, for a well-known dataset like this one, it converges in one or two rounds.

Step 4: Term Agent

After approval, the TermsAgent runs once. It receives the complete approved annotation and produces a richer ODRL document: permissions with purpose constraints, temporal bounds, and duties; prohibitions with rationales; obligations with triggers; governing law; retention period. This is the document you send to counsel alongside a plain-English version. The machine-readable form goes into the RDF graph; the prose equivalent is what a human lawyer reviews. They should agree, and the SHACL shapes can be extended to check that they reference the same retention date.

The Output

The final serialization is TTL. Here is what the dataset resource looks like after a successful session:

@prefix dcat:  <http://www.w3.org/ns/dcat#> .
@prefix dct:   <http://purl.org/dc/terms/> .
@prefix dpv:   <https://w3id.org/dpv#> .
@prefix odrl:  <http://www.w3.org/ns/odrl/2/> .
@prefix csvw:  <http://www.w3.org/ns/csvw#> .
@prefix ex:    <https://example.org/> .

ex:dataset a dcat:Dataset ;
    dct:title "Customer accounts master"@en ;
    dct:publisher <https://example.org/org-acme-bank> ;
    dct:license ex:licenses/restricted-data-agreement ;
    dct:accessRights <http://publications.europa.eu/resource/authority/access-right/RESTRICTED> ;
    dct:conformsTo ex:schema/customer-accounts-csvw ,
                   ex:profile/finance-restricted ;
    odrl:hasPolicy ex:usage-agreement ;
    dpv:hasDataController <https://example.org/org-acme-bank> ;
    dpv:hasLegalBasis <https://w3id.org/dpv#Contract> ,
                      <https://w3id.org/dpv#LegalObligation> ;
    dpv:hasPurpose <https://w3id.org/dpv#CreditChecking> ,
                   <https://w3id.org/dpv#FraudPreventionAndDetection> ;
    dpv:hasPersonalData <https://w3id.org/dpv/pd#Financial> ,
                        <https://w3id.org/dpv/pd#EmailAddress> ,
                        <https://w3id.org/dpv/pd#Name> ,
                        <https://w3id.org/dpv/pd#Identifier> ;
    dcat:distribution ex:distribution-service .

ex:col-balance a csvw:Column ;
    csvw:name "balance" ;
    csvw:propertyUrl <https://spec.edmcouncil.org/fibo/ontology/FND/Accounting/CurrencyAmount/hasAmount> ;
    dpv:hasPersonalData <https://w3id.org/dpv/pd#Financial> ;
    schema:description "The balance column represents a monetary amount on a financial account." .

ex:col-email a csvw:Column ;
    csvw:name "email" ;
    csvw:propertyUrl <http://schema.org/email> ;
    dpv:hasPersonalData <https://w3id.org/dpv/pd#EmailAddress> .

Every triple (subject-predicate-object) in that output was written by an AI Agent. The dct:accessRights value is the specific EU vocabulary IRI – not “RESTRICTED” as a string, but a dereferenceable identifier that a system can look up and reason about. The csvw:propertyUrl on balance is a full FIBO FND IRI pointing into a published, versioned ontology. The dpv:hasPersonalData at the dataset level is there because Shape 4 required it, and the loop didn’t exit until it was.

What Inference Adds

The TTL above is all explicit triples. Every statement was deliberately asserted by an agent. But DPV and FIBO are OWL ontologies with class hierarchies that a reasoner can close over. What makes this different from 2005-style open-world OWL is the precondition. The graph the reasoner runs over is finite. It has a bounded and versioned vocabulary and just passed a SHACL tollgate. The reasoner isn’t chasing open-world inference across contested, heterogeneous data. It’s closing a transitive chain over a small set of axioms from a spec we control, on a dataset we just validated. The application runs this step via OwlInferenceService after the annotation loop exits. The DPV class hierarchy is loaded as inline axioms (no network call at runtime) and combined with the annotation graph in a Jena OWL_MEM_RDFS_INF model:

private static final String DPV_AXIOMS = """
    @prefix dpv:   <https://w3id.org/dpv#> .
    @prefix dpvpd: <https://w3id.org/dpv/pd#> .
    @prefix rdfs:  <http://www.w3.org/2000/01/rdf-schema#> .
    @prefix owl:   <http://www.w3.org/2002/07/owl#> .

    dpv:PersonalData    a owl:Class .
    dpvpd:Identifying   a owl:Class ; rdfs:subClassOf dpv:PersonalData .
    dpvpd:Financial     a owl:Class ; rdfs:subClassOf dpv:PersonalData .
    dpvpd:EmailAddress  a owl:Class ; rdfs:subClassOf dpvpd:Identifying .
    dpvpd:Name          a owl:Class ; rdfs:subClassOf dpvpd:Identifying .
    dpvpd:Identifier    a owl:Class ; rdfs:subClassOf dpvpd:Identifying .
    """;

public OntModel enrich(Model explicit) {
    OntModel inf = ModelFactory.createOntologyModel(OntModelSpec.OWL_MEM_RDFS_INF);
    inf.read(new StringReader(DPV_AXIOMS),  null, "TURTLE");
    inf.read(new StringReader(FIBO_AXIOMS), null, "TURTLE");
    inf.add(explicit);
    return inf;
}

The RDFS reasoner then fires rule rdfs11 (transitivity of rdfs:subClassOf ) to close the 2-hop chain EmailAddress -> Identifying -> PersonalData into the 1-hop derived fact EmailAddress rdfs:subClassOf PersonalData. The same closure fires for dpv-pd:Name and dpv-pd:Identifier. TheinferredPersonalDataCategories() method runs a SPARQL query against the inference model that uses only a single-hop rdfs:subClassOf pattern:

PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT DISTINCT ?cat WHERE {
    ?col <https://w3id.org/dpv#hasPersonalData> ?cat .
    ?cat rdfs:subClassOf <https://w3id.org/dpv#PersonalData> .
}

Against the plain annotation model, only the direct subclasses of dpv:PersonalData satisfy the single-hop pattern:

dpv-pd:Financial    <- direct subClassOf dpv:PersonalData           ✓
dpv-pd:Identifying  <- direct subClassOf dpv:PersonalData           ✓
dpv-pd:EmailAddress <- subClassOf dpv-pd:Identifying only           ✗ (missed)
dpv-pd:Name         <- subClassOf dpv-pd:Identifying only           ✗ (missed)
dpv-pd:Identifier   <- subClassOf dpv-pd:Identifying only           ✗ (missed)

Against the inference model, the transitive closure is in the graph, so the same 1-hop query returns all four categories used in our dataset:

dpv-pd:EmailAddress  ✓  (inferred: EmailAddress -> Identifying -> PersonalData)
dpv-pd:Financial     ✓
dpv-pd:Identifier    ✓  (inferred)
dpv-pd:Name          ✓  (inferred)

The result appears in the API response as inferredPersonalDataCategories. Downstream compliance systems can query “what personal-data categories does this dataset process?” without needing to know how many hops deep the DPV hierarchy goes, or at which level a given category sits. The same pattern extends to FIBO. The service loads fibo-fbc:FinancialAccount rdfs:subClassOf fibofnd:Account, so a downstream system querying for any fibofnd:Account gets hits on rows typed as fibo-fbc:FinancialAccount. The annotation is specific; the query can be general; the reasoner handles the gap.

The inference is only as correct as the rule loaded into DPV_AXIOMS and FIBO_AXIOMS. If those strings contain wrong rdfs:subClassOf assertions, the inferred results are wrong too. The SHACL shapes don’t catch rule errors (they validate the data graph, not the vocabulary). This is the same kind of 1-time expert investment as the authoritative mapping table: someone who knows DPV wrote those rules, and they hold until the spec changes. The inference scales for free across datasets once the vocabulary is correct. The vocabulary still has to be correct first.

The LLM can be confidently wrong; the SHACL validator cannot be argued with.

Outcome

The agents handled the per-dataset drafting: mapping column names to FIBO IRIs, classifying columns by DPV-PD category, proposing the ODRL agreement terms. This work is now seconds per dataset instead of hours. But a model proposing FIBO mappings will return confident, wrong identifiers. The authoritative mapping table in ReviewerPrompts and the SHACL shapes in finance-shapes.ttl were written by a human who knows FIBO. So was the DPV rules file. The expert cost hasn’t vanished; it’s been compacted from continuous per-dataset engagement into 1-time vocabulary and constraint design. The agents scale that investment across datasets. They don’t replace it. The SHACL shapes handled what the agents cannot: deterministic rejection. If dct:accessRights is missing, the validator fails. There is no confidence score, no “I think this is probably fine.” Pass or fail. The loop does not exit until it passes.

I stated in Part 1 that the distinction sharpens once an agent can act rather than just annotate. A batch annotation pipeline gets the data classification wrong and a human catches it in review. An agent that reads the resulting RDF and decides what to do with the balance field (whether to include it in an export, route it to a counterparty, aggregate it across currencies) acts on the classification before anyone reviews. The SHACL gate isn’t only about correctness in the archive. It’s a control boundary the agent passes before it does something irreversible. That’s the sense in which the vocabulary converts “probably right” into “checkable right” at the point where wrong stops being cheap. Neither the agents nor the validator is sufficient on its own. A SHACL profile without the agent loop to populate it is exactly what the 2005 tooling looked like: correct, expensive, therefore empty. An agent loop without the SHACL gate produces fluent-looking RDF with no formal guarantee it satisfies the constraints it claims to satisfy. Together, they produce the “second population” of semantic web technology. The part of ontology that never got hyped and kept working in the one environment where the cost of agreed meaning was never optional – data governance.