Protocol

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:

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).

Has Required Rule#
TextRationale
#

A property MUST be declared as its own resource with a Domain and Range, not implied by appearing on an instance. The domain says which class(es) the property applies to; the range says what its values are.

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).

Has Required Rule#
TextRationale
#

A Datatype Property's Range MUST be a datatype (an xsd type or Literal); a Object Property's range MUST be a Class. Use a datatype property when the value is content; use an object property when the value is a reference to another resource.

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.

Has Required Rule#
TextRationale
#

Subclass Of MAY name more than one parent; model a class that genuinely satisfies several contracts by listing all of them. Properties accumulate down every branch of the hierarchy.

A class is a contract, not a slot in a single-parent tree. Allowing several parents lets a Manager be both an Employee and a Stakeholder without duplicating either contract.

Has Forbidden Rule#
TextRationale
#

A class MUST NOT inherit, through two or more parents, two distinct properties (different URIs) that share a local name but have incompatible Range or cardinality. Resolve the clash by renaming one property, re-scoping its Domain so it is not inherited, restructuring the hierarchy, or making the two genuinely identical.

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.

Has Required Rule#
TextRationale
#

An individual becomes part of the schema ONLY through an explicit marker: One Of on its class (a closed set of named individuals) or In on a property (a closed set of literal values). A class that merely has declared individuals MUST NOT be treated as an enumeration — its individuals are data.

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 One Of MUST be named individuals (resolved references), not literals. Each member's local name identifies it within the enumeration. For a closed set of literal values, do not use One Of on a class — constrain a property with In.

The One Of marker defines a class extensionally as exactly those individuals, so its members are resources with identity. A literal in that list is a category error (and, because an unresolved name reads as a literal, it is how a misspelled or unimported member surfaces) — reported at validate time. Literal value sets are a property-level concern, which is what In expresses.

#

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.

Has Recommended Rule#
TextRationale
#

To say a property is required, single-valued, drawn from a closed set of values, or pattern-constrained, declare a Property Shape on a Node Shape whose Target Class is the class — rather than inventing ad-hoc conventions or encoding the constraint in a comment.

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 In on its Property Shape. Use One Of on a class only for a fixed set of individuals.

The two enumeration markers — In and One Of — are not interchangeable: In constrains a property's literal values, while One Of defines a class's individual membership. Matching the marker to what is actually closed keeps the model honest.

#

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-]*$" }