Ontology Conventions
How to model a domain in Kanonak — classes vs properties vs individuals, datatype vs object properties, subClassOf and multiple inheritance, and the explicit markers that promote individuals into the schema as enumerations.
A Kanonak ontology is built from three kinds of resource, and good modeling starts with knowing which one you are creating:
- a class (
Class) — a kind of thing, a contract its instances satisfy (
Person,Invoice,OperationCategory); - a property (
Datatype Property or
Object Property) — a relationship from a subject to a value, scoped by its
Domain and
Range;
- an individual — an instance of a class (
alice,invoice-2026-04).
Everything else in this guide follows from getting those three right.
A decision guide — what are you adding?
A KIND of thing, with its own properties and instances
-> a CLASS. type: rdfs.Class. Relate it to others with subClassOf.
A RELATIONSHIP or attribute of a class
-> a PROPERTY. Value is a literal (a date, a count, text)?
-> owl.DatatypeProperty, range an xsd datatype.
Value is another resource (a reference)?
-> owl.ObjectProperty, range the target class.
A specific INSTANCE
-> an INDIVIDUAL. type: <SomeClass>.
Is it a fixed, named member of the model (a category, a status)?
-> mark the class owl.oneOf -> it is an ENUMERATION (schema).
Is it content that changes over time (a record, a row, a snapshot)?
-> leave it unmarked -> it is DATA, not schema.
The protocol never guesses which individuals are schema. Promotion is driven by explicit markers (One Of,
In) — the enumerations-vs-data convention below is the heart of this guide.
- Layer
Guidance Layer
- Author
Paul Fryer
- Created Date
- May 30, 2026
Conventions
Classes, Properties, and Individuals
Model kinds as classes, relationships and attributes as properties, and specific things as individuals. A class is a contract: it says what properties its instances may carry (via each property's Domain). A property is first-class — it has its own URI, a domain, and a range — not a field buried in a class. An individual is an instance typed by a class. Name classes as singular nouns (
Invoice, not Invoices); name properties as the relationship they express (hasAuthor, publishedOn).
| Text | Rationale | |
|---|---|---|
| # | A property MUST be declared as its own resource with a | First-class properties are what make the graph self-describing and validatable: a tool can ask "what may a Person carry?" and resolve it through domains, rather than discovering fields by scanning instances. It is also what lets one property be shared and refined across classes. |
class-with-properties
Person is the kind; fullName carries a literal value, employer references another resource. Both properties name their domain and range explicitly.
- Value
- Person: type: rdfs.Class rdfs.comment: A human being in the directory. fullName: type: owl.DatatypeProperty rdfs.domain: Person rdfs.range: xsd.string employer: type: owl.ObjectProperty rdfs.domain: Person rdfs.range: Organization
Datatype vs Object Properties
The single most important property distinction. A Datatype Property holds a literal — a value that is its content: a string, a number, a date, a boolean. Its
Range is an xsd datatype (or
Literal). An
Object Property holds a reference — a link to another resource. Its range is a
Class, and its values resolve to other entities by URI. The range is what decides it: a datatype range means a value, a class range means a reference. Choosing wrong makes the graph either un-navigable (a reference flattened to a string) or un-renderable (a literal treated as a broken link).
| Text | Rationale | |
|---|---|---|
| # | A | The protocol reads a property's kind and range together: an object property's values are resolved as references across the import closure, a datatype property's values are kept as literals. A mismatch (a class range on a datatype property, or a literal range on an object property) makes resolution and rendering wrong, and is reported at validate time. |
both-kinds
dueDate is a literal date; billedTo points at a Customer resource. Range type follows kind in both cases.
- Value
- dueDate: type: owl.DatatypeProperty rdfs.domain: Invoice rdfs.range: xsd.date # a literal value billedTo: type: owl.ObjectProperty rdfs.domain: Invoice rdfs.range: Customer # a reference to a resource
reference-as-string
Avoid — modeling a relationship as a string. The link to the customer becomes opaque text that cannot be navigated, rendered as a reference, or validated. Use an owl.ObjectProperty with range Customer.
- Value
- billedTo: type: owl.DatatypeProperty # WRONG: a reference modeled as a literal rdfs.domain: Invoice rdfs.range: xsd.string
subClassOf and Multiple Inheritance
Subclass Of says "every instance of this class is also an instance of that one," so the subclass inherits the parent's properties. A class is a contract, and
Subclass Of is a list — a class may have several parents, and an instance may be typed by several classes (multiple inheritance is first-class, faithful to RDF). Inherited properties merge openly: a subclass carries every property whose domain is the class, any ancestor, or
Resource. The one thing multiple inheritance must not do is inherit two different properties that share a name but contradict each other.
| Text | Rationale | |
|---|---|---|
| # |
| A class is a contract, not a slot in a single-parent tree. Allowing several parents lets a |
| Text | Rationale | |
|---|---|---|
| # | A class MUST NOT inherit, through two or more parents, two distinct properties (different URIs) that share a local name but have incompatible | A class is a single coherent contract. Two same-named properties with conflicting types are two contradictory obligations for one name — there is no consistent value the instance could carry, and no consumer could agree on the property's shape. The protocol reports this "diamond name-clash" as an error at validate time so it is fixed in the model, not discovered downstream. (A single property reached through several inheritance paths is fine — it is one property; so are two same-named properties whose range and cardinality already agree.) |
multiple-parents
Manager satisfies both contracts and inherits the properties of each. No clash, because the inherited property sets are disjoint (or identical where they overlap).
- Value
- Employee: { type: rdfs.Class } Stakeholder: { type: rdfs.Class } Manager: type: rdfs.Class rdfs.subClassOf: [ Employee, Stakeholder ]
diamond-name-clash
Invalid — C inherits two distinct code properties with incompatible ranges (string vs integer). There is no single contract for code. Rename one, re-domain it, or restructure so C inherits only one code.
- Value
- # pkg A A: { type: rdfs.Class } code: { type: owl.DatatypeProperty, rdfs.domain: A, rdfs.range: xsd.string } # pkg B B: { type: rdfs.Class } code: { type: owl.DatatypeProperty, rdfs.domain: B, rdfs.range: xsd.integer } # pkg C C: type: rdfs.Class rdfs.subClassOf: [ a.A, b.B ] # inherits a.code (string) AND b.code (integer)
Enumerations vs Data Individuals
Individuals fall into two groups that look identical in the graph — both are instances of a class — so the protocol tells them apart only by an explicit marker, never by a heuristic. A closed set of named members that is part of the model (a fixed list of categories, statuses, operation kinds) is an enumeration: mark the class with One Of listing exactly its individuals. A closed set of literal values allowed on one property is a property enumeration: constrain the property with
In (see the shapes convention). Everything else — instances whose values are content that changes over time (records, rows, snapshots) — is data, not schema, and is never promoted. "This class happens to have some individuals" is explicitly NOT a marker.
| Text | Rationale | |
|---|---|---|
| # | An individual becomes part of the schema ONLY through an explicit marker: | Enumerable categories and content records are indistinguishable at the graph level — both are typed instances. Without an explicit marker, a tool would have to guess, and would either bake changing data into the schema or miss a genuine enumeration. The marker makes the author's intent unambiguous and stable. |
| # | The members of an | The |
closed-enumeration
OperationCategory is a closed enumeration of exactly three named individuals — part of the schema. Each member is a resource with its own label and URI.
- Value
- OperationCategory: type: rdfs.Class owl.oneOf: [ FileOperations, ShellOperations, NetworkOperations ] FileOperations: { type: OperationCategory, rdfs.label: File Operations } ShellOperations: { type: OperationCategory, rdfs.label: Shell Operations } NetworkOperations: { type: OperationCategory, rdfs.label: Network Operations }
data-as-enumeration
Avoid — servers are operational data that change over time, not a fixed part of the model. Leave Server unmarked and let its instances be data. Reserve One Of for genuinely fixed sets (statuses, categories, units).
- Value
- Server: type: rdfs.Class owl.oneOf: [ prod-server-1, prod-server-2 ] # WRONG: these are data
Constraining Properties with Shapes
The bare class/property graph says a property exists on a class; it does not say how many values are allowed, which literals are permitted, or what a string must look like. Express those obligations with scoped SHACL shapes (Node Shape /
Property Shape). A
Property Shape (named by
Path) on a shape targeting a class (
Target Class) refines one property:
Min Count /
Max Count give cardinality (minCount ≥ 1 is required; maxCount = 1 is single-valued, otherwise a list);
In gives a closed set of permitted literal values;
Pattern /
Min Length /
Max Length constrain strings. Shapes refine the contract — they do not redefine the property.
| Text | Rationale | |
|---|---|---|
| # | To say a property is required, single-valued, drawn from a closed set of values, or pattern-constrained, declare a | Shapes put the constraint in the graph where it is validated and can be read by any consumer, instead of in prose a tool cannot enforce. Cardinality and value sets become first-class, checkable facts about the model. |
| # | For a property whose values are a fixed set of literals (not references), use | The two enumeration markers — |
shape-constraints
steps is required and may repeat; status is single-valued and limited to three literal values; slug is a single string matching a pattern. Each constraint refines an existing property of Workflow.
- Value
- WorkflowShape: type: sh.NodeShape sh.targetClass: Workflow sh.property: - { sh.path: steps, sh.minCount: 1 } # required, a list - { sh.path: status, sh.maxCount: 1, sh.in: [ draft, active, done ] } - { sh.path: slug, sh.maxCount: 1, sh.pattern: "^[a-z][a-z0-9-]*$" }