diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f1c67..1eb5288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ described in `VERSIONING.md`. ## [Unreleased] +### Changed + +- Namespace moved to the stable `https://sourcelume.apache.org/ns#`. The context URL stays + versioned (`/context/0.0.1/`). +- The JSON Schema accepts only the canonical context IRI as `@context`. Inline context objects + and other IRIs are rejected. +- `tools/validate.py` resolves the canonical context from the bundled copy instead of fetching it. + ### Added - Added intended usage for custodyChain in spec. diff --git a/context/0.0.1/sourcelume.jsonld b/context/0.0.1/sourcelume.jsonld index 10ab6c2..5196b85 100644 --- a/context/0.0.1/sourcelume.jsonld +++ b/context/0.0.1/sourcelume.jsonld @@ -1,7 +1,7 @@ { "@context": { "@version": 1.1, - "sl": "https://sourcelume.apache.org/ns/0.0.1#", + "sl": "https://sourcelume.apache.org/ns#", "dct": "http://purl.org/dc/terms/", "schema": "https://schema.org/", "spdx": "http://spdx.org/rdf/terms#", diff --git a/schema/0.0.1/sourcelume.schema.json b/schema/0.0.1/sourcelume.schema.json index bd4ec48..3935b63 100644 --- a/schema/0.0.1/sourcelume.schema.json +++ b/schema/0.0.1/sourcelume.schema.json @@ -18,7 +18,10 @@ "custodyChain" ], "properties": { - "@context": {}, + "@context": { + "const": "https://sourcelume.apache.org/context/0.0.1/sourcelume.jsonld", + "description": "Only the canonical context IRI is accepted. Inline @context objects and any other IRI are rejected, so a record's meaning is fixed by the published context." + }, "id": { "type": "string", "format": "uri" diff --git a/schema/0.0.1/sourcelume.shacl.ttl b/schema/0.0.1/sourcelume.shacl.ttl index fe5a920..2f62836 100644 --- a/schema/0.0.1/sourcelume.shacl.ttl +++ b/schema/0.0.1/sourcelume.shacl.ttl @@ -1,5 +1,5 @@ @prefix sh: . -@prefix sl: . +@prefix sl: . @prefix dct: . @prefix schema: . @prefix xsd: . diff --git a/spec/0.0.1/index.md b/spec/0.0.1/index.md index 947bdcb..4141bd4 100644 --- a/spec/0.0.1/index.md +++ b/spec/0.0.1/index.md @@ -33,6 +33,14 @@ this identity, was produced/curated/distributed by these parties, is licensed un came from this origin, and passed through this chain of custody.* Sourcelume does not verify the claim's accuracy — it gives the claim a consistent shape so it *can* be verified by someone else. +## JSON-LD context + +Records reference the context with the canonical IRI +`https://sourcelume.apache.org/context/0.0.1/sourcelume.jsonld` and no other `@context` value. +The context is published at that address and is not modified after release. Its namespace is the +stable `https://sourcelume.apache.org/ns#`, so term IRIs stay the same across versions. Inline +`@context` objects and other context IRIs are rejected. + ## Fields | Field | Required | Type | Description | diff --git a/tests/test_validate.py b/tests/test_validate.py index 74bef74..ff87921 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -41,13 +41,9 @@ def test_validate_example_with_valid_record(self) -> None: with tempfile.TemporaryDirectory() as tmp: example_path = Path(tmp) / "valid.jsonld" - # Load the context from the local file - context_path = REPO_ROOT / "context" / "0.0.1" / "sourcelume.jsonld" - context = json.loads(context_path.read_text()) - - # Create a minimal valid ProvenanceRecord with inline context + # Create a minimal valid ProvenanceRecord with the canonical context IRI valid_record = { - "@context": context["@context"], + "@context": validate.CANONICAL_CONTEXT_IRI, "id": "https://example.org/records/test", "type": "ProvenanceRecord", "identifier": "https://example.org/datasets/test", @@ -82,6 +78,30 @@ def test_validate_example_with_valid_record(self) -> None: result = validate.validate_example(example_path, validator, shapes_path) self.assertTrue(result) + def test_schema_pins_the_canonical_context_iri(self) -> None: + """The schema's @context const and the validator's constant must agree.""" + schema = validate.load_validator().schema + self.assertEqual(schema["properties"]["@context"]["const"], validate.CANONICAL_CONTEXT_IRI) + + def test_inline_context_is_rejected(self) -> None: + """An inline @context object is not accepted, only the canonical IRI.""" + validator = validate.load_validator() + context = json.loads((REPO_ROOT / "context" / "0.0.1" / "sourcelume.jsonld").read_text()) + record = {"@context": context["@context"], "id": "https://example.org/records/test", "type": "ProvenanceRecord"} + errors = [e for e in validator.iter_errors(record) if list(e.path) == ["@context"]] + self.assertEqual(len(errors), 1) + + def test_foreign_context_iri_is_rejected(self) -> None: + """A context IRI on another host is rejected, even if the path looks the same.""" + validator = validate.load_validator() + record = { + "@context": "https://example.org/context/0.0.1/sourcelume.jsonld", + "id": "https://example.org/records/test", + "type": "ProvenanceRecord", + } + errors = [e for e in validator.iter_errors(record) if list(e.path) == ["@context"]] + self.assertEqual(len(errors), 1) + def test_validate_example_with_invalid_json_schema(self) -> None: """Test that an invalid record fails JSON Schema validation.""" with tempfile.TemporaryDirectory() as tmp: diff --git a/tools/validate.py b/tools/validate.py index 2bc9e89..a965c53 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -29,6 +29,8 @@ BASE = Path(__file__).resolve().parent.parent SCHEMA_DIR = BASE / "schema" / SPEC_VERSION EXAMPLES_DIR = BASE / "examples" +CANONICAL_CONTEXT_IRI = "https://sourcelume.apache.org/context/0.0.1/sourcelume.jsonld" +CONTEXT_FILE = BASE / "context" / SPEC_VERSION / "sourcelume.jsonld" def load_validator() -> Draft202012Validator: @@ -46,8 +48,11 @@ def validate_example(example: Path, validator: Draft202012Validator, shapes_path print(f" - {list(err.path)}: {err.message}") return False + # The canonical context IRI is resolved from the bundled copy, never fetched. + # The schema has already guaranteed that @context is exactly that IRI. + context = json.loads(CONTEXT_FILE.read_text())["@context"] data_graph = Graph() - data_graph.parse(str(example), format="json-ld") + data_graph.parse(data=json.dumps({**doc, "@context": context}), format="json-ld") conforms, _, results_text = shacl_validate( data_graph,