Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions dprod-contracts/docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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) |
Expand All @@ -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 |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
23 changes: 22 additions & 1 deletion dprod-contracts/dprod-contracts-shapes.ttl
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://creativecommons.org/licenses/by/4.0/> ;
dct:modified "2026-08-12"^^xsd:date ;
dct:modified "2026-08-19"^^xsd:date ;
owl:imports <https://www.omg.org/spec/DPROD/contracts/> ;
owl:imports sh: ;
owl:versionInfo "0.7" ;
Expand Down Expand Up @@ -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: ;
Expand Down
18 changes: 11 additions & 7 deletions dprod-contracts/dprod-contracts.ttl
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ dct:modified a owl:AnnotationProperty .
can partially understand them; DPROD-aware processors handle the
extensions."""@en ;
dct:license <https://creativecommons.org/licenses/by/4.0/> ;
dct:modified "2026-08-12"^^xsd:date ;
dct:modified "2026-08-19"^^xsd:date ;
dct:publisher <https://www.omg.org/> ;
dct:contributor <https://www.linkedin.com/in/matthiasautrata> ;
dct:contributor <https://www.linkedin.com/in/natasavaritimou> ;
Expand Down Expand Up @@ -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" ;
.

Expand Down Expand Up @@ -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" ;
.
Expand All @@ -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" ;
.
Expand Down
157 changes: 157 additions & 0 deletions dprod-contracts/tests/test_duty_only_properties.py
Original file line number Diff line number Diff line change
@@ -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: <https://www.omg.org/spec/DPROD/dprod/> .
@prefix dct: <http://purl.org/dc/terms/> .
@prefix ex: <https://example.org/> .
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

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