# OWL — Quickstart: Turtle In, JSON Schema Out: Your First OWL Transform

There is a `.ttl` file somewhere in your repository. Someone modeled the domain properly once - classes, subclass axioms, cardinality restrictions, a SKOS scheme for the controlled list - and then everyone else carried on hand-writing JSON Schema and DDL, because nothing bridged the two.

# Turtle In, JSON Schema Out: Your First OWL Transform

There is a `.ttl` file somewhere in your repository. Someone modeled the domain properly once - classes, subclass axioms, cardinality restrictions, a SKOS scheme for the controlled list - and then everyone else carried on hand-writing JSON Schema and DDL, because nothing bridged the two.

CoreModels treats OWL as a first-class **source**, not only an export target. The format key is `owl`, the payload is a Turtle document as plain text, and one HTTP call turns that ontology into a schema your API team can use - together with a ledger of everything the conversion could not carry exactly.

This is the shortest path from a Turtle file to a first result.

## The ontology

Save this as `catalog.ttl`. It is small on purpose, but it exercises the constructs that matter: a class hierarchy, a required property, a repeatable one, an object property pointing at a controlled vocabulary, and a cross-standard equivalence.

```turtle
@prefix owl:  <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix schema: <https://schema.org/> .
@prefix ex:   <https://example.org/catalog#> .

ex:ProductCatalog a owl:Ontology ;
    rdfs:label "Product Catalog" .

ex:Product a owl:Class ;
    rdfs:label "Product" ;
    rdfs:comment "Anything the catalog can sell." ;
    owl:equivalentClass schema:Product ;
    rdfs:subClassOf [ a owl:Restriction ; owl:onProperty ex:sku ; owl:minCardinality 1 ] ,
                    [ a owl:Restriction ; owl:onProperty ex:sku ; owl:maxCardinality 1 ] ,
                    [ a owl:Restriction ; owl:onProperty ex:listPrice ; owl:maxCardinality 1 ] ,
                    [ a owl:Restriction ; owl:onProperty ex:availability ; owl:maxCardinality 1 ] .

ex:BundledProduct a owl:Class ;
    rdfs:label "Bundled Product" ;
    rdfs:subClassOf ex:Product .

ex:sku a owl:DatatypeProperty ;
    rdfs:label "sku" ;
    rdfs:domain ex:Product ;
    rdfs:range xsd:string .

ex:listPrice a owl:DatatypeProperty ;
    rdfs:label "listPrice" ;
    rdfs:domain ex:Product ;
    rdfs:range xsd:decimal .

ex:tag a owl:DatatypeProperty ;
    rdfs:label "tag" ;
    rdfs:domain ex:Product ;
    rdfs:range xsd:string .

ex:availability a owl:ObjectProperty ;
    rdfs:label "availability" ;
    rdfs:domain ex:Product ;
    rdfs:range ex:AvailabilityStatus .

ex:AvailabilityStatus a owl:Class ;
    rdfs:label "Availability Status" ;
    rdfs:subClassOf skos:Concept .

ex:AvailabilityStatusScheme a skos:ConceptScheme ;
    rdfs:label "Availability Status" .

ex:Available a ex:AvailabilityStatus, skos:Concept ;
    skos:inScheme ex:AvailabilityStatusScheme ;
    skos:prefLabel "Available" .

ex:InStock a ex:AvailabilityStatus, skos:Concept ;
    skos:inScheme ex:AvailabilityStatusScheme ;
    skos:prefLabel "In stock" ;
    skos:broader ex:Available ;
    skos:exactMatch schema:InStock .

ex:Discontinued a ex:AvailabilityStatus, skos:Concept ;
    skos:inScheme ex:AvailabilityStatusScheme ;
    skos:prefLabel "Discontinued" .
```

## What you need

A bearer token and a CoreModels project id. The route we are about to call - `graph/transform/schema/map` - is **stateless**: the project scopes authorization only. Nothing is read from the graph, nothing is written to it, a Viewer-role token is enough, and a scratch project is fine. (The same call is available to agents as the `transform_schema` MCP tool; the arguments match, except that the project id travels as `graphProjectId` and the nested `mapping` object flattens to `mappingKind` / `guide` / `caseInsensitive`.)

## The call

```bash
export TOKEN="…"
export PROJECT_ID="…"          # 32-char hex project id

jq -n --rawfile ttl catalog.ttl '{
  sourceFormat:     "owl",
  sourceSchema:     $ttl,
  targetFormat:     "jsonschema",
  targetHintFormat: "owl",
  targetHintSchema: $ttl,
  mapping:          { kind: "inferred" }
}' > body.json

curl -sS -X POST \
  "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data @body.json
```

One thing needs explaining before the output: `targetHintSchema`. Inferred mapping works by matching labels and types against *something*, and it declines rather than guessing when it has nothing to aim at. Passing the source document as its own hint is the identity case - it says "re-express this faithfully, do not re-align it." Later, when you have a canonical model to map toward, you swap that hint out and the same call becomes an alignment.

## The output

