diff --git a/dprod-contracts/docs/specification.md b/dprod-contracts/docs/specification.md
index db5dd73..c0dc80e 100644
--- a/dprod-contracts/docs/specification.md
+++ b/dprod-contracts/docs/specification.md
@@ -209,7 +209,7 @@ All five properties are `owl:ObjectProperty`. Their values come from open SKOS t
| Property | Value |
|----------|-------|
| **Type** | `owl:DatatypeProperty` |
-| **Domain** | `odrl:Duty` |
+| **Subject scope** | `odrl:Duty` (enforced by SHACL; no `rdfs:domain`) |
| **Range** | `xsd:dateTime` | `xsd:duration` (enforced by SHACL) |
| **Cardinality** | 0..1 |
| **Definition** | Time constraint for duty fulfillment |
@@ -350,7 +350,7 @@ ODRL defines `odrl:and` and `odrl:or` but lacks negation. DPROD Contracts adds `
|----------|-------|
| **Type** | `owl:ObjectProperty` |
| **SubPropertyOf** | `odrl:function` |
-| **Domain** | `odrl:Duty` |
+| **Subject scope** | `odrl:Duty` (enforced by SHACL; no `rdfs:domain`) |
| **Range** | `odrl:Party` |
| **Cardinality** | 0..1 |
| **Definition** | Party bearing the duty (must perform the action) |
@@ -363,7 +363,7 @@ The duty bearer. Replaces `odrl:assignee` on duties to avoid role overloading --
|----------|-------|
| **Type** | `owl:ObjectProperty` |
| **SubPropertyOf** | `odrl:function` |
-| **Domain** | `odrl:Duty` |
+| **Subject scope** | `odrl:Duty` (enforced by SHACL; no `rdfs:domain`) |
| **Range** | `odrl:Party` |
| **Cardinality** | 0..1 |
| **Definition** | Party affected by the duty action |
@@ -403,6 +403,7 @@ Shapes are defined in `dprod-contracts-shapes.ttl`. Key constraints:
| `dprod-shapes:PermissionShape` | `odrl:Permission` | Exactly one `odrl:action`; at most one `odrl:target` (inherited from policy if absent) |
| `dprod-shapes:ProhibitionShape` | `odrl:Prohibition` | Exactly one `odrl:action`; at most one `odrl:target` (inherited from policy if absent) |
| `dprod-shapes:DutyShape` | `odrl:Duty` | Exactly one `odrl:action`; `dprod:subjectOfDuty` 0..1; `dprod:objectOfDuty` 0..1; `deadline` 0..1 (dateTime/duration); `schedule` 0..1 (identified `dprod:Schedule`); `state` 0..1 |
+| `dprod-shapes:DutyOnlyPropertySubjectShape` | Subjects using `dprod:deadline`, `dprod:subjectOfDuty`, or `dprod:objectOfDuty` | Subject must be an `odrl:Duty`; Permission and Prohibition use fails validation instead of inferring an additional Duty type |
### Schedule shapes
@@ -502,4 +503,4 @@ DPROD contracts reject odrl:Request because Offer-Request-Agreement semantics ar
---
-**Version**: 0.7 | **Date**: 2026-08-12
+**Version**: 0.7 | **Date**: 2026-08-19
diff --git a/dprod-contracts/dprod-contracts-shapes.ttl b/dprod-contracts/dprod-contracts-shapes.ttl
index abcea5a..901d3b9 100644
--- a/dprod-contracts/dprod-contracts-shapes.ttl
+++ b/dprod-contracts/dprod-contracts-shapes.ttl
@@ -23,7 +23,7 @@ dprod-shapes:
rdfs:comment
"This is intended to be used with SHACL, not as an ontology: the ontology element is to provide the metadata for the set of shapes."@en ;
dct:license ;
- dct:modified "2026-08-12"^^xsd:date ;
+ dct:modified "2026-08-19"^^xsd:date ;
owl:imports ;
owl:imports sh: ;
owl:versionInfo "0.7" ;
@@ -210,6 +210,27 @@ dprod-shapes:ProhibitionShape
] ;
.
+dprod-shapes:DutyOnlyPropertySubjectShape
+ a sh:NodeShape ;
+ rdfs:isDefinedBy dprod-shapes: ;
+ rdfs:label "duty-only property subject shape" ;
+ sh:targetSubjectsOf dprod:deadline ;
+ sh:targetSubjectsOf dprod:subjectOfDuty ;
+ sh:targetSubjectsOf dprod:objectOfDuty ;
+ sh:and (
+ [ sh:class odrl:Duty ]
+ [
+ sh:not [
+ sh:or (
+ [ sh:class odrl:Permission ]
+ [ sh:class odrl:Prohibition ]
+ )
+ ]
+ ]
+ ) ;
+ sh:message "dprod:deadline, dprod:subjectOfDuty, and dprod:objectOfDuty may be used only on odrl:Duty." ;
+.
+
dprod-shapes:DutyShape
a sh:NodeShape ;
rdfs:isDefinedBy dprod-shapes: ;
diff --git a/dprod-contracts/dprod-contracts.ttl b/dprod-contracts/dprod-contracts.ttl
index 6a54189..85e4174 100644
--- a/dprod-contracts/dprod-contracts.ttl
+++ b/dprod-contracts/dprod-contracts.ttl
@@ -45,7 +45,7 @@ dct:modified a owl:AnnotationProperty .
can partially understand them; DPROD-aware processors handle the
extensions."""@en ;
dct:license ;
- dct:modified "2026-08-12"^^xsd:date ;
+ dct:modified "2026-08-19"^^xsd:date ;
dct:publisher ;
dct:contributor ;
dct:contributor ;
@@ -259,9 +259,10 @@ dprod:deadline
- xsd:duration: relative to activation (e.g., P30D, PT24H)
For duration: deadline = activationTime + duration.
No rdfs:range declared because the range is a union of datatypes;
- SHACL enforces the allowed types."""@en ;
+ SHACL enforces both the odrl:Duty subject scope and the allowed types.
+ No rdfs:domain is declared so invalid use does not silently infer an
+ additional odrl:Duty type."""@en ;
rdfs:isDefinedBy dprod: ;
- rdfs:domain odrl:Duty ;
rdfs:label "deadline" ;
.
@@ -370,10 +371,11 @@ dprod:subjectOfDuty
a owl:ObjectProperty ;
dct:description "Party bearing the duty (must perform the action)."@en ;
rdfs:comment
- "The duty bearer. Sub-property of odrl:function for ODRL processor compatibility."@en ;
+ """The duty bearer. Sub-property of odrl:function for ODRL processor
+ compatibility. SHACL enforces the odrl:Duty subject scope; no rdfs:domain
+ is declared so invalid use does not silently infer an odrl:Duty type."""@en ;
rdfs:isDefinedBy dprod: ;
rdfs:subPropertyOf odrl:function ;
- rdfs:domain odrl:Duty ;
rdfs:range odrl:Party ;
rdfs:label "subject" ;
.
@@ -382,10 +384,12 @@ dprod:objectOfDuty
a owl:ObjectProperty ;
dct:description "Party affected by the duty action."@en ;
rdfs:comment
- "The party affected by or receiving the result of the duty action. Sub-property of odrl:function for ODRL processor compatibility."@en ;
+ """The party affected by or receiving the result of the duty action.
+ Sub-property of odrl:function for ODRL processor compatibility. SHACL
+ enforces the odrl:Duty subject scope; no rdfs:domain is declared so invalid
+ use does not silently infer an odrl:Duty type."""@en ;
rdfs:isDefinedBy dprod: ;
rdfs:subPropertyOf odrl:function ;
- rdfs:domain odrl:Duty ;
rdfs:range odrl:Party ;
rdfs:label "object" ;
.
diff --git a/dprod-contracts/tests/test_duty_only_properties.py b/dprod-contracts/tests/test_duty_only_properties.py
new file mode 100644
index 0000000..d9c0d3a
--- /dev/null
+++ b/dprod-contracts/tests/test_duty_only_properties.py
@@ -0,0 +1,157 @@
+from pathlib import Path
+import unittest
+
+from pyshacl import validate
+from rdflib import Graph, Namespace
+from rdflib.namespace import RDFS
+
+
+CONTRACTS_DIR = Path(__file__).resolve().parents[1]
+ONTOLOGY_FILE = CONTRACTS_DIR / "dprod-contracts.ttl"
+SHAPES_FILE = CONTRACTS_DIR / "dprod-contracts-shapes.ttl"
+SPECIFICATION_FILE = CONTRACTS_DIR / "docs" / "specification.md"
+DPROD = Namespace("https://www.omg.org/spec/DPROD/dprod/")
+
+DUTY_ONLY_MESSAGE = (
+ "dprod:deadline, dprod:subjectOfDuty, and dprod:objectOfDuty "
+ "may be used only on odrl:Duty."
+)
+
+
+class DutyOnlyPropertyValidationTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.ontology = Graph().parse(ONTOLOGY_FILE, format="turtle")
+ cls.shapes = Graph().parse(SHAPES_FILE, format="turtle")
+
+ def validate(self, statements: str, with_ontology: bool):
+ data = Graph().parse(
+ data=f"""
+ @prefix dprod: .
+ @prefix dct: .
+ @prefix ex: .
+ @prefix odrl: .
+ @prefix xsd: .
+
+ {statements}
+ """,
+ format="turtle",
+ )
+ if with_ontology:
+ return validate(
+ data_graph=data,
+ shacl_graph=self.shapes,
+ ont_graph=self.ontology,
+ inference="rdfs",
+ advanced=True,
+ )
+ return validate(
+ data_graph=data,
+ shacl_graph=self.shapes,
+ advanced=True,
+ )
+
+ def assert_conforms(self, statements: str) -> None:
+ for with_ontology in (True, False):
+ with self.subTest(with_ontology=with_ontology):
+ conforms, _, report = self.validate(statements, with_ontology)
+ self.assertTrue(conforms, report)
+
+ def assert_rejected(self, statements: str, message: str) -> None:
+ for with_ontology in (True, False):
+ with self.subTest(with_ontology=with_ontology):
+ conforms, _, report = self.validate(statements, with_ontology)
+ self.assertFalse(conforms, report)
+ self.assertIn(message, report)
+
+ def test_owl_domains_do_not_infer_duty_as_a_validation_fallback(self) -> None:
+ for duty_only_property in (
+ DPROD.deadline,
+ DPROD.subjectOfDuty,
+ DPROD.objectOfDuty,
+ ):
+ with self.subTest(duty_only_property=duty_only_property):
+ self.assertEqual(
+ [],
+ list(self.ontology.objects(duty_only_property, RDFS.domain)),
+ )
+
+ def test_specification_documents_shacl_enforced_duty_scope(self) -> None:
+ specification = SPECIFICATION_FILE.read_text(encoding="utf-8")
+
+ self.assertGreaterEqual(
+ specification.count(
+ "| **Subject scope** | `odrl:Duty` (enforced by SHACL; no `rdfs:domain`) |"
+ ),
+ 3,
+ )
+ self.assertIn("dprod-shapes:DutyOnlyPropertySubjectShape", specification)
+
+ def test_duty_only_properties_are_rejected_on_other_rule_types(self) -> None:
+ property_values = {
+ "dprod:deadline": '"PT1H"^^xsd:duration',
+ "dprod:subjectOfDuty": "ex:provider",
+ "dprod:objectOfDuty": "ex:consumer",
+ }
+ for rule_class in ("odrl:Permission", "odrl:Prohibition"):
+ for property_name, value in property_values.items():
+ with self.subTest(rule_class=rule_class, property_name=property_name):
+ self.assert_rejected(
+ f"""
+ ex:rule a {rule_class} ;
+ odrl:action odrl:use ;
+ {property_name} {value} .
+ ex:provider a odrl:Party .
+ ex:consumer a odrl:Party .
+ """,
+ DUTY_ONLY_MESSAGE,
+ )
+
+ def test_named_and_inline_duties_remain_valid(self) -> None:
+ duty_properties = """
+ odrl:action odrl:use ;
+ dprod:deadline "PT1H"^^xsd:duration ;
+ dprod:subjectOfDuty ex:provider ;
+ dprod:objectOfDuty ex:consumer
+ """
+ self.assert_conforms(
+ f"""
+ ex:namedDuty a odrl:Duty ;
+ {duty_properties} .
+ [ a odrl:Duty ;
+ {duty_properties}
+ ] .
+ ex:provider a odrl:Party .
+ ex:consumer a odrl:Party .
+ """
+ )
+
+ def test_obsolete_recurrence_is_rejected_on_every_rule_type(self) -> None:
+ for rule_class in ("odrl:Duty", "odrl:Permission", "odrl:Prohibition"):
+ with self.subTest(rule_class=rule_class):
+ self.assert_rejected(
+ f"""
+ ex:rule a {rule_class} ;
+ odrl:action odrl:use ;
+ dprod:recurrence "FREQ=DAILY" .
+ """,
+ "dprod:recurrence is obsolete; use dprod:schedule",
+ )
+
+ def test_schedule_remains_valid_on_non_duty_resources(self) -> None:
+ self.assert_conforms(
+ '''
+ ex:permission a odrl:Permission ;
+ odrl:action odrl:use ;
+ dprod:schedule ex:weekdaySchedule .
+
+ ex:weekdaySchedule a dprod:Schedule ;
+ dct:conformsTo dprod:PosixCrontabScheduleFormat ;
+ dprod:scheduleTimeZone "Europe/London" ;
+ dprod:scheduleExpression "0 6 * * 1-5" .
+ '''
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()