| <!-- | |
| Licensed to the Apache Software Foundation (ASF) under one or more | |
| contributor license agreements. See the NOTICE file distributed with | |
| this work for additional information regarding copyright ownership. | |
| The ASF licenses this file to You under the Apache License, Version 2.0 | |
| (the "License"); you may not use this file except in compliance with | |
| the License. You may obtain a copy of the License at | |
| http://www.apache.org/licenses/LICENSE-2.0 | |
| Unless required by applicable law or agreed to in writing, software | |
| distributed under the License is distributed on an "AS IS" BASIS, | |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| See the License for the specific language governing permissions and | |
| limitations under the License. | |
| --> | |
| # Record Validation | |
| This document describes the record validation architecture of the Apache | |
| Sourcelume Registry: the `RecordValidator` SPI in the core, the shipped | |
| validation plugins, and the design decisions behind them. | |
| The goal is Java-side parity with the **Python reference validator** | |
| (`sourcelume-spec` `tools/validate.py`, jsonschema[format] + pyshacl), so | |
| records can be validated at ingest time inside the JVM — while the spec | |
| artifacts (JSON Schema, SHACL shapes, JSON-LD context) remain the single | |
| source of truth, packaged in the `sourcelume-spec` artifact. | |
| ## Plugin architecture | |
| Validation is a **chain of plugins**, not a built-in feature: | |
| - The **core** (`sourcelume-registry-core`) defines only the contract: | |
| `RecordValidator` (stable `id()`, execution `order()`, and | |
| `validate(String jsonLd)` that reports failures as results, never as | |
| exceptions), the immutable `ValidationResult`/`ValidationIssue` types, and | |
| the `ValidatorChain`. | |
| - Concrete validators live in **separate plugin modules**, each owning its | |
| validation machinery. They register themselves through the JDK | |
| `ServiceLoader` (`META-INF/services`); the chain discovers whatever is on | |
| the classpath. | |
| Chain semantics (mirroring `validate.py`, which runs the JSON Schema stage | |
| before SHACL and only invokes the latter when the former passes): | |
| - Validators run by ascending `order()`. | |
| - The chain is **fail-fast**: the first non-conforming validator ends the | |
| chain; its issues (plus everything collected so far) are returned. | |
| - An **empty chain is rejected** (`IllegalStateException`): a chain that | |
| validates nothing would silently accept every document. | |
| - **Duplicate ids are rejected** (`IllegalStateException`): two plugins | |
| claiming the same stage is a deployment error. Swapping a plugin means | |
| swapping the jar, not running both. | |
| Validators take the **raw JSON-LD string**, not a DTO: the JSON-LD semantics | |
| (term-to-IRI expansion) must be preserved, and validation must be possible | |
| before any DTO mapping happens. | |
| ### Shipped plugins | |
| | Module | Stage id | Order | Machinery | Weight | | |
| |---|---|---|---|---| | |
| | `sourcelume-registry-validation-jsonschema` | `json-schema` | 100 | networknt json-schema-validator 2.x (draft 2020-12) | light | | |
| | `sourcelume-registry-validation-shacl` | `shacl` | 200 | Apache Jena SHACL 6.x (JSON-LD via Titanium) | ~18 MB | | |
| Deployments pick the stages they want by adding or removing plugin jars; | |
| the discovery mechanism does the rest. The core stays free of both | |
| networknt and Jena dependencies. | |
| Both stages are record-type aware — the spec defines more than one | |
| record type (`ProvenanceRecord`, `TrainingProvenanceRecord`): | |
| - The `json-schema` plugin bundles one schema per record type and picks | |
| it by the document's `type` term — the same selection `validate.py` | |
| makes via its `SCHEMA_FILES` map. A document with no usable `type`, or | |
| one no bundled schema knows, fails the stage. | |
| - The `shacl` plugin's shapes target each record class | |
| (`sl:ProvenanceRecord`, `sl:TrainingProvenanceRecord`); its | |
| trivial-conformance guard (below) accepts a node of any known record | |
| type as the focus. | |
| ### Usage | |
| ```java | |
| ValidatorChain chain = ValidatorChain.discover(); | |
| ValidationResult result = chain.validate(jsonLd); | |
| if (!result.conforms()) { | |
| result.issues().forEach(issue -> | |
| System.out.println(issue.severity() + " " + issue.path() + ": " + issue.message())); | |
| } | |
| ``` | |
| The "no record without a verdict" policy is enforced by the core's | |
| `IngestService`, which the REST layer calls synchronously on every | |
| submission: it runs the chain and stores the record with its verdict — | |
| `VALIDATED` for a conforming record, `INCOMPLETE` with the structured | |
| issues attached otherwise (see [ingest.md](ingest.md)). The throwing | |
| `ValidationService` facade with its `SourcelumeValidationException` stays | |
| reserved for callers that want validation as a gate; the ingest pipeline | |
| deliberately treats a failed validation as data, not an exception — an | |
| INCOMPLETE record is a normal, expected outcome of curation. | |
| ### Runtime wiring and the validation endpoint | |
| The Quarkus runtime depends on both plugin modules and wires the chain | |
| once at startup (`ValidatorChainProducer`): `ValidatorChain.discover()` | |
| throws when no plugins are found, so a mispackaged registry **fails to | |
| boot** instead of silently accepting unvalidated records. There is no | |
| switch to disable validation — a registry that skips it contradicts its | |
| purpose. Because CDI producers of `@ApplicationScoped` scope are created | |
| lazily on first access, the producer observes the Quarkus `StartupEvent` | |
| and touches the (proxied) chain there — discovery and shape/schema | |
| parsing happen exactly once, during boot, guaranteed. | |
| That wiring backs a pre-flight endpoint, `POST /records/validate`: | |
| - Request: the raw JSON-LD record (`Content-Type: application/json`). | |
| - `200` with `{"conforms": true, "issues": []}` if the record passes both stages. | |
| - `422` with the issues of the first failing validator (the chain is | |
| fail-fast) otherwise; malformed JSON is reported through the same | |
| contract, never a 500. | |
| The response is a wire DTO, not the core `ValidationResult` — the HTTP | |
| contract stays stable while core evolves. The endpoint touches no backend | |
| (Atlas stays out of it on purpose), so producers can pre-flight records | |
| before submitting them; the same chain will enforce itself during ingest. | |
| ## Why two stages? The alias gap | |
| The two stages overlap by roughly 90%: required properties, string types, | |
| cardinality. One class of errors, however, is invisible to JSON Schema: | |
| > The sourcelume schema allows additional properties (a deliberate 0.0.1 | |
| > decision). Term aliases therefore cannot be constrained at the JSON level. | |
| A record can claim an Apache-2.0 license under `"license"` *and* a GPL-3.0 | |
| license under the full IRI `"http://purl.org/dc/terms/license"`. The JSON | |
| Schema passes (`additionalProperties` is unconstrained). After JSON-LD | |
| expansion both claims map to `dct:license` on the same node — and the SHACL | |
| shapes reject it (`maxCount 1` and `nodeKind sh:IRI`). | |
| Both stages together see what the registry actually stores and queries: the | |
| JSON as submitted, and the RDF graph after expansion. The shared fixtures | |
| were run against the Python oracle (`tools/validate.py`) during development | |
| with matching verdicts; keeping that parity honest is exactly why the | |
| fixtures live next to the plugin tests. Each plugin module deliberately | |
| carries its own copies of the shared fixtures (module-local test resources, | |
| no cross-module test coupling) — when a fixture changes, update both | |
| copies and re-run the oracle. | |
| ## SHACL plugin: design decisions and findings | |
| ### Offline context policy (SSRF) | |
| Documents reference the JSON-LD context by IRI; the spec fixes it as | |
| `https://sourcelume.apache.org/context/0.0.1/sourcelume.jsonld`. Fetching | |
| whatever IRI an *ingested* document names is still not acceptable: | |
| - validation would depend on network access, and | |
| - ingest payloads would direct the validator at arbitrary hosts (SSRF). | |
| The `JsonLdContextInliner` therefore replaces every reference ending in the | |
| bundled context resource path with the context shipped in the `sourcelume-spec` | |
| artifact and **refuses documents with any other string reference**. Inline | |
| context objects are passed through, but scanned recursively for every | |
| construct that would make the RDF parser retrieve a remote document — | |
| `@import` and term-scoped context references (in string *and* array form, | |
| and at any depth inside term definitions) are refused, so the offline | |
| guarantee is structural (a JSON-LD processor only ever retrieves documents | |
| for exactly those constructs). Both amplification channels are capped: the | |
| number of replacements (a document made of repeated context references | |
| cannot amplify into the serialized form) and the number of reported | |
| refusals (a document made of many distinct unknown references cannot | |
| amplify into the issue list and the HTTP response). Documents without any | |
| `@context` at all, | |
| or with a `@context` that is neither a string, an array, nor an object, are | |
| refused as well: their terms would not expand, the graph would be empty of | |
| sourcelume types, and an empty graph trivially *conforms* to the shapes — | |
| silently accepting garbage would be worse than rejecting the document. | |
| The same trivial-conformance concern applies to context *games*: an inline | |
| context can redefine the `type` term mapping (or be simply empty), in which | |
| case the document expands to no node of a known record type and the shapes | |
| would conform vacuously. The plugin therefore refuses any graph that, | |
| after expansion, contains no node typed `sl:ProvenanceRecord` or | |
| `sl:TrainingProvenanceRecord` — validation must never succeed by dropping | |
| the focus node. | |
| ### Format assertions (parity with jsonschema[format]) | |
| Since JSON Schema draft 2019-09, `format` only annotates by default. The | |
| Python reference validator enforces `date-time`/`uri` via `jsonschema[format]` | |
| (rfc3339-validator, rfc3987), so the `json-schema` plugin explicitly enables | |
| networknt's format assertions and pins the message locale to `Locale.ROOT` | |
| for deterministic output. Bad timestamps and malformed URIs are violations, | |
| not annotations. | |
| ### Apache Jena as the SHACL engine | |
| SHACL validates RDF graphs, not JSON trees: the document must be expanded to | |
| RDF first (JSON-LD → RDF via Titanium in Jena 6.x), which is why a SHACL stage | |
| cannot be built from a JSON library alone — the same reason the Python side | |
| pairs pyshacl with rdflib. Jena executes the spec's `sourcelume.shacl.ttl` | |
| directly (SHACL Core only: `targetClass`, `datatype`, `min/maxCount`, | |
| `nodeKind`, `sh:in`), keeping the shapes file the single source of truth for | |
| both implementations. | |
| ### ASF licensing note (category B) | |
| `jena-shacl` transitively depends on `org.glassfish:jakarta.json` | |
| (EPL-2.0 / GPL-2.0 with Classpath Exception), an ASF **category B** | |
| dependency. That is acceptable as a binary dependency, but it must be | |
| reflected in LICENSE/NOTICE when this plugin is bundled in a distribution. | |
| All other Jena transitives are category A (Apache/MIT/BSD). | |
| ### Known spec follow-ups | |
| These are open questions for `dev@sourcelume.apache.org`, not blockers: | |
| - **SHACL normativity**: the spec prose describes RDF-level validation but | |
| does not yet state normatively that SHACL shapes must pass. The registry | |
| treats them as normative for ingest. | |
| ## License | |
| Apache License, Version 2.0 — see the repository root. |