diff --git a/CLAUDE.md b/CLAUDE.md index a50e2e2..232c7d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,10 @@ section for the full explanation). Two probes, because Cloudflare 403s the runner on ordinary paths but exempts `/.well-known/` (#112): the origin for artifact completeness, the canonical host for the Worker's rewrite and content types. Probe the origin, not `material-identity.eu`, for anything outside `.well-known` +- `examples/` — reference examples (#130), **not entries**: never under `published/`, keep their own + licence (the steel content spec is CC-BY-4.0, a byte-identical copy from material-identity/schema). + Copied wholesale into `site/examples/` with a derived `/examples/` page; the Worker types `.json` + there. `test/examples.test.ts` fails if any `dictionaryReference` does not resolve under `published/` - `REVIEW.md` — what reviewers check beyond CI; read it before reviewing any publish PR - `standards/` — local-only licensed docs; only its README is committed diff --git a/README.md b/README.md index 27d5473..e48f472 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ served with a short cache, so no derived view can ever contradict an entry. | [`/dictionary.ttl`](https://material-identity.eu/dictionary.ttl) | the whole dictionary as RDF; semantics declared in [`/context.jsonld`](https://material-identity.eu/context.jsonld) | | [`/feed.xml`](https://material-identity.eu/feed.xml) | new and superseded entries | | [`/about`](https://material-identity.eu/about) | what this is, what it promises, what it is not | +| [`/examples/`](https://material-identity.eu/examples/) | a content specification that uses the dictionary, as a reference example: not part of the dictionary, and it keeps its own licence ([`examples/`](examples/README.md)) | RDF is a *second* serialization, never a mutation of the first: `/def/.json` carries no `@context` and never will. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..cefcae6 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,49 @@ +# Examples + +Material that shows the dictionary in use. **Nothing here is part of the dictionary.** These +files are not entries, they carry no `/def/` identifier, and they sit outside +`published/`, so the add-only rule and the two-yes gate do not apply to them. The build copies +this directory into the site unchanged, and it is served at +[`https://material-identity.eu/examples/`](https://material-identity.eu/examples/) (#130). + +## Content specifications + +A **content specification** is layer 1 of the [addressing model](../README.md#terminology): +the set of data elements a passport for one product group carries. Its id is reissued only +when its shape changes or when a meaning it references changes. Layer 2 is the dictionary +release it pins, and layer 3 is the dictionary element id each membership points at. + +| File | Source | Status | +|---|---|---| +| [`content-specifications/dpp-steel-v0.0.2.lock.json`](content-specifications/dpp-steel-v0.0.2.lock.json) | [material-identity/schema](https://github.com/material-identity/schema) `dpp/v0.0.2/specifications/steel.lock.json` at commit [`eb99ea2`](https://github.com/material-identity/schema/commit/eb99ea20cd0670d5b070aa7830653c4955b1c8b9) (merge of [material-identity/schema#380](https://github.com/material-identity/schema/pull/380)) | **published** in material-identity/schema `main`, DPP v0.0.2 | + +The copy is byte-identical to its source: +SHA-256 `b94be652ae86178d1d371a9df5d1bfc3cba3b86afcd1b8f8aa257ef4ac021a3f`. The specification +is maintained in material-identity/schema, not here. When the source changes, replace the file +with a fresh copy and update the commit and hash above. Never edit it in place. + +### How it uses this dictionary + +The specification pins one dictionary release (`"dictionary": { "release": "v2026.10.01" }`). +Each membership in `elements` (and the nested `elements` and `item` of a collection) refers to +one dictionary entry and adds the facts that belong to the specification, not to the concept: + +| Field | Meaning | +|---|---| +| `dictionaryReference` | the entry, as `https://material-identity.eu/def/` | +| `objectType` | the entry's object type, repeated so a consumer can shape the data without fetching the entry first | +| `isMandatory` | whether this specification requires the element | +| `accessCategory` | who may see the value (`public`, `legitimateInterest`, `authorityOnly`). This is informative: the legal act is normative and the passport interface enforces it | +| `granularity` | the level the value is stated at: `model`, `batch`, `item` or `operator` | +| `condition` | for an optional element, when it becomes mandatory, in `en` and `de` | + +Mandatoriness and access belong to the membership, never to the entry: the same entry can be +mandatory and public in one specification and optional or restricted in another. A test +(`test/examples.test.ts`, part of `npm test` and so of `pr-checks`) fails when any `dictionaryReference` in +`content-specifications/*.json` does not resolve to a file under `published/`. + +## Licence + +The example keeps **its own licence**: CC-BY-4.0, the data licence of material-identity/schema +(stated in the file's own `$comment`). The dictionary's CC0 1.0 dedication does **not** cover +it. If you reuse it, attribute material-identity/schema. diff --git a/examples/content-specifications/dpp-steel-v0.0.2.lock.json b/examples/content-specifications/dpp-steel-v0.0.2.lock.json new file mode 100644 index 0000000..102a3b4 --- /dev/null +++ b/examples/content-specifications/dpp-steel-v0.0.2.lock.json @@ -0,0 +1,533 @@ +{ + "specificationId": "https://material-identity.org/dpp/v0.0.2/spec/steel", + "act": "steel", + "title": { + "en": "Steel Digital Product Passport — demonstration content specification (JRC-derived element set)", + "de": "Digitaler Produktpass Stahl — Demonstrations-Inhaltsspezifikation (Elementsatz nach JRC-Vorschlag)" + }, + "dppSchemaVersion": "https://material-identity.org/schemas/dpp/v0.0.2", + "dictionary": { + "authority": "https://material-identity.eu/", + "release": "v2026.10.01" + }, + "elements": [ + { + "elementId": "productTradeName", + "dictionaryReference": "https://material-identity.eu/def/103aa193-b9c9-4a7c-8bd8-4544b2c34d8c", + "objectType": "MultiLanguageDataElement", + "isMandatory": false, + "accessCategory": "public", + "granularity": "model" + }, + { + "elementId": "modelIdentifier", + "dictionaryReference": "https://material-identity.eu/def/9322b7c7-bcff-4413-86d0-6b55a06c2a7f", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "model" + }, + { + "elementId": "heatNumber", + "dictionaryReference": "https://material-identity.eu/def/1789b4b0-3ea2-4c8c-86e2-6f80cda30be6", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "granularity": "batch", + "condition": { + "en": "Mandatory where the passport is established at batch or item level and the product comes from a single heat.", + "de": "Verpflichtend, wenn der Pass auf Chargen- oder Artikelebene erstellt wird und das Erzeugnis aus einer einzigen Schmelze stammt." + }, + "rationale": "Conditional rather than mandatory: a model-level passport, or a batch mixing several heats, has no single heat to state, and a mandatory element would force an invented value there. Every steel product passported per heat, per lot or per coil meets the condition. The heat number is the anchor of EN 10204 traceability; it is public because it is printed on the product and its labels." + }, + { + "elementId": "sequenceOrLotNumber", + "dictionaryReference": "https://material-identity.eu/def/2f0d456a-269a-470c-b185-f1d864c552a7", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "granularity": "batch", + "condition": { + "en": "Mandatory where applicable: where the manufacturer identifies a casting sequence or a production or processing lot in addition to the heat.", + "de": "Verpflichtend, falls zutreffend: wenn der Hersteller zusätzlich zur Schmelze eine Gießsequenz oder ein Fertigungs- oder Bearbeitungslos ausweist." + }, + "rationale": "Successor of castLotNumber (dictionary release v2026.10.01), which conflated the heat with the casting sequence and the lot; the heat now has its own element, heatNumber." + }, + { + "elementId": "itemSerialNumber", + "dictionaryReference": "https://material-identity.eu/def/0b73f419-6685-42ad-90fe-ca389b47b8f6", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "granularity": "item", + "condition": { + "en": "Mandatory where the passport is established at item level (e.g. individually numbered coils).", + "de": "Verpflichtend, wenn der Pass auf Artikelebene erstellt wird (z. B. einzeln nummerierte Coils)." + } + }, + { + "elementId": "gtin", + "dictionaryReference": "https://material-identity.eu/def/aedfd18d-2a70-4c97-add3-e2a4e8af0db6", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "model", + "rationale": "Mandatory per JRC Table 8. ESPR Annex III(c) reads 'GTIN or equivalent', and the JRC notes no GS1 scheme is used across the steel chain (p. 50): the 'or equivalent' reading is open (dictionary batch open point 5)." + }, + { + "elementId": "cnCode", + "dictionaryReference": "https://material-identity.eu/def/402c3df6-503f-4d98-ba62-4060b683f0ac", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "model" + }, + { + "elementId": "taricCode", + "dictionaryReference": "https://material-identity.eu/def/facc4a40-9c63-40bc-b18b-e4a2aa4e36e0", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "model" + }, + { + "elementId": "esprProductCategory", + "dictionaryReference": "https://material-identity.eu/def/34bb8a98-ed8a-496b-8981-7bbd732e10c5", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "model", + "rationale": "Values are the JRC's representative products (p. 7), not a delegated-act classification; expect a successor entry once the steel delegated act defines categories." + }, + { + "elementId": "steelGradeClassification", + "dictionaryReference": "https://material-identity.eu/def/bd30379b-b033-4743-bc87-d9ef38b4f7be", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "model" + }, + { + "elementId": "steelName", + "dictionaryReference": "https://material-identity.eu/def/0444b5f4-6587-47ac-bb82-f80ac67a0337", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "model" + }, + { + "elementId": "steelNumber", + "dictionaryReference": "https://material-identity.eu/def/268e3b59-9b83-4448-afb7-02989074cd93", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "granularity": "model", + "condition": { + "en": "Mandatory where a steel number has been allocated to the steel grade.", + "de": "Verpflichtend, wenn der Stahlsorte eine Werkstoffnummer zugeteilt ist." + } + }, + { + "elementId": "steelmakingRoute", + "dictionaryReference": "https://material-identity.eu/def/eac0bfa9-3a9e-42c8-9205-442695de3c9a", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "batch" + }, + { + "elementId": "manufacturerInformation", + "dictionaryReference": "https://material-identity.eu/def/44b59074-03fc-48b6-8a09-db112d765f81", + "objectType": "DataElementCollection", + "isMandatory": true, + "accessCategory": "public", + "granularity": "operator", + "rationale": "All members public, deviating from JRC Tables 15/16 (contact at authority-only): ESPR Art. 27(6)(a) requires name, registered trade name or mark, postal address and electronic contact 'on the public part of the digital product passport' (register E13a).", + "elements": [ + { + "elementId": "operatorName", + "dictionaryReference": "https://material-identity.eu/def/1f4ff778-38a8-485b-8094-d11df6ddeac3", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public" + }, + { + "elementId": "registeredTradeName", + "dictionaryReference": "https://material-identity.eu/def/0d5cc23f-f750-440b-90d1-bec966a76c3e", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public" + }, + { + "elementId": "postalAddress", + "dictionaryReference": "https://material-identity.eu/def/73bac10b-e4cb-437b-8628-2bfe17d7d3f9", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public" + }, + { + "elementId": "electronicContact", + "dictionaryReference": "https://material-identity.eu/def/9ad69d75-3e83-482b-a403-f8b3daca807c", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public" + } + ] + }, + { + "elementId": "importerInformation", + "dictionaryReference": "https://material-identity.eu/def/694e5f94-2fef-4750-bd3d-6530b7bc3042", + "objectType": "DataElementCollection", + "isMandatory": false, + "accessCategory": "public", + "granularity": "operator", + "condition": { + "en": "Mandatory where the manufacturer is not established in the Union and the product is placed on the Union market by an importer.", + "de": "Verpflichtend, wenn der Hersteller nicht in der Union niedergelassen ist und das Produkt von einem Importeur auf dem Unionsmarkt in Verkehr gebracht wird." + }, + "rationale": "All members public, deviating from JRC Tables 15/16: ESPR Art. 29(3)(a) places the importer's contact details on the public part of the passport (register E13a).", + "elements": [ + { + "elementId": "operatorName", + "dictionaryReference": "https://material-identity.eu/def/1f4ff778-38a8-485b-8094-d11df6ddeac3", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public" + }, + { + "elementId": "registeredTradeName", + "dictionaryReference": "https://material-identity.eu/def/0d5cc23f-f750-440b-90d1-bec966a76c3e", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public" + }, + { + "elementId": "postalAddress", + "dictionaryReference": "https://material-identity.eu/def/73bac10b-e4cb-437b-8628-2bfe17d7d3f9", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public" + }, + { + "elementId": "electronicContact", + "dictionaryReference": "https://material-identity.eu/def/9ad69d75-3e83-482b-a403-f8b3daca807c", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public" + }, + { + "elementId": "eoriNumber", + "dictionaryReference": "https://material-identity.eu/def/30f6afb0-6865-4549-b0b9-c8e128b6fadc", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public" + } + ] + }, + { + "elementId": "otherOperatorIdentifier", + "dictionaryReference": "https://material-identity.eu/def/783ab518-e8b0-47f3-9a20-eb1adbe8c167", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "granularity": "operator" + }, + { + "elementId": "countryOfOrigin", + "dictionaryReference": "https://material-identity.eu/def/e169c479-f45e-4a23-b0e3-10db50f39a91", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "batch" + }, + { + "elementId": "meltAndPourCountry", + "dictionaryReference": "https://material-identity.eu/def/0d23989d-032f-4097-87ab-e21626f4d8f6", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "batch" + }, + { + "elementId": "manufacturingDate", + "dictionaryReference": "https://material-identity.eu/def/6b76049d-23c4-4f57-a805-0a5735ba26c0", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "batch" + }, + { + "elementId": "productMass", + "dictionaryReference": "https://material-identity.eu/def/ff3cbed2-d039-4314-8405-46162c44110a", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "batch", + "rationale": "Mandatory on JRC prose only: §7.1.3 pp. 53-54 promises physical characteristics as mandatory data elements, but no JRC table carries them (register E12). A nominal value, not an inspection result (register C4)." + }, + { + "elementId": "nominalDimensions", + "dictionaryReference": "https://material-identity.eu/def/a4cfd95c-8f69-4231-a307-a9db4a6ef0bc", + "objectType": "DataElementCollection", + "isMandatory": true, + "accessCategory": "public", + "granularity": "model", + "rationale": "Mandatory on JRC prose only: §7.1.3 pp. 53-54 promises physical characteristics as mandatory data elements, but no JRC table carries them (register E12). Nominal (ordered) dimensions, not measured values (register C4); which member applies depends on the product form.", + "elements": [ + { + "elementId": "nominalThickness", + "dictionaryReference": "https://material-identity.eu/def/0dcb109a-bfda-4bf6-832b-d01b347d6d9f", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "condition": { + "en": "Mandatory for flat products.", + "de": "Verpflichtend bei Flacherzeugnissen." + } + }, + { + "elementId": "nominalWidth", + "dictionaryReference": "https://material-identity.eu/def/ad886bb9-e742-4c99-b47c-963bccc56b60", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "condition": { + "en": "Mandatory for flat products.", + "de": "Verpflichtend bei Flacherzeugnissen." + } + }, + { + "elementId": "nominalLength", + "dictionaryReference": "https://material-identity.eu/def/3f08c850-40c8-476c-ab99-d7a8c6a612c4", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "condition": { + "en": "Mandatory where the product is supplied in cut lengths.", + "de": "Verpflichtend, wenn das Erzeugnis in Festlängen geliefert wird." + } + }, + { + "elementId": "nominalDiameter", + "dictionaryReference": "https://material-identity.eu/def/90fdd516-08f8-4118-ab2e-9fe5ed4980ac", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "condition": { + "en": "Mandatory for wire rod and round bars.", + "de": "Verpflichtend bei Walzdraht und Rundstäben." + } + } + ] + }, + { + "elementId": "substancesOfConcern", + "dictionaryReference": "https://material-identity.eu/def/058585d8-a86f-4242-b555-13bab56c9adf", + "objectType": "MultiValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "batch", + "rationale": "Mandatory presence, possibly empty: an empty list states that no substance of concern is present above the information threshold (ESPR Art. 7(5); register A12). Identification and location public, concentration legitimateInterest (owner decision; JRC Table 16 pp. 72-73 'Legitimate interest or Authority only', register E8).", + "item": { + "elementId": "substanceEntry", + "dictionaryReference": "https://material-identity.eu/def/87a7b37b-303f-41c9-91a5-c0df93ef5968", + "objectType": "DataElementCollection", + "accessCategory": "public", + "elements": [ + { + "elementId": "substanceName", + "dictionaryReference": "https://material-identity.eu/def/efeef994-ce87-402d-b9e8-1c552a55701d", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public" + }, + { + "elementId": "substanceOtherNames", + "dictionaryReference": "https://material-identity.eu/def/597cd178-c7e2-46e5-9844-3e67724913c9", + "objectType": "MultiValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "item": { + "elementId": "substanceOtherName", + "dictionaryReference": "https://material-identity.eu/def/a21e647c-222a-47a7-95fe-62d67e4bcf39", + "objectType": "SingleValuedDataElement", + "accessCategory": "public" + } + }, + { + "elementId": "ecNumber", + "dictionaryReference": "https://material-identity.eu/def/1e7918b0-c845-4c50-8eeb-e075546b1185", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "condition": { + "en": "Mandatory where an EC number or ECHA list number exists for the substance.", + "de": "Verpflichtend, wenn für den Stoff eine EG-Nummer oder eine ECHA-Listennummer existiert." + } + }, + { + "elementId": "casNumber", + "dictionaryReference": "https://material-identity.eu/def/6f90f298-26b9-4b28-a451-e3f379ce7a71", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public", + "condition": { + "en": "Mandatory where a CAS number is available for the substance.", + "de": "Verpflichtend, wenn für den Stoff eine CAS-Nummer verfügbar ist." + } + }, + { + "elementId": "casName", + "dictionaryReference": "https://material-identity.eu/def/24a1b0ad-ff61-469e-a3d4-90c2be4c2183", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "public" + }, + { + "elementId": "location", + "dictionaryReference": "https://material-identity.eu/def/ff8e0a0e-389e-4659-884d-5fbe3d5621bb", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public" + }, + { + "elementId": "substanceConcentration", + "dictionaryReference": "https://material-identity.eu/def/a7959908-88a0-47ad-8591-9e01c972da06", + "objectType": "DataElementCollection", + "isMandatory": true, + "accessCategory": "legitimateInterest", + "elements": [ + { + "elementId": "concentrationDescriptor", + "dictionaryReference": "https://material-identity.eu/def/98576949-d3ef-4679-95db-854f79a87bbb", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "legitimateInterest" + }, + { + "elementId": "concentrationValue", + "dictionaryReference": "https://material-identity.eu/def/aa2503ef-7af6-4b7f-ab13-1fa811ce23fe", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "legitimateInterest" + }, + { + "elementId": "concentrationUpperLimit", + "dictionaryReference": "https://material-identity.eu/def/a2f3af21-04a6-419a-9584-787aeb57fba7", + "objectType": "SingleValuedDataElement", + "isMandatory": false, + "accessCategory": "legitimateInterest", + "condition": { + "en": "Mandatory where the concentration descriptor is concentration range.", + "de": "Verpflichtend, wenn als Konzentrationsangabe ein Konzentrationsbereich gewählt ist." + } + } + ] + } + ] + } + }, + { + "elementId": "safeUseInstructions", + "dictionaryReference": "https://material-identity.eu/def/84d1303e-0ac4-49c2-a813-8878cd627299", + "objectType": "MultiLanguageDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "batch" + }, + { + "elementId": "endOfLifeInformation", + "dictionaryReference": "https://material-identity.eu/def/5787eb67-cde3-40bb-b987-f4195e07c26b", + "objectType": "MultiLanguageDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "batch", + "rationale": "Public (owner decision 2026-09-30), as JRC §9.2.2 p. 66 allows: end-of-life handling is addressed to every holder of the product, down to the waste operator. Deviates from JRC Table 16 p. 73 ('Legitimate interest or Authority only') (dictionary batch open point 18)." + }, + { + "elementId": "recycledContent", + "dictionaryReference": "https://material-identity.eu/def/c66b5cd6-5aef-4074-97e7-64e063c3bff3", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "batch" + }, + { + "elementId": "preConsumerRecycledContent", + "dictionaryReference": "https://material-identity.eu/def/49a9e89f-5843-4ae1-ac7a-26df894c73db", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "legitimateInterest", + "granularity": "batch", + "rationale": "The pre-consumer and post-consumer values should sum to recycledContent: a cross-field rule no derived schema expresses, checked on the fixtures instead." + }, + { + "elementId": "postConsumerRecycledContent", + "dictionaryReference": "https://material-identity.eu/def/bfb61d33-65fd-44da-9766-82d74f5348f9", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "legitimateInterest", + "granularity": "batch" + }, + { + "elementId": "recycledContentConformityCertification", + "dictionaryReference": "https://material-identity.eu/def/29ab855b-8ab0-48a7-8d6d-b35ee7e06d38", + "objectType": "RelatedResource", + "isMandatory": true, + "accessCategory": "authorityOnly", + "granularity": "batch" + }, + { + "elementId": "productCarbonFootprint", + "dictionaryReference": "https://material-identity.eu/def/8dc23b31-b627-442e-b960-0a3d69fed311", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "batch" + }, + { + "elementId": "pcfMethodologyReference", + "dictionaryReference": "https://material-identity.eu/def/d45130f2-f3eb-4836-9153-4e23102208b8", + "objectType": "SingleValuedDataElement", + "isMandatory": true, + "accessCategory": "public", + "granularity": "batch" + }, + { + "elementId": "pcfConformityCertification", + "dictionaryReference": "https://material-identity.eu/def/38729474-d769-4f4c-aa3e-890a0d75ca37", + "objectType": "RelatedResource", + "isMandatory": true, + "accessCategory": "authorityOnly", + "granularity": "batch" + }, + { + "elementId": "declarationOfPerformance", + "dictionaryReference": "https://material-identity.eu/def/d01bf027-d808-4b6e-a3f2-cab0796f7eae", + "objectType": "RelatedResource", + "isMandatory": false, + "accessCategory": "public", + "granularity": "model", + "condition": { + "en": "Mandatory where the product is a construction product covered by a harmonised standard or for which a European Technical Assessment has been issued, unless an exemption under Art. 5 of Regulation (EU) No 305/2011 applies (Regulation (EU) No 305/2011 Art. 4(1)).", + "de": "Verpflichtend, wenn das Produkt ein Bauprodukt ist, das von einer harmonisierten Norm erfasst ist oder für das eine Europäische Technische Bewertung ausgestellt wurde, sofern keine Ausnahme nach Art. 5 der Verordnung (EU) Nr. 305/2011 greift (Verordnung (EU) Nr. 305/2011 Art. 4 Abs. 1)." + }, + "rationale": "Public, deviating from JRC Tables 12/16 (voluntary, legitimate interest): the declaration is supplied to every recipient and may be made available on a freely accessible website (Regulation (EU) No 305/2011 Art. 7; Delegated Regulation (EU) No 157/2014), and Regulation (EU) 2024/3110 Art. 16(2)(d) and Art. 76(2)(e) continue that free access (register E14). Conditional-mandatory because the CPR duty to draw up a declaration is independent of ESPR (#368). v0.0.2 models the Regulation (EU) No 305/2011 declaration of performance only; the declaration of performance and conformity of Regulation (EU) 2024/3110 comes in a later contract version." + }, + { + "elementId": "declarationOfPerformanceRendition", + "dictionaryReference": "https://material-identity.eu/def/8e4155d8-dc90-4c0c-a881-9c4a62507aa5", + "objectType": "RelatedResource", + "isMandatory": false, + "accessCategory": "public", + "granularity": "model", + "condition": { + "en": "Mandatory where the declaration of performance element is present; renders that declaration of performance in human-readable form (Regulation (EU) No 305/2011 Art. 7; Delegated Regulation (EU) No 157/2014).", + "de": "Verpflichtend, wenn das Element Leistungserklärung vorhanden ist; gibt diese Leistungserklärung in menschenlesbarer Form wieder (Verordnung (EU) Nr. 305/2011 Art. 7; Delegierte Verordnung (EU) Nr. 157/2014)." + }, + "rationale": "Public and conditional like the machine-readable declaration it renders (register E14). A separate entry only because resourceMediaType is single-valued." + } + ], + "$comment": "SPDX-License-Identifier: CC-BY-4.0 — IMMUTABLE published content specification for the steel act of DPP v0.0.2, generated from dpp/work/steel.content-spec.json by publishDppContentSpec. Any semantic change (a membership, a mandatory flag, an access assignment, a dictionary pin, an element key) creates a new lock and a new DPP contract version — never an edit to this file (open item O8, review F7). PoC contract: no delegated act exists for this product group; the element set is a plausible demonstration set, not a regulatory minimum.", + "dppVersion": "v0.0.2" +} diff --git a/scripts/build.ts b/scripts/build.ts index 7a4b5ca..9c0ef33 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -2,12 +2,13 @@ // Build (plan §4 M3, redesigned per issue #57): repo model → site/ — canonical JSON + // human HTML per entry, one stylesheet. Deterministic transform, no network, content // never altered. Usage: npm run build [-- --root ] [-- --out ] -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { loadRepo } from './lib/repo.ts'; import { canonicalJson } from './lib/emit.ts'; -import { RefIndex, renderAboutPage, renderEntryPage, renderGraphPage, renderIndexPages, renderSchemaPage, renderTreePage } from './lib/render.ts'; +import { type ExampleSpec, RefIndex, renderAboutPage, renderEntryPage, renderExamplesPage, renderGraphPage, renderIndexPages, renderSchemaPage, renderTreePage } from './lib/render.ts'; import { renderFeed } from './lib/feed.ts'; import { citation } from './lib/cite.ts'; import { renderTurtle } from './lib/rdf.ts'; @@ -25,6 +26,9 @@ const WELL_KNOWN_PATH = join(LIB_DIR, '..', '.well-known'); // `--exclude=.[^/]*`, so a site/.well-known/ is silently dropped and the URL 404s in production. // The Worker maps the canonical /.well-known/ onto this path; the origin is never advertised. const WELL_KNOWN_OUT = 'well-known'; +// Reference examples (#130) — content specifications that USE the dictionary. Not entries: never +// under published/, copied wholesale like .well-known, with a derived /examples/ index page. +export const EXAMPLES_PATH = join(LIB_DIR, '..', 'examples'); export interface BuildResult { entries: number; @@ -79,11 +83,25 @@ export function build(root: string, out: string): BuildResult { // derived second serialization. The canonical /def/.json is untouched by both. cpSync(CONTEXT_PATH, join(out, 'context.jsonld')); if (existsSync(WELL_KNOWN_PATH)) cpSync(WELL_KNOWN_PATH, join(out, WELL_KNOWN_OUT), { recursive: true }); + if (existsSync(EXAMPLES_PATH)) { + cpSync(EXAMPLES_PATH, join(out, 'examples'), { recursive: true }); + writeFileSync(join(out, 'examples', 'index.html'), renderExamplesPage(loadExampleSpecs(EXAMPLES_PATH), refs)); + } const issued = new Map([...releases].map(([path, release]) => [path, release.date])); writeFileSync(join(out, 'dictionary.ttl'), renderTurtle(repo, refs, issued)); return { entries, out }; } +/** Every examples/content-specifications/*.json, sorted, with the SHA-256 of its exact bytes. */ +export function loadExampleSpecs(dir: string): ExampleSpec[] { + const specDir = join(dir, 'content-specifications'); + if (!existsSync(specDir)) return []; + return readdirSync(specDir).filter((n) => n.endsWith('.json')).sort().map((name) => { + const bytes = readFileSync(join(specDir, name)); + return { path: `content-specifications/${name}`, doc: JSON.parse(bytes.toString('utf8')), sha256: createHash('sha256').update(bytes).digest('hex') }; + }); +} + function flagValue(args: string[], flag: string): string | undefined { const i = args.indexOf(flag); return i !== -1 && args[i + 1] ? args[i + 1] : undefined; diff --git a/scripts/lib/render.ts b/scripts/lib/render.ts index 9f881b1..f7335d7 100644 --- a/scripts/lib/render.ts +++ b/scripts/lib/render.ts @@ -185,7 +185,7 @@ ${body} @@ -411,7 +411,7 @@ export function renderAboutPage(repo: RepoModel, refs: RefIndex, release?: strin
  • One address, one meaning. An entry lives at https://material-identity.eu/def/<uuid>. The address says nothing by itself — you resolve it, you never parse it — so a definition can be reworded without breaking anyone's reference.
  • It never changes. A published entry is served with a year-long immutable cache and is never edited or deleted. A reference written today returns the same bytes in twenty years.
  • Meanings that move on say so. When a concept genuinely changes, a new entry is published pointing back at the one it replaces. Nothing is overwritten, and the old address keeps resolving — so an old passport stays readable while new ones move forward.
  • -
  • People and machines, same address. Ask for JSON and you get the entry; open it in a browser and you get this site's page for it. Also available as RDF for semantic tooling.
  • +
  • People and machines, same address. Ask for JSON and you get the entry; open it in a browser and you get this site's page for it. Also available as RDF for semantic tooling. To see how a passport specification points at entries, see the examples.
  • Free, in both senses. The content is CC0: no fee, no attribution obligation, nothing to clear with a lawyer before you depend on it.
  • @@ -644,3 +644,72 @@ ${rows} return { name: pageName(page), html: pageShell(page === 1 ? 'Dictionary index' : `Dictionary index — page ${page}`, canonicalPath, body, { rssFeed: true, nav: 'index' }) }; }); } + +/** One content specification under examples/, as the build found it (#130). */ +export interface ExampleSpec { + /** path below /examples/, e.g. `content-specifications/dpp-steel-v0.0.2.lock.json` */ + path: string; + doc: Doc; + sha256: string; +} + +/** Membership rows, depth-first: nested `elements` and a collection's `item` keep their parent path. */ +function membershipRows(elements: unknown, refs: RefIndex, parent: string[] = []): string[] { + const rows: string[] = []; + for (const m of Array.isArray(elements) ? (elements as Doc[]) : []) { + const path = [...parent, String(m.elementId)]; + const mandatory = m.isMandatory === undefined ? '' : m.isMandatory ? 'mandatory' : 'optional'; + const condition = en(m.condition); + rows.push(` +${path.map(esc).join(' / ')} +${refs.link(m.dictionaryReference)} +${kindChip(m.objectType)} +${esc(mandatory)}${condition === undefined ? '' : `
    ${esc(condition)}`} +${esc(m.accessCategory ?? '')} +${esc(m.granularity ?? '')} +`); + rows.push(...membershipRows(m.elements, refs, path)); + if (m.item && typeof m.item === 'object') rows.push(...membershipRows([m.item], refs, [...path.slice(0, -1), `${path.at(-1)}[]`])); + } + return rows; +} + +/** + * /examples/ (#130): content specifications that consume this dictionary, shown next to it. + * They are not entries — no /def/ id, outside published/, their own licence — and the page says + * so. Everything per file (release, licence, hash, memberships) is read from the file itself. + */ +export function renderExamplesPage(specs: ExampleSpec[], refs: RefIndex): string { + const sections = specs.map(({ path, doc, sha256 }) => { + const dictionary = (doc.dictionary ?? {}) as Doc; + const licence = /SPDX-License-Identifier:\s*(\S+)/.exec(String(doc.$comment ?? ''))?.[1]; + return `

    ${esc(en(doc.title) ?? path)}

    + +${row('Specification id', `${esc(doc.specificationId ?? '')}`)} +${row('Dictionary release', `${esc(dictionary.release ?? '')}`)} +${row('Licence', licence === undefined ? 'see the source repository' : `${esc(licence)} — its own licence, not this dictionary's CC0`)} +${row('File', `/examples/${esc(path)}`)} +${row('SHA-256', `${esc(sha256)}`)} +
    +
    + + +${membershipRows(doc.elements, refs).join('\n')} + +
    ElementDictionary entryKindMandatoryAccessGranularity
    `; + }).join('\n\n'); + + const body = `

    Examples

    +

    How a passport specification uses this dictionary. Nothing on this page is part of the dictionary: these files are not entries, have no /def/ address, and keep their own licence.

    + +

    Content specifications

    +

    A content specification is the first of the three addressing layers: the set of data elements a passport for one product group carries. It pins a dictionary release (the second layer), and each of its memberships points at one dictionary element id (the third) through dictionaryReference. Being mandatory, an access category, a granularity and a condition are facts about the membership, not about the entry. The same entry can be mandatory and public in one specification and optional or restricted in another.

    +

    The specifications shown here are maintained in material-identity/schema and copied here byte for byte. Source commit and publication status are recorded in the examples README. A test checks that every reference in them resolves to a published entry.

    +
    + +${sections || '

    No examples in this build.

    '}`; + + return pageShell('Examples', '/examples/', body, { + description: 'Content specifications that use the material-identity dictionary, shown as reference examples. They are not part of the dictionary.', + }); +} diff --git a/test/build.test.ts b/test/build.test.ts index 8d1dc91..ed70f27 100644 --- a/test/build.test.ts +++ b/test/build.test.ts @@ -356,12 +356,13 @@ test('the tracked .well-known directory is copied into the site byte-for-byte (# } }); -test('index footer links to the tree view and the schema reference page', () => { +test('index footer links to the tree view, the schema reference page and the examples', () => { const out = buildGreen(); try { const html = readFileSync(join(out, 'index.html'), 'utf8'); assert.match(html, /Tree view<\/a>/); assert.match(html, /JSON Schema reference<\/a>/); + assert.match(html, /Examples<\/a>/); } finally { rmSync(out, { recursive: true, force: true }); } diff --git a/test/examples.test.ts b/test/examples.test.ts new file mode 100644 index 0000000..0ab9499 --- /dev/null +++ b/test/examples.test.ts @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build, EXAMPLES_PATH, loadExampleSpecs } from '../scripts/build.ts'; +import { DEF_PREFIX, type RepoModel } from '../scripts/lib/repo.ts'; +import { RefIndex, renderExamplesPage } from '../scripts/lib/render.ts'; + +// Reference examples (#130): content specifications that use the dictionary, served at /examples/. +const here = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(here, '..'); +const SPEC_DIR = join(EXAMPLES_PATH, 'content-specifications'); +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** Every `dictionaryReference` value anywhere in a document, nested memberships and items included. */ +function dictionaryReferences(node: unknown): string[] { + if (Array.isArray(node)) return node.flatMap(dictionaryReferences); + if (!node || typeof node !== 'object') return []; + return Object.entries(node).flatMap(([k, v]) => (k === 'dictionaryReference' ? [String(v)] : dictionaryReferences(v))); +} + +test('every dictionaryReference in an example content specification resolves to a published entry', () => { + const files = readdirSync(SPEC_DIR).filter((n) => n.endsWith('.json')); + assert.ok(files.length > 0, 'examples/content-specifications/ holds no specification'); + for (const name of files) { + const refs = dictionaryReferences(JSON.parse(readFileSync(join(SPEC_DIR, name), 'utf8'))); + assert.ok(refs.length > 0, `${name} references no dictionary entry`); + for (const ref of refs) { + assert.ok(ref.startsWith(DEF_PREFIX), `${name}: ${ref} is not a ${DEF_PREFIX} reference`); + const uuid = ref.slice(DEF_PREFIX.length); + assert.match(uuid, UUID, `${name}: ${ref} does not end in a UUIDv4`); + assert.ok(existsSync(join(ROOT, 'published', `${uuid}.yaml`)), `${name}: ${ref} has no published/${uuid}.yaml`); + } + } +}); + +test('the reference walk finds nested memberships and collection items', () => { + const doc = { elements: [{ dictionaryReference: 'a', elements: [{ dictionaryReference: 'b' }], item: { dictionaryReference: 'c' } }] }; + assert.deepEqual(dictionaryReferences(doc), ['a', 'b', 'c']); +}); + +test('the build copies examples/ byte-for-byte and renders the /examples/ page', () => { + const out = mkdtempSync(join(tmpdir(), 'dict-site-')); + try { + build(join(here, 'fixtures', 'green'), out); + for (const name of readdirSync(SPEC_DIR)) { + assert.deepEqual(readFileSync(join(out, 'examples', 'content-specifications', name)), readFileSync(join(SPEC_DIR, name))); + } + const html = readFileSync(join(out, 'examples', 'index.html'), 'utf8'); + assert.match(html, //); + assert.match(html, /Nothing on this page is part of the dictionary/); + assert.match(html, //); + assert.ok(!/