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