Files
data-meta-model/governance/rules.yaml
T

1046 lines
52 KiB
YAML
Raw Normal View History

2026-08-05 09:14:34 +02:00
# PERNOD RICARD DATA METAMODEL — T-BOX RULEBOOK
# =============================================================================
# SINGLE SOURCE OF TRUTH. The .md and .xlsx deliverables are GENERATED from this
# file and must never be edited by hand (EV-007, EV-008).
#
# python3 generate_rulebook_md.py
# python3 generate_rulebook_xlsx.py
#
# severity : BLOCKING | MAJOR | GUIDELINE — one per rule. The rule is the
# rule; transitional tolerance belongs to a migration process,
# not to a normative document.
# control.tier : shacl | script | human (EV-012 — where the rule is enforced)
# control.executor : the script or shape that enforces it (EV-011 — no blocking
# rule without an executor)
# control.procedure: filled in by the processbook. null until then.
# =============================================================================
meta:
title: Pernod Ricard Data MetaModel — T-Box Rulebook
version: "1.1"
status: Draft for review
date: "2026-08-03"
scope: >
Governs the vocabulary of the model itself: the IRIs, labels and declarative
axioms of every class, property and enumeration individual. Does not govern
instances, which remain under the A-Box rulebook.
audience: >
Written to be read and applied directly, by a person or by a language model,
without further context. Every rule states what must hold, why, and what
enforces it.
categories:
Identifier:
title: Form of the IRI
intent: >
The IRI is identity. It is immutable in the RDF sense — changing one is a
migration, never an edit — so it must be unambiguous, searchable and free
of local convention.
Label:
title: Derivation and display
intent: >
The label is derived, not authored. Anything a reader needs that the
derivation cannot produce lives in a dedicated channel: the short label
for the spoken form, the comment for meaning.
Declaration:
title: Declarative completeness
intent: >
Nothing is left to be guessed. What a term is, what it applies to, where
it sits and where it came from are all stated, never inferred from
structure or from silence.
Evolution:
title: Change and propagation
intent: >
Every rule here is anchored on a real failure. They govern how the model
changes without breaking what depends on it.
severity_model:
BLOCKING: >
The commit does not pass. Enforced pre-commit and in continuous integration
by the named executor.
MAJOR: >
Reviewed and answered for, but not machine-enforceable. Reserved for rules
that require human judgement, per EV-011.
GUIDELINE: >
Recommended practice with no gate.
rules:
# ---------------------------------------------------------------- IDENTIFIER
- id: TN-001
category: Identifier
title: CamelCase IRI with no separator
statement: >
The local name of an IRI is strict CamelCase with no separator of any kind:
no hyphen, no underscore, no dot, no space. Initial capital for a class or
an enumeration individual; initial lower case for a property, whether
object, datatype or annotation. Consecutive capitals are admitted only where
they carry an acronym reproduced from a target class short label under
TN-011, and nowhere else.
scope: [class, object_property, datatype_property, annotation_property, individual]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A hyphen forces escaping in certain Turtle and SPARQL contexts and makes
decamelisation ambiguous: Sub-Domain has no single correct expansion.
Typographic convention is a display concern and belongs to the short label,
never to identity.
examples:
- {from: "pr:data_domain", to: "pr:DataDomain", note: no underscore}
- {from: "pr:Sub-Domain", to: "pr:DataSubDomain", note: no hyphen}
- {from: "pr:BelongsTo", to: "pr:belongsTo", note: a property starts lower case}
- id: TN-002
category: Identifier
title: No acronym in the IRI
statement: >
The local name of an IRI contains no acronym, initialism or abbreviation.
Every short form is expanded in full. One exception, and one only: a
relation reproducing the short label of the class it targets carries that
short label as it stands, acronym included, under TN-011. No other term may
carry an acronym, and no exception list is maintained.
scope: [class, object_property, datatype_property, annotation_property, individual]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: A-Box NR-004
rationale: >
An acronym in an identifier is a local convention pretending to be a name.
It is unsearchable by anyone who does not already know it, and it collides
across domains: PO is a Product Owner in one team and a Purchase Order in
another. Length is not a counter-argument — an IRI is written by tooling and
read in context, and the short form remains available through the short
label and the acronym annotation. The single exception exists because a
relation name is read on every edge of the graph and in every query, where
the spoken form is what makes it legible; reproducing the target's short
label verbatim also keeps the relation name derivable from the class rather
than invented, which a truncated form would not.
examples:
- {from: "pr:KPI", to: "pr:KeyPerformanceIndicator", note: "short form moves to pr:acronym"}
- {from: "pr:BIDataSource", to: "pr:BusinessIntelligenceDataSource", note: null}
- {from: "pr:definitionUri", to: "pr:definitionAddress", note: "URI is itself an acronym"}
- {from: "a relation targeting a class whose short label is BI Field", to: "pr:containsBIField", note: "the one exception — see TN-011"}
- id: TN-003
category: Identifier
title: Display properties are annotations
statement: >
pr:acronym and pr:shortLabel are declared owl:AnnotationProperty, never
owl:DatatypeProperty.
scope: [annotation_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A datatype property applies to an individual. Annotating a term of the
vocabulary with one takes the ontology out of OWL DL into OWL Full, which
reasoners reject and strict validators flag. An annotation property applies
to anything — class, property or individual. Nothing is lost on the control
side: SHACL validates triples and does not read OWL declarations, so every
shape targeting these properties keeps working unchanged.
examples:
- {from: "pr:shortLabel a owl:DatatypeProperty", to: "pr:shortLabel a owl:AnnotationProperty", note: null}
- {from: "pr:acronym a owl:DatatypeProperty", to: "pr:acronym a owl:AnnotationProperty", note: null}
- id: TN-004
category: Identifier
title: No digit in the IRI
statement: >
The local name of a vocabulary term contains no digit. A numeric parameter —
a window, a threshold, a version — is a value, never an identity.
scope: [class, object_property, datatype_property, annotation_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: A-Box NR-014 (deliberate opposite)
rationale: >
An instance identifier does encode numbers, and that is its purpose: it
carries a position in the domain tree. A vocabulary term encodes nothing but
its own meaning. The opposition is deliberate and is stated in the ontology
header so that no reader takes one for a violation of the other. A term
named after a thirty-day window starts lying the day the window becomes
ninety, and nothing detects it.
examples:
- {from: "pr:queryCount30d", to: "pr:queryCount", note: the window belongs to the harvesting specification}
- id: TN-005
category: Identifier
title: A class is a singular common noun
statement: >
The local name of a class is a singular noun phrase. No plural, no verb. No
type word such as Object, Entity or Item unless it names a genuine
abstraction of the model. No suffix rule is imposed on abstract classes.
scope: [class]
severity: BLOCKING
control: {tier: [script, human], executor: check_tbox_naming.py, procedure: null}
filiation: A-Box NR-008, NR-013
rationale: >
Abstractness is not inferable from a name and must not be encoded in one.
The correspondence fails in both directions: classes can be abstract without
any suffix, and classes ending in Object can be among the most heavily
instantiated in the model. Renaming a central business term to satisfy a
naming rule would trade meaning for symmetry. Abstractness is declared
instead, by TN-007.
examples:
- {from: "pr:DataDomains", to: "pr:DataDomain", note: singular}
- {from: "pr:ActorObject", to: "pr:Actor", note: no type word added for its own sake}
- id: TN-006
category: Identifier
title: Every layer root is abstract
statement: >
A class that serves as the root of a layer carries pr:isAbstract true.
scope: [class]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A non-abstract layer root allows an instance to be typed as an object of
that layer without saying which kind of object it is. That statement carries
no information and no downstream control can repair it.
examples:
- {from: "pr:PhysicalLayerObject with no declaration", to: "pr:isAbstract true", note: null}
- id: TN-007
category: Identifier
title: Every class declares whether it is abstract
statement: >
Every owl:Class carries pr:isAbstract with an explicit boolean value.
Absence of the property is a violation, not a default.
scope: [class]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
Where declaration is optional, nothing distinguishes a class deliberately
left concrete from one where the question was never asked. Two situations
make the compulsion necessary. A class with subclasses may be perfectly
instantiable, and has been wrongly marked abstract by inference. And a class
used as a classification axis may have no declared subclass at all, its
members being typed by multiple typing on instances — its abstractness is
then structurally unverifiable and only the explicit declaration carries it.
examples:
- {from: "a class with no declaration", to: "pr:isAbstract true or false", note: silence is a violation}
- {from: "pr:Metric inferred abstract because it has a subclass", to: "pr:isAbstract false", note: concrete despite having a subclass}
- id: TN-008
category: Identifier
title: Abstractness is declared, never inferred
statement: >
Abstractness is read from pr:isAbstract and from nothing else. No consumer
of the model — viewer, export, script or documentation generator — may
derive it from the presence of subclasses, from the absence of instances, or
from any other structural signal.
scope: [class, consumer]
severity: BLOCKING
control: {tier: human, executor: consumer contract review, procedure: null}
filiation: null
rationale: >
A declaration rule holds in the source only if it also binds every consumer.
Otherwise each one re-invents its own inference and the model acquires as
many answers as it has readers. This is the same failure mode that justified
a governed short label rather than a display dictionary per viewer.
examples:
- {from: "isAbstract = term.subclasses.length > 0", to: "isAbstract = term.isAbstract === true", note: consumer contract}
- id: TN-009
category: Identifier
title: A relation begins with a verb
statement: >
The local name of an object property begins with a verb, in lower case. Both
the active form and the passive or participial form are admitted: a past
participle is a verb in first position. No relation begins with a
preposition or with a noun.
scope: [object_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A relation reads as a verb and an attribute reads as a noun. The distinction
is what lets a reader tell an edge from a field without opening the
declaration. The explicit clause on participial forms is required: read
literally, a verb-first rule would condemn a whole family of sound relations
such as computedBy, storedIn and derivedFrom.
examples:
- {from: "pr:inDatabase", to: "pr:isInDatabase", note: a preposition is not a verb}
- {from: "pr:primaryLocation", to: "pr:primarilyStoredIn", note: a noun is not a verb}
- {from: "pr:computedBy", to: "pr:computedBy", note: participial form is conforming}
- id: TN-010
category: Identifier
title: The is prefix is either a copula or a predicate
statement: >
On an owl:ObjectProperty, is acts as a COPULA — a state verb before a
preposition or adjective, whose target is a node of the graph. On a boolean
datatype or annotation property, is acts as a PREDICATE — it introduces an
adjective or participle and the value is true or false. No other use is
admitted.
scope: [object_property, datatype_property, annotation_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
Conformance of a name beginning with is is never checkable on the name
alone. A control asserting that every such term is a boolean produces false
positives on every copula; the reverse control lets real problems through.
The correct check reads the declared type of the property first, then
applies the matching clause.
examples:
- {from: "pr:isInSchema", to: copula, note: object property, target is a node}
- {from: "pr:isNullable", to: predicate, note: datatype property of range xsd:boolean}
- id: TN-011
category: Identifier
title: A relation names its target by its short label
statement: >
A relation need not mention its target class at all. When it does, it uses
the target class's pr:shortLabel AS IT STANDS — not the full label, not a
truncation of the short label, and not a form invented for the occasion.
Where the short label carries an acronym, the acronym is reproduced with it:
this is the single exception admitted by TN-002, and TN-001 admits the
consecutive capitals it produces.
scope: [object_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
Relation names are what is read on the edges of the graph, constantly, in
every viewer and every query. Full labels make them unreadable on an arrow.
The short label is the form the organisation actually speaks, and reusing it
makes a relation name predictable from the class it points at instead of
arbitrary. Reproducing it whole is what preserves that property: a
truncation would be a form the model invented, unpredictable from the class
and unverifiable against it. A corollary: relations do not normally carry a
short label of their own, since this rule already keeps their IRIs short.
examples:
- {from: "pr:hasDomainOwner", to: "pr:hasDomainOwner", note: "target DataDomainOwner, short label Domain Owner — conforming"}
- {from: "pr:hasDGL", to: "pr:hasGovernanceLead", note: "target short label is Governance Lead"}
- {from: "pr:inBIDataSource", to: "pr:isInBIDataSource", note: "short label BI Data Source reproduced whole, acronym included"}
- {from: "pr:containsField", to: "pr:containsBIField", note: "a truncated short label is not admitted"}
- id: TN-012
category: Identifier
title: An attribute is a noun
statement: >
The local name of a datatype property is a noun phrase. It begins with no
verb, and in particular with no has.
scope: [datatype_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A has prefix carries no information — rdfs:domain already says who has the
attribute — and it costs the reader the distinction TN-009 exists to make
visible. Under TN-018 the derived label would read as a verb phrase on a
field, so a relation and an attribute would become indistinguishable in
every display.
examples:
- {from: "pr:hasFormula", to: "pr:formula", note: null}
- {from: "pr:hasName", to: "pr:canonicalName", note: name alone is too generic to stand as an identity}
- id: TN-013
category: Identifier
title: A date attribute is a noun, not a participle
statement: >
An attribute holding a date or timestamp is named as a noun.
scope: [datatype_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
The participial form is idiomatic English but creates a standing family of
exceptions to TN-012 that must be defended at every review. The nominal form
is consistent with the other attributes of the model and needs no defence.
examples:
- {from: "pr:createdOn", to: "pr:creationDate", note: null}
- {from: "pr:lastQueriedOn", to: "pr:lastQueryDate", note: null}
- id: TN-014
category: Identifier
title: An attribute never takes a relational suffix
statement: >
A datatype property never takes a form ending in By, In, From or On. Those
forms are reserved for object properties. The only exception is the
predicative is of booleans, covered by TN-015.
scope: [datatype_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
An attribute named like a relation will be read as one. A reader
encountering a term in the shape of computedBy or ownedBy expects a node at
the other end, not a string. The tell is usually visible in the label: a
parenthetical gloss added to explain what the value actually holds is
evidence that the name itself is wrong.
examples:
- {from: "pr:definedBy holding a string", to: "pr:definitionAddress", note: "address, not location — location is taken by the storage side"}
- id: TN-015
category: Identifier
title: A boolean attribute begins with is
statement: >
A property of range xsd:boolean begins with is, followed by an adjective or
participle. This is the single explicit exception to TN-012.
scope: [datatype_property, annotation_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A bare adjective does not read as a statement, and some of them collide with
reserved words in the languages that consume the model.
examples:
- {from: "pr:nullable", to: "pr:isNullable", note: null}
- {from: "pr:abstract", to: "pr:isAbstract", note: "abstract is a reserved word in several target languages"}
- id: TN-016
category: Identifier
title: A controlled value is an individual or a literal
statement: >
A value becomes a TYPED INDIVIDUAL if it needs to be defined, owned, or
extended by a domain without modifying the model. It stays a LITERAL if it
is a closed technical state whose list is the property of governance and
must precisely not be extended. The model chooses one form per kind of value
and states the choice.
scope: [class, individual, datatype_property]
severity: BLOCKING
control: {tier: [shacl, human], executor: pr_metamodel_shapes.ttl, procedure: null}
filiation: A-Box NR-016
rationale: >
A literal cannot carry a definition, an owner or a link, and extending its
list means editing the shapes and therefore the frozen vocabulary. A typed
individual can be added by a domain without touching the model, appears as a
node in every viewer, and can be reached by following an edge. The test is
not how the value looks but who is allowed to add one.
examples:
- {from: "an activation level defined in a playbook, not in the graph", to: "literal constrained by sh:in", note: not domain-extensible}
- {from: "a kind of system a domain may need to add", to: typed individual, note: extensible and worth defining}
- id: TN-017
category: Identifier
title: Controlled literals are UPPER_SNAKE_CASE
statement: >
Controlled literal values are written UPPER_SNAKE_CASE. This is the only
place in the model where that casing is used.
scope: [datatype_property]
severity: BLOCKING
control: {tier: shacl, executor: pr_metamodel_shapes.ttl, procedure: null}
filiation: null
rationale: >
The casing exists so that a value drawn from a closed list is
distinguishable from free text at a glance, in the source and in any export.
examples:
- {from: "\"Full activation\"", to: "\"FULL_ACTIVATION\"", note: null}
- {from: "\"Published\"", to: "\"PUBLISHED\"", note: null}
# --------------------------------------------------------------------- LABEL
- id: TN-018
category: Label
title: The label is derived from the IRI
statement: >
rdfs:label is obtained from the local name of the IRI by inserting a space
at every case boundary. No word added, removed, reordered or substituted.
The words of a class label keep their initial capital; the words of a
property label are entirely lower case.
scope: [class, object_property, datatype_property, annotation_property, individual]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: A-Box NR-005 (deliberate opposite)
rationale: >
A label carrying meaning the IRI does not is the symptom of a badly named
IRI or of an incomplete comment — never of a legitimate exception to the
label. Where a label has been quietly enriched to explain a term, the
explanation belongs in rdfs:comment and the short form, if the organisation
speaks one, in pr:shortLabel.
examples:
- {from: "pr:BusinessIntelligenceDataSource", to: "Business Intelligence Data Source", note: a class label keeps its capitals}
- {from: "pr:primarilyStoredIn", to: "primarily stored in", note: a property label is lower case}
- {from: "\"Ownership and Categorization Layer Object\"", to: "\"Ownership Layer Object\"", note: the added meaning moves to rdfs:comment}
- id: TN-019
category: Label
title: No acronym in the label
statement: >
The label introduces no acronym or abbreviation that the IRI does not
already carry, including in a recapitalised form that the derivation would
not produce. Where the IRI legitimately carries an acronym under TN-011, the
derived label carries it too and nothing further is added.
scope: [class, object_property, datatype_property, annotation_property, individual]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A direct corollary of TN-018, kept as a separate rule because it catches
labels whose IRI is innocent: a term correctly named in full can still carry
a hand-written label that reintroduces the short form. Stated as an
introduction rather than a prohibition, it stays a pure corollary — whatever
the IRI legitimately holds, the derivation reproduces, and nothing else.
examples:
- {from: "\"BI expression\"", to: "\"expression\"", note: the IRI was already conforming}
- id: TN-020
category: Label
title: No state in the label
statement: >
The label mentions no status, version or deprecation. State lives in an
axiom — owl:deprecated, pr:status — never in text. Deprecated and active
terms are never mixed in a single view: a consumer filters on the axiom at
query time and presents deprecated terms separately.
scope: [class, object_property, datatype_property, annotation_property, individual, consumer]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A term whose label repeats its state carries that state twice. The day one
is changed without the other, the two contradict each other and nothing
detects it. With the rule in force, the question of whether a term is
deprecated has exactly one possible answer.
examples:
- {from: "\"belongs to domain (deprecated)\"", to: "\"belongs to domain\"", note: owl:deprecated carries the state}
- id: TN-021
category: Label
title: No gloss or parenthesis in the label
statement: >
No parenthesis, qualification or example appears in the label. They belong
in rdfs:comment.
scope: [class, object_property, datatype_property, annotation_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A parenthetical gloss is almost always the symptom of an IRI that does not
say what it holds. The right response is usually to rename the term, not to
annotate the label.
examples:
- {from: "\"defined by (code URI)\"", to: "\"definition address\"", note: rename the term rather than gloss the label}
- {from: "\"query count (30 days)\"", to: "\"query count\"", note: null}
- id: TN-022
category: Label
title: The short label carries the spoken form
statement: >
A term carries pr:shortLabel whenever the form derived under TN-018 is not
what the organisation writes or says. The short label is free: acronyms,
abbreviations, hyphens, short forms.
scope: [class, individual]
severity: MAJOR
control: {tier: human, executor: review checklist, procedure: null}
filiation: null
rationale: >
The short label is what makes the derivation rule bearable. Without it, a
mechanical label impoverishes every screen; with it, display becomes
explicit and centrally governed instead of being improvised by each
consumer. It is MAJOR rather than BLOCKING because judging whether a derived
form matches what people actually say is human work, and EV-011 forbids
declaring a rule blocking with no executor.
examples:
- {from: "\"Data Sub Domain Owner\"", to: "short label Sub Domain Owner", note: the word Data is dropped in the spoken form}
- {from: "\"Key Performance Indicator\"", to: "short label KPI, acronym KPI", note: the acronym channel is what makes TN-002 bearable}
- id: TN-023
category: Label
title: The label is generated, never typed
statement: >
rdfs:label is never written by hand. It is produced by the derivation
function from the IRI and stored in the source. A hand-typed label is a
violation even when its value happens to be correct.
scope: [class, object_property, datatype_property, annotation_property, individual]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
Generate and store, rather than store only or compute at read time. The
label stays in the source so SPARQL and third-party tooling behave normally,
while a script regenerates it at every commit and the gate fails if a stored
label differs from the derived one. Redundancy becomes harmless because it
is checked: rather than hoping two facts stay consistent, the model makes
inconsistency detectable.
examples:
- {from: label absent, to: violation, note: null}
- {from: label differing from the derivation, to: "violation — drift", note: null}
# --------------------------------------------------------------- DECLARATION
- id: TN-024
category: Declaration
title: Every active term carries a comment
statement: >
rdfs:comment is mandatory on every active term. On a property the comment is
structured: the definition, plus the scope constraint or the confusion to be
avoided. On a class the form is free.
scope: [class, object_property, datatype_property, annotation_property]
severity: BLOCKING
control: {tier: [script, human], executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A comment that paraphrases the name prevents nothing. A good one prevents a
specific error: it says where the property may and may not be declared, or
which neighbouring term it must not be confused with. The structure is
imposed on properties because they are where ambiguity costs most — they are
asserted thousands of times by people who will not read the model. The
script checks presence; only a reviewer checks value, which is why the rule
names two tiers.
examples:
- {from: "\"the owner\"", to: "definition plus where it may be declared and what it excludes", note: null}
- id: TN-025
category: Declaration
title: Every property declares its domain and range
statement: >
rdfs:domain and rdfs:range are mandatory on every property, EXCEPT where the
property is deliberately polymorphic. A polymorphic property states so in
its comment and has its scope declared in SHACL. Silent absence is a
violation; documented absence is not.
scope: [object_property, datatype_property]
severity: BLOCKING
control: {tier: [script, human], executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A property with no domain cannot be targeted by any control. But some
properties are polymorphic by design, their scope controlled class by class
in SHACL rather than by twin properties; giving those an rdfs:domain would
trigger the RDFS retyping described in TN-028. The rule therefore separates
the two cases rather than demanding a domain everywhere.
examples:
- {from: a polymorphic property with no domain and no comment, to: violation, note: silence is indistinguishable from omission}
- {from: a polymorphic property with no domain, documented, to: conforming, note: scope declared in SHACL}
- id: TN-026
category: Declaration
title: Every term declares how it was authored
statement: >
pr:authoringMode is mandatory on every term. pr:harvestSource is mandatory
if and only if the mode is HARVESTED.
scope: [class, object_property, datatype_property, annotation_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
Provenance decides who may edit a term and what a divergence means. A term
stating where its data comes from without stating that it is harvested, or
declaring itself harvested without naming a source, is half-declared in a
way no control can catch. Declared symmetrically, provenance also makes a
harvester specifiable from the model itself rather than from a side
document.
examples:
- {from: harvestSource present, authoringMode absent, to: "authoringMode HARVESTED", note: null}
- {from: "authoringMode HARVESTED, harvestSource absent", to: harvestSource declared, note: null}
- id: TN-027
category: Declaration
title: A concrete class has one layer and one provenance
statement: >
Every concrete class descends from exactly one layer root and from exactly
one provenance axis. Controlled-vocabulary classes are outside the layers by
nature — a named and closed exception.
scope: [class]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A class with no layer is invisible to every layer-scoped control and to
every view organised by layer; a class with two is ambiguous in both. Where
a family of classes genuinely sits outside the data layers — actors,
governance objects — the answer is an explicit root of its own, not silence.
Adding such a root by INSERTION above an existing abstraction, rather than
by renaming it, keeps the ranges that point at that abstraction readable: a
range must state the nature of its target, not its position in the model.
examples:
- {from: a class with no parent, to: attached to its layer root, note: null}
- {from: renaming an abstraction into a layer root, to: inserting the layer root above it, note: preserves the meaning of every range that points at it}
- id: TN-028
category: Declaration
title: A sub-property inherits the domain of its parent
statement: >
Declaring rdfs:subPropertyOf does not shield a property from the
rdfs:domain of its parent. Every assertion of a sub-property is also an
assertion of the parent, so the parent's domain applies and its RDFS
retyping takes effect.
scope: [object_property, datatype_property]
severity: BLOCKING
control: {tier: [script, human], executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
In RDFS a domain is not a rejecting constraint but a retyping inference.
Attaching a sub-property to a parent whose domain does not fit will silently
retype its subjects — an object of one layer quietly becomes an object of
another, entering counts, controls and traversals it was never part of.
Where two relations share a verb but not a domain, they must not be merged
and must not be linked by subPropertyOf; the unification, if one is needed,
belongs on a shared upper property with no domain of its own.
examples:
- {from: "a dashboard field asserted through a property whose domain is a warehouse column", to: "the dashboard field is inferred to BE a warehouse column", note: silent retyping}
- {from: "linking the two by rdfs:subPropertyOf", to: "no protection — the parent's domain still applies", note: null}
- {from: "a shared upper property with no domain", to: "end-to-end traversal without merging IRIs", note: the correct unification}
# ----------------------------------------------------------------- EVOLUTION
- id: EV-001
category: Evolution
title: A rename is never an edit in place
statement: >
A rename creates a new term and keeps the old one with owl:deprecated true
and dcterms:isReplacedBy. No IRI is modified in place. The deprecated stub
carries no edge: no subClassOf, no label, no comment — only its type and the
two axioms.
scope: [procedure]
severity: BLOCKING
control: {tier: human, executor: commit review, procedure: null}
filiation: A-Box LC-002
rationale: >
Nothing propagates in RDF. An IRI is a string copied into every triple, so
renaming means rewriting every triple where it appears as subject, predicate
or object — declaration, subclass axioms, domain, range, instance typing,
shape targets and paths, queries, viewers, documentation. Edges are moved,
not duplicated. The stub exists so that a consumer holding an older export
can still be told what replaced the term.
examples:
- {from: editing an IRI in place, to: new term plus deprecated stub, note: null}
- id: EV-002
category: Evolution
title: A withdrawal requires proof of non-instantiation
statement: >
A term is removed only after a count proving it was never instantiated, the
count being attached to the commit. Without proof, deprecation only.
scope: [procedure]
severity: BLOCKING
control: {tier: script, executor: count query attached to the commit, procedure: null}
filiation: null
rationale: >
Removal is the one irreversible operation on a vocabulary. The proof is
cheap — a single counting query — and it is what separates a safe withdrawal
from a silent data loss.
examples:
- {from: deleting a term, to: "SELECT (COUNT(?s) AS ?n) WHERE { ?s a pr:Term }", note: must return zero}
- id: EV-003
category: Evolution
title: A deletion removes every reference to the subject
statement: >
A subject removed while its IRI is still cited elsewhere is recreated by
RDFS inference. Every reference is removed in the same operation.
scope: [procedure]
severity: BLOCKING
control: {tier: script, executor: dangling-reference check in the migration script, procedure: null}
filiation: null
rationale: >
A graph has no foreign keys. Deleting the block that declares a subject
leaves every triple that names it intact, and a reasoner will reconstruct a
hollow node from them — present in traversals, absent from every control.
examples:
- {from: deleting a subject block, to: deleting the block and every triple naming it, note: null}
- id: EV-004
category: Evolution
title: A change is validated before it is applied
statement: >
Every modification of the model is agreed before any artifact is written.
Dry run first, apply second.
scope: [procedure]
severity: BLOCKING
control: {tier: human, executor: commit review, procedure: null}
filiation: null
rationale: >
The damage from a bad change is rarely in the change itself but in
everything that silently depended on the old state. A dry run that reports
what it would touch is what makes that dependency visible while it can still
be discussed.
examples:
- {from: "migrate.py --apply as the first run", to: "migrate.py, then --apply", note: dry run is the default}
- id: EV-005
category: Evolution
title: Graph edits go through an RDF parser
statement: >
Any transformation of the graph is performed with an RDF parser. No regular
expression applied line by line to Turtle.
scope: [procedure, script]
severity: BLOCKING
control: {tier: human, executor: script review, procedure: null}
filiation: null
rationale: >
Turtle is a structured syntax and a line is not a unit of meaning. A literal
may contain the very characters a regex splits on, so a text-level edit that
looks correct on every example can truncate a value on the one that matters.
A parser knows the difference between a delimiter and a character inside a
string; a regex does not, and every workaround for that is a partial
reimplementation of the parser.
examples:
- {from: "re.sub on the file text", to: remove and add on parsed triples, note: null}
- {from: hand-written clause splitting, to: parser, note: a workaround is evidence the wrong tool is in use}
- id: EV-006
category: Evolution
title: Every script is idempotent
statement: >
A script may be replayed without changing the result. Its guard tests the
TARGET state, not the source state, and it counts its own result. Acceptance
test: two consecutive runs, the second reporting zero modifications.
scope: [script]
severity: BLOCKING
control: {tier: script, executor: double run in continuous integration, procedure: null}
filiation: null
rationale: >
A chain of transformations is only safe if any step can be replayed. A guard
that tests whether work remains to be done breaks as soon as another script
has already changed the source; a guard that tests whether the target state
exists survives it. The self-count is what turns the property into a
verifiable one.
examples:
- {from: appending unconditionally, to: appending only if the target is absent, note: guard on the target state}
- id: EV-007
category: Evolution
title: The replayable source is the start of the chain
statement: >
The replayable source is the starting file plus the ordered sequence of
scripts, never an intermediate committed state. Hand-editing a file produced
by a script is forbidden, including for a typo: the script is corrected and
the chain replayed.
scope: [procedure]
severity: BLOCKING
control: {tier: human, executor: commit review, procedure: null}
filiation: null
rationale: >
A committed intermediate file is a convenience, not a source. As soon as one
hand edit exists in it that no script performs, replaying the chain no
longer reproduces the committed state and nobody can say which of the two is
right. Keeping the chain authoritative is also what makes a failed migration
recoverable: restart from the last true source with a corrected script,
rather than repair a half-migrated file by hand.
examples:
- {from: fixing a typo in a generated file, to: fixing the script and replaying, note: null}
- id: EV-008
category: Evolution
title: Every artifact has a producer script
statement: >
No artifact contains hard-coded data. Every deliverable — viewer,
documentation, workbook, diagram — is regenerated from a versioned source by
a script.
scope: [script, artifact]
severity: BLOCKING
control: {tier: human, executor: artifact review, procedure: null}
filiation: null
rationale: >
An artifact that cannot be regenerated becomes stale the moment the model
moves, and there is no way to tell whether it is stale or merely different.
This rulebook applies the rule to itself: the source is one file, the
document and the workbook are generated from it.
examples:
- {from: a hand-maintained workbook, to: a source file plus a generator, note: null}
- id: EV-009
category: Evolution
title: A change propagates in a single merge
statement: >
A change is propagated to the vocabulary, the instances, the shapes, the
consumers and the documentation in one branch, merged as a block. The main
line never sees a state where one has moved and another has not.
scope: [procedure]
severity: BLOCKING
control: {tier: human, executor: merge review, procedure: null}
filiation: null
rationale: >
Partial propagation is not a smaller version of a change, it is a different
and invalid state. Its worst form is silent: a gate that validates the
stale half and reports success. A single merge makes the intermediate state
unreachable rather than merely discouraged.
examples:
- {from: committing the vocabulary and updating the shapes later, to: one branch, one merge, one tag, note: null}
- id: EV-010
category: Evolution
title: Shapes declare the ontology version they target
statement: >
The shapes graph declares dcterms:conformsTo equal to the owl:versionIRI of
the ontology. Any divergence aborts validation immediately.
scope: [shape, script]
severity: BLOCKING
control: {tier: script, executor: run_shacl_validation.py, procedure: null}
filiation: null
rationale: >
Validating against the wrong shapes does not fail loudly. It produces a long
list of violations that reads exactly like a regression of the model, and
the time goes into interpreting them rather than noticing the mismatch. One
declared triple and one check turn that into a single line naming the real
problem.
examples:
- {from: hundreds of violations to interpret, to: "ABORT — shapes target a different ontology version", note: null}
- id: EV-011
category: Evolution
title: No blocking rule without an executor
statement: >
A rule may be declared BLOCKING only if a named script or shape enforces it.
A blocking rule with no control is a statement of intent.
scope: [rule]
severity: BLOCKING
control: {tier: script, executor: rulebook source self-check, procedure: null}
filiation: null
rationale: >
An unenforced blocking rule is worse than an absent one: it is cited as
though it held, and the gap only surfaces when something it should have
caught reaches production. Making the executor a mandatory field turns the
omission into something a script can detect on the rulebook itself.
examples:
- {from: "severity BLOCKING with no executor", to: violation, note: detected by the generators before they write anything}
- id: EV-012
category: Evolution
title: Every rule names the tier that enforces it
statement: >
Controls exist at three tiers — SHACL for the shape of the graph, scripts
for the file, the lexicon and naming, human review for semantics. Every rule
names its tier. A rule with no assigned tier is a violation.
scope: [rule]
severity: BLOCKING
control: {tier: script, executor: rulebook source self-check, procedure: null}
filiation: null
rationale: >
SHACL validates a graph, not a file, and not meaning. Two IRIs standing for
the same notion produce two individually valid graphs; a duplicated block of
text produces identical triples and no complaint. Naming the tier forces the
question of what actually catches each rule, and pushes the ones SHACL
cannot see to a control that can: a normalised-label index reviewed by a
person, a file-level hash, a naming script, a review checklist.
examples:
- {from: two IRIs for one notion, to: normalised label index plus human review, note: SHACL cannot see it}
- {from: a duplicated text block, to: file-level block hashing, note: identical triples, no violation}
- id: EV-013
category: Evolution
title: No sh:pattern outside the SHACL specification
statement: >
sh:pattern uses XPath 2.0 regular expressions, which support neither
lookbehind nor lookahead. A constraint is expressed positively — what the
string must be — rather than negatively. Where that is impossible the rule
leaves SHACL and moves to the script tier under EV-012.
scope: [shape]
severity: BLOCKING
control: {tier: script, executor: run_shacl_validation.py, procedure: null}
filiation: A-Box NR-001
rationale: >
A pattern written in a richer regex dialect does not degrade gracefully: the
validator fails to compile it, and the rule that depended on it silently
stops being enforced while still being declared blocking.
examples:
- {from: "a pattern using (?<! ... )", to: "a pattern describing the whole accepted string", note: express positively}
- id: EV-014
category: Evolution
title: The term namespace is never versioned
statement: >
The namespace of terms is not versioned. The version lives in
owl:versionIRI alone.
scope: [ontology]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
A versioned term namespace changes every IRI at every release, breaking
every consumer for no benefit. It also fails silently in the other
direction: a file bound to an outdated namespace targets IRIs that no longer
exist, so every shape matches nothing and validation passes while testing
nothing at all.
examples:
- {from: "a prefix bound to a versioned namespace", to: "a prefix bound to the stable namespace", note: the version lives in owl:versionIRI}
- id: EV-015
category: Evolution
title: Every procedure that writes produces an execution log
statement: >
Any procedure that modifies the graph writes a versioned execution log,
committed alongside the change. The log records the attempt number and
timestamp, a checksum of every input consumed, the ontology version and the
version the shapes declare they target, a count per step, the full
validation report, and a difference against the previous attempt.
scope: [procedure, script]
severity: BLOCKING
control: {tier: script, executor: the procedure's own runner, procedure: null}
filiation: null
rationale: >
A counter printed to the terminal disappears with the terminal. When a
validation run fails, the question is never only what failed but whether it
is the same failure as the previous attempt: a violation that persists
unchanged after a correction says the cause lies elsewhere, while one that
moves every time says the change is being patched rather than fixed. Those
are opposite diagnoses and neither is available without a record of the
previous run. The input checksums serve the same purpose one level down,
separating a wrong model from a stale file — the ambiguity that made a past
incident expensive to explain. The log is also the natural carrier of the
proof of non-instantiation required by EV-002, which otherwise has no
defined format.
examples:
- {from: a counter printed to standard output, to: a versioned log committed with the change, note: null}
- {from: "a failed run with no record of the previous one", to: "a difference against the previous attempt", note: what makes correct-or-abandon decidable}
# ====== ANNEX: ABSTRACTNESS (TN-006, TN-007) ======
abstractness:
- {term: MetaModelObject, is_abstract: true, note: "root of the model"}
- {term: DefinedObject, is_abstract: true, note: "provenance axis"}
- {term: CapturedObject, is_abstract: true, note: "provenance axis"}
- {term: OwnershipLayerObject, is_abstract: true, note: "layer root"}
- {term: BusinessLayerObject, is_abstract: true, note: "layer root"}
- {term: LogicalLayerObject, is_abstract: true, note: "layer root"}
- {term: PhysicalLayerObject, is_abstract: true, note: "NEW — omission in v1.6"}
- {term: DeliveryLayerObject, is_abstract: true, note: "layer root"}
- {term: ConsumptionLayerObject, is_abstract: true, note: "layer root"}
- {term: GovernanceLayerObject, is_abstract: true, note: "NEW — seventh layer root"}
- {term: DataStructure, is_abstract: true, note: "harvesting types the concrete sort"}
- {term: KeyConstraint, is_abstract: true, note: "only PrimaryKey and ForeignKey instantiate"}
- {term: Actor, is_abstract: true, note: "renaming would degrade the range of ownedBy and hasPublisher"}
- {term: DataDomain, is_abstract: false, note: null}
- {term: DataSubDomain, is_abstract: false, note: null}
- {term: BusinessObject, is_abstract: false, note: "ends in Object and is heavily instantiated - refutes any suffix rule"}
- {term: BusinessConcept, is_abstract: false, note: null}
- {term: Metric, is_abstract: false, note: "concrete despite having KeyPerformanceIndicator beneath it"}
- {term: KeyPerformanceIndicator, is_abstract: false, note: null}
- {term: DataObject, is_abstract: false, note: null}
- {term: DataElement, is_abstract: false, note: null}
- {term: Database, is_abstract: false, note: null}
- {term: Schema, is_abstract: false, note: null}
- {term: Field, is_abstract: false, note: null}
- {term: System, is_abstract: false, note: null}
- {term: Transformation, is_abstract: false, note: null}
- {term: BaseTable, is_abstract: false, note: null}
- {term: ExternalTable, is_abstract: false, note: null}
- {term: View, is_abstract: false, note: null}
- {term: PrimaryKey, is_abstract: false, note: null}
- {term: ForeignKey, is_abstract: false, note: null}
- {term: DataProduct, is_abstract: false, note: null}
- {term: DataContract, is_abstract: false, note: null}
- {term: DataInterface, is_abstract: false, note: null}
- {term: BusinessIntelligenceWorkspace, is_abstract: false, note: null}
- {term: BusinessIntelligenceDataSource, is_abstract: false, note: null}
- {term: BusinessIntelligenceField, is_abstract: false, note: null}
- {term: BusinessIntelligenceReport, is_abstract: false, note: null}
- {term: DataDomainOwner, is_abstract: false, note: null}
- {term: DataSubDomainOwner, is_abstract: false, note: null}
- {term: DataProductOwner, is_abstract: false, note: null}
- {term: DataSteward, is_abstract: false, note: null}
- {term: DataGovernanceLead, is_abstract: false, note: null}
- {term: SystemType, is_abstract: false, note: "must stay concrete — it types five individuals"}
# ====== ANNEX: DISPLAY (TN-011, TN-022) ======
display:
- {iri: DataDomain, label: "Data Domain", short_label: "Domain", acronym: "DD", note: "DD is already the short form used inside A-Box identifiers under NR-014"}
- {iri: DataSubDomain, label: "Data Sub Domain", short_label: "Sub Domain", acronym: "SD", note: "no hyphen"}
- {iri: DataDomainOwner, label: "Data Domain Owner", short_label: "Domain Owner", acronym: "DDO", note: null}
- {iri: DataSubDomainOwner, label: "Data Sub Domain Owner", short_label: "Sub Domain Owner", acronym: "SDO", note: null}
- {iri: DataProductOwner, label: "Data Product Owner", short_label: "Product Owner", acronym: "PO", note: "NOT DPO — the initialism is taken by Data Protection Officer"}
- {iri: DataSteward, label: "Data Steward", short_label: "Steward", acronym: null, note: null}
- {iri: DataGovernanceLead, label: "Data Governance Lead", short_label: "Governance Lead", acronym: "DGL", note: null}
- {iri: DataElement, label: "Data Element", short_label: "Element", acronym: null, note: "drives the name of hasElement and hasGrainElement"}
- {iri: BusinessConcept, label: "Business Concept", short_label: "Concept", acronym: null, note: "drives the name of usesConcept"}
- {iri: BusinessObject, label: "Business Object", short_label: null, acronym: "BO", note: null}
- {iri: KeyPerformanceIndicator, label: "Key Performance Indicator", short_label: "KPI", acronym: "KPI", note: "the acronym channel is what makes TN-002 bearable"}
- {iri: BusinessIntelligenceWorkspace, label: "Business Intelligence Workspace", short_label: "BI Workspace", acronym: null, note: null}
- {iri: BusinessIntelligenceDataSource, label: "Business Intelligence Data Source", short_label: "BI Data Source", acronym: null, note: null}
- {iri: BusinessIntelligenceField, label: "Business Intelligence Field", short_label: "BI Field", acronym: null, note: null}
- {iri: BusinessIntelligenceReport, label: "Business Intelligence Report", short_label: "BI Report", acronym: null, note: null}
- {iri: OwnershipLayerObject, label: "Ownership Layer Object", short_label: null, acronym: null, note: "the categorisation role moves into rdfs:comment"}