```jsonc
{
  "success": true,
  "lossiness": [],
  "errors": [],
  "schema": {
    "type": "object",
    "properties": {
      "sku": {
        "type": "string",
        "x-maps-to": { "ex": "https://example.org/catalog#sku" }
      },
      "listPrice": {
        "type": "number",
        "x-maps-to": { "ex": "https://example.org/catalog#listPrice" }
      },
      "tag": {
        "type": "array",
        "items": { "type": "string" },
        "x-maps-to": { "ex": "https://example.org/catalog#tag" }
      },
      "availability": {
        "enum": ["ex:Available", "ex:InStock", "ex:Discontinued"],
        "x-maps-to": { "ex": "https://example.org/catalog#availability" }
      }
    },
    "required": ["sku"],
    "x-maps-to": {
      "ex": "https://example.org/catalog#Product",
      "owl": "https://schema.org/Product"
    },
    "$defs": {
      "ex:BundledProduct": {
        "allOf": [
          { "$ref": "#/$defs/ex:Product" },
          { "type": "object", "properties": {} }
        ],
        "x-maps-to": { "ex": "https://example.org/catalog#BundledProduct" }
      }
    }
  },
  "plan": { "operations": [ /* the executed plan - see below */ ] }
}
```

## Read the output

Six things in that JSON came from OWL constructs, and each is worth checking back against the Turtle:

- **`required: ["sku"]`** came from `owl:minCardinality 1` on `ex:sku`. Nothing else in the file said "required" - cardinality restrictions are OWL's own carrier for that fact, and the coder reads them as exactly that.
- **`sku` is a scalar, `tag` is an array.** `ex:sku` carries a `maxCardinality 1` restriction; `ex:tag` carries no restriction at all. An unrestricted RDF property is optional and multi-valued - the open-world default - and the decoder respects it instead of quietly assuming "one string." If `tag` should be single-valued, say so with a restriction; the difference is now visible instead of implied.
- **`listPrice` became `"type": "number"`.** `xsd:decimal` has no exact JSON Schema counterpart; the numeric family collapses.
- **`availability` became an `enum`.** The class declared `rdfs:subClassOf skos:Concept`, so it and its individuals were read as a controlled vocabulary rather than as another entity type.
- **`x-maps-to` is everywhere.** Every OWL entity *is* an IRI, so identity lifts straight through: the entity's own IRI plus the `owl:equivalentClass schema:Product` assertion both land on the `Product` type. The alignment work you did in Turtle survives into JSON Schema instead of stopping at the border.
- **`ex:BundledProduct` arrived as an `allOf` composition** in `$defs`. `rdfs:subClassOf` between two named classes is inheritance, and inheritance is modeled natively rather than flattened.

## Read the ledger

`"lossiness": []` on this run. That is the exception, not the rule - and the ledger, not `success`, is the field you should be reading on every call.

`success: true` means "it ran." It does not mean "nothing changed." The lossiness ledger is the honest list of what the target format could not hold exactly. It has four kinds: **StructuralDrop** (no home in the target), **TypeApproximation** (a close-but-not-exact type), **ConstraintRelaxation** (a rule that could not be enforced), and **SemanticNarrowing** (meaning narrowed or guessed).

Change one word in the request - `"targetFormat": "linkml"` - and the same ontology comes back with an entry:

```json
{
  "kind": "SemanticNarrowing",
  "path": "Taxonomy[ex:AvailabilityStatus]",
  "explanation": "permissible_values are flat; the term hierarchy was flattened."
}
```

That is the `skos:broader` link from `ex:InStock` up to `ex:Available`. LinkML enumerations have no hierarchy, so the parent-child relationship did not survive the trip. Nothing failed; you were told. Treat the ledger as a review checklist: an empty list is a clean conversion, and every entry names the exact path to double-check on the target side.

## If the call fails

Two failures account for almost every first attempt, and both name their cause precisely.

If the Turtle is structurally broken - an unclosed IRI, an unterminated literal - the run stops and the envelope carries a line number you can jump to:

```json
{
  "success": false,
  "lossiness": [],
  "errors": [ { "path": "$", "message": "Line 1: Unclosed IRI reference (missing '>')." } ],
  "schema": null
}
```

If you dropped the two `targetHint*` keys, the engine declines rather than guessing, and says so: `The inference resolver requires a target IR to match against.` Put the hint back - the source document itself is a perfectly good one until you have a real target.

Note what is *not* on that list. Dublin Core dates, versioning statements, annotation properties, anything else outside the schema vocabulary: those never fail an import. The decoder counts the triples it did not consume and reports one summarized ledger entry, so a real-world ontology full of metadata still goes through.

## What actually happened

Five stages, in order. The Turtle was **decoded** into a neutral schema model. The inferred strategy **produced a plan**. The plan went through a **validation gate** that every plan passes, whoever or whatever produced it. The engine **executed** it. The result was **encoded** into JSON Schema. Lossiness is collected at every stage and aggregated into the single ledger you just read.

The `plan` object in the response is not decoration. It is the executed plan as a reviewable, replayable artifact: the same source plus the same plan produces the same output. Keep it and a one-off conversion becomes a build step.

## Next

Swap `targetFormat` for `sql`, `avro`, `shex`, `linkml`, `protobuf`, `odcs`, `osi`, `jsonld`, or `owl` itself and re-run - the decode work is already done, so a new target costs one word. Then replace `targetHintSchema` with the schema you actually want to align to, and watch the plan turn from an identity mapping into a real alignment.

For the full endpoint catalog and ready-to-paste bodies for every other format on this surface, see the Transform section of the CoreModels documentation.
