@prefix sh:      <http://www.w3.org/ns/shacl#> .
@prefix pu:      <http://purl.org/stuff/plugin-universe/> .
@prefix trn:     <http://purl.org/stuff/transmissions/> .
@prefix lv2:     <http://lv2plug.in/ns/lv2core#> .
@prefix units:   <http://lv2plug.in/ns/extensions/units#> .
@prefix rdf:     <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs:    <http://www.w3.org/2000/01/rdf-schema#> .
@prefix owl:     <http://www.w3.org/2002/07/owl#> .
@prefix xsd:     <http://www.w3.org/2001/XMLSchema#> .
@prefix skos:    <http://www.w3.org/2004/02/skos/core#> .
@prefix foaf:    <http://xmlns.com/foaf/0.1/> .
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix prov:    <http://www.w3.org/ns/prov#> .

## Validation shapes for the catalogue graph model.
##
## These describe what a harvester is allowed to write, and they exist because
## the alternative is discovering a defect by noticing that a facet count does
## not add up. Every constraint here corresponds to something that has gone
## wrong, or that would be silently wrong if it did:
##
##  - a plugin with no label is unfindable and unrenderable
##  - a format IRI that is not one of the known individuals is a typo that
##    quietly creates an empty facet
##  - a category IRI outside the concept scheme dangles
##  - a port with a minimum above its maximum makes every range check nonsense
##  - a graph without a licence flag cannot be included in, or excluded from,
##    the CC0 dump — so it is invisible to the thing the whole design is for
##
## Run with `npm run validate`, and in the ingest tests.

pu:shapes a owl:Ontology ;
    rdfs:label "Plugin Universe validation shapes" ;
    dcterms:license <https://creativecommons.org/publicdomain/zero/1.0/> .

## ── Wiki prose ──────────────────────────────────────────────────────────────

pu:WikiRevisionShape a sh:NodeShape ;
    sh:targetClass pu:WikiRevision ;
    rdfs:label "Wiki revision" ;

    sh:property [
        sh:path pu:wikiSubject ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:message "A revision is about exactly one plugin." ;
    ] ;
    sh:property [
        sh:path pu:wikiText ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:message "A revision holds exactly one body of Markdown. Two would mean a save merged with an existing revision instead of superseding it." ;
    ] ;
    sh:property [
        sh:path pu:editSummary ;
        sh:maxCount 1 ;
        sh:datatype xsd:string ;
    ] ;
    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:message "Prose is CC BY-SA, which requires attribution. A revision with no author cannot be licensed." ;
    ] ;
    sh:property [
        sh:path prov:generatedAtTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:dateTime ;
        sh:message "Which revision is current is decided by time, so a revision without one can never be shown." ;
    ] ;
    sh:property [
        sh:path prov:wasRevisionOf ;
        sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:message "A revision supersedes at most one other. Two predecessors is a forked history, which this design has no way to present." ;
    ] .

## ── Vendors ─────────────────────────────────────────────────────────────────
## The derived identity layer. Every constraint here is something
## `bin/mint-vendors.js` is supposed to guarantee, so a violation is a bug in the
## derivation rather than bad data from a source — which is why the script
## validates before it writes rather than after.

pu:VendorShape a sh:NodeShape ;
    sh:targetClass pu:Vendor ;
    rdfs:label "Vendor" ;

    sh:property [
        sh:path foaf:name ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:minLength 1 ;
        sh:message "A vendor has exactly one canonical name — the spelling most of their plugins use. Two would mean the fold produced a record it could not pick a name for." ;
    ] ;
    sh:property [
        sh:path pu:vendorKey ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:pattern "^[a-z0-9]+$" ;
        sh:message "The key is the folded name: lower case, no punctuation, no spaces. Anything else means the fold and the key disagree, and the grouping cannot be checked." ;
    ] ;
    sh:property [
        sh:path skos:altLabel ;
        sh:datatype xsd:string ;
        sh:minLength 1 ;
        sh:message "An alternative spelling is a spelling. An empty one says a source held a blank vendor name and it reached the identity layer." ;
    ] .

## ── Feedback ────────────────────────────────────────────────────────────────
## A message to the moderators. Correspondence rather than a contribution, so
## there is no subject plugin and no value to apply — the whole of it is who
## wrote it, when, what they said, and whether it has been dealt with.

pu:FeedbackShape a sh:NodeShape ;
    sh:targetClass pu:Feedback ;
    rdfs:label "Feedback" ;

    sh:property [
        sh:path pu:message ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:minLength 1 ;
        sh:message "A message with nothing in it is not a message. Exactly one: two would mean a write merged with an existing record instead of making a new one." ;
    ] ;
    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:message "Only a signed-in account may send feedback, so an unattributed message is one the route should never have written — and one nobody could reply to." ;
    ] ;
    sh:property [
        sh:path prov:generatedAtTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:dateTime ;
        sh:message "The queue is ordered by time, and a message without one would never appear in it." ;
    ] ;
    sh:property [
        sh:path pu:feedbackStatus ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:in ( "new" "read" ) ;
        sh:message "A message is new or read. There is nothing to accept or reject — the only question is whether a moderator has dealt with it." ;
    ] ;
    sh:property [
        sh:path pu:readBy ;
        sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
    ] ;
    sh:property [
        sh:path pu:readAt ;
        sh:maxCount 1 ;
        sh:datatype xsd:dateTime ;
    ] .

## ── Plugins ─────────────────────────────────────────────────────────────────

pu:PluginShape a sh:NodeShape ;
    sh:targetClass trn:PluginProfile ;
    rdfs:label "Plugin profile" ;

    sh:property [
        sh:path rdfs:label ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:minLength 1 ;
        sh:message "A plugin needs exactly one non-empty label: it is the name people search for." ;
    ] ;
    sh:property [
        sh:path rdfs:comment ;
        sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:message "A plugin has at most one description. Two mean two sources were merged without precedence." ;
    ] ;
    sh:property [
        sh:path trn:vendor ;
        sh:maxCount 1 ;
        sh:datatype xsd:string ;
    ] ;
    sh:property [
        sh:path dcterms:created ;
        sh:maxCount 1 ;
        sh:datatype xsd:dateTime ;
        sh:message "A plugin is first seen once. Two dates mean a re-harvest wrote a new one instead of preserving the old, which would make 'recently added' a list of whatever was harvested last." ;
    ] ;
    sh:property [
        sh:path foaf:depiction ;
        sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:message "An image is an IRI, not a string, and there is one of it." ;
    ] ;
    sh:property [
        sh:path pu:slug ;
        sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:pattern "^[^/]+/[^/]+$" ;
        sh:message "A registry slug is organisation/package-name." ;
    ] ;
    sh:property [
        sh:path trn:format ;
        sh:nodeKind sh:IRI ;
        sh:in ( trn:VST3 trn:VST2 trn:CLAP trn:AudioUnit trn:AudioUnitV3
                trn:LV2 trn:LADSPA trn:AAX trn:Standalone ) ;
        sh:message "Formats are the trn:PluginFormat individuals. An unlisted IRI is a typo, and a typo here creates a facet that silently matches nothing." ;
    ] ;
    sh:property [
        sh:path trn:role ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^http://purl\\.org/stuff/transmissions/" ;
        sh:message "Roles come from the trn: role taxonomy." ;
    ] ;
    ## What a plugin takes in, puts out, and needs from its host. The three
    ## together are what make a chain suggestable — "accepts MIDI, produces
    ## audio" is the fact that says this goes after that — and they are the part
    ## of a profile only its author really knows.
    ##
    ## Constrained to the namespace rather than enumerated. The individuals live
    ## in vocabs/trn-profile.ttl and the submission form reads them from there,
    ## so an sh:in list here would be a third copy of one list.
    ##
    ## It is also the looser constraint on purpose. Harvested data has twice
    ## carried a term the vocabulary had not caught up with — AudioSidechain and
    ## MidiCC, both now defined — and refusing those at ingest would have lost a
    ## fact rather than fixed one. The gap is closed by
    ## tests/store/profile-vocabulary.test.js, which fails when the catalogue
    ## uses a signal type this file does not define: a report rather than a
    ## refusal, which is the right severity for somebody else's vocabulary
    ## moving faster than ours.
    sh:property [
        sh:path trn:accepts ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^http://purl\\.org/stuff/transmissions/" ;
        sh:message "A signal type comes from the trn: vocabulary." ;
    ] ;
    sh:property [
        sh:path trn:produces ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^http://purl\\.org/stuff/transmissions/" ;
        sh:message "A signal type comes from the trn: vocabulary." ;
    ] ;
    sh:property [
        sh:path trn:requires ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^http://purl\\.org/stuff/transmissions/" ;
        sh:message "A host requirement comes from the trn: vocabulary." ;
    ] ;
    sh:property [
        sh:path trn:caution ;
        sh:datatype xsd:string ;
        sh:minLength 1 ;
        sh:message "A caution is prose for a reader; an empty one says nothing." ;
    ] ;
    sh:property [
        sh:path pu:category ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^http://purl\\.org/stuff/plugin-universe/category/[a-z0-9-]+$" ;
        sh:message "A category must be a concept of pu:categories. Anything else dangles outside the scheme." ;
    ] ;
    sh:property [
        sh:path pu:tag ;
        sh:datatype xsd:string ;
        sh:minLength 1 ;
        sh:pattern "^[^A-Z]*$" ;
        sh:message "Tags are folded to lower case at normalisation, so that 'Reverb' and 'reverb' are one tag." ;
    ] ;
    sh:property [
        sh:path owl:sameAs ;
        sh:nodeKind sh:IRI ;
        sh:message "sameAs asserts that two IRIs denote the same plugin. A document about the plugin is rdfs:seeAlso." ;
    ] ;
    sh:property [
        sh:path foaf:homepage ;
        sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^https?://" ;
        sh:message "foaf:homepage ranges over foaf:Document, so it is an IRI a consumer can follow — not a string. It was a literal until 2026-09-07 and nothing noticed, because nothing displayed it." ;
    ] ;
    sh:property [
        sh:path rdfs:seeAlso ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^https?://" ;
        sh:message "A source record is a document to fetch." ;
    ] ;
    sh:property [
        sh:path pu:downloadCount ;
        sh:maxCount 1 ;
        sh:datatype xsd:integer ;
        sh:minInclusive 0 ;
    ] ;
    sh:property [
        sh:path pu:sourceAvailability ;
        sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:in ( pu:OpenSource pu:SourceAvailable pu:Proprietary ) ;
        sh:message "Source availability is one of three individuals, and absent means unknown — which is not the same as proprietary." ;
    ] ;
    sh:property [
        sh:path pu:pricing ;
        sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:in ( pu:Free pu:Donationware pu:Freemium pu:Paid ) ;
        sh:message "Pricing is one of four individuals. It is never derived from a licence: an open-source licence grants source, not a free binary." ;
    ] ;
    sh:property [
        sh:path pu:licenceId ;
        sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:minLength 1 ;
        ## Generated from LICENCE_IDS in src/harvest/Licensing.js, which is
        ## OPEN_SOURCE_LICENCES plus OTHER_LICENCES. tests/harvest/Licensing.test.js
        ## binds the two; adding a licence there and not here produced 136
        ## violations after one GitHub sweep. Regenerate rather than hand-edit.
        ##
        ## GPL, LGPL and AGPL without a version are here deliberately: a DOAP
        ## licence URL states the family and not the version, and the catalogue
        ## records what the source said rather than choosing a version for it.
        ## A warning, not a violation — this list is a list of what we recognise,
        ## and the world will keep producing licences it does not contain.
        sh:in (
          "0BSD" "AFL-3.0" "AGPL-3.0" "Apache-2.0" "Artistic-2.0"
          "BSD-2-Clause" "BSD-3-Clause" "BSL-1.0" "CC0-1.0" "EPL-2.0"
          "EUPL-1.2" "GPL-2.0" "GPL-3.0" "GPL-3.0-or-later" "ISC"
          "LGPL-2.1" "LGPL-3.0" "MIT" "MPL-2.0" "Unlicense"
          "Zlib" "GPL" "LGPL" "AGPL" "CC-BY-4.0"
          "CC-BY-SA-4.0" "CC-BY-NC-SA-4.0" "CC-BY-ND-4.0" "Proprietary" "NOASSERTION"
        ) ;
        sh:severity sh:Warning ;
        sh:message "Unrecognised licence identifier. Normalise it in toSpdx(), or add it to OPEN_SOURCE_LICENCES or OTHER_LICENCES in src/harvest/Licensing.js and regenerate the sh:in list here." ;
    ] .

## ── Promotion ───────────────────────────────────────────────────────────────
## A paid placement, and the record that makes an ad repository a query.
##
## The constraints here are the compliance ones. A promotion that cannot say
## what it promotes, or when it ends, is one that cannot be disclosed and
## cannot be expired — and an unexpirable paid placement is the failure that
## costs a great deal to retrofit.

pu:PromotionShape a sh:NodeShape ;
    sh:targetClass pu:Promotion ;
    rdfs:label "Promotion" ;

    sh:property [
        sh:path pu:promotes ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:message "A promotion promotes exactly one plugin. Two would make the ranking effect impossible to disclose per result." ;
    ] ;
    sh:property [
        sh:path pu:startedAt ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:dateTime ;
        sh:message "A placement records when it began." ;
    ] ;
    sh:property [
        sh:path pu:endsAt ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:dateTime ;
        sh:message "A placement records when it stops. Without an end it never expires, which is the one thing a paid placement must not be." ;
    ] ;
    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:message "Somebody placed it. An unattributable paid placement has no audit trail." ;
    ] ;
    sh:property [
        sh:path pu:paidBy ;
        sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
    ] ;
    sh:property [
        sh:path pu:revokedBy ;
        sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
    ] ;
    sh:property [
        sh:path pu:paymentReference ;
        sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:pattern "^cs_[A-Za-z0-9_]+$" ;
        sh:message "A payment reference is a Stripe Checkout Session id. Anything else here means something other than a verified payment wrote this placement." ;
    ] ;
    # A placement that ends before it starts reads as permanently inactive
    # rather than as broken, so nothing would ever report it.
    #
    # sh:lessThan, not sh:sparql: rdf-validate-shacl has no SPARQL constraint
    # validator and *throws* when it meets one, which would take down
    # `npm run validate` for every graph rather than skipping the constraint.
    # tests/rdf/shapes.test.js keeps one out of this file.
    sh:property [
        sh:path pu:startedAt ;
        sh:lessThan pu:endsAt ;
        sh:message "A promotion ends before it starts." ;
    ] .

## ── Parameters ──────────────────────────────────────────────────────────────
## Ports are blank nodes: a parameter has no identity outside its plugin.

pu:PortShape a sh:NodeShape ;
    sh:targetObjectsOf lv2:port ;
    rdfs:label "Control port" ;

    sh:property [
        sh:path rdf:type ;
        sh:minCount 1 ;
        sh:message "A port must say what kind of port it is." ;
    ] ;
    sh:property [
        sh:path lv2:symbol ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:pattern "^[a-zA-Z_][a-zA-Z0-9_]*$" ;
        sh:message "An LV2 symbol is an identifier, not a label: it is how a CC mapping, a measurement or a preset refers to the parameter." ;
    ] ;
    sh:property [
        sh:path lv2:name ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
    ] ;
    sh:property [
        sh:path lv2:minimum ;
        sh:maxCount 1 ;
        sh:lessThanOrEquals lv2:maximum ;
        sh:message "A parameter's minimum cannot exceed its maximum." ;
    ] ;
    sh:property [
        sh:path lv2:maximum ;
        sh:maxCount 1 ;
    ] ;
    sh:property [
        sh:path lv2:default ;
        sh:maxCount 1 ;
    ] ;
    sh:property [
        sh:path units:unit ;
        sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:message "A mapped unit is a units: individual. An unmapped one is kept as pu:unitLabel instead." ;
    ] ;
    sh:property [
        sh:path lv2:scalePoint ;
        sh:node pu:ScalePointShape ;
    ] .

pu:ScalePointShape a sh:NodeShape ;
    rdfs:label "Scale point" ;
    sh:property [
        sh:path rdfs:label ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
    ] ;
    sh:property [
        sh:path rdf:value ;
        sh:maxCount 1 ;
    ] .

## ── Packaging ───────────────────────────────────────────────────────────────

pu:PackageShape a sh:NodeShape ;
    sh:targetClass pu:Package ;
    rdfs:label "Package" ;

    sh:property [
        sh:path pu:packageVersion ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:message "A package is a released version; without the version it is not one." ;
    ] ;
    sh:property [
        sh:path dcterms:issued ;
        sh:maxCount 1 ;
    ] ;
    sh:property [
        sh:path pu:packageFile ;
        sh:node pu:PackageFileShape ;
    ] .

pu:PackageFileShape a sh:NodeShape ;
    sh:targetClass pu:PackageFile ;
    rdfs:label "Package file" ;

    sh:property [
        sh:path pu:downloadUrl ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^https?://" ;
        sh:message "A file record with no download URL describes nothing anyone can fetch." ;
    ] ;
    sh:property [
        sh:path pu:sha256 ;
        sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:pattern "^[0-9a-f]{64}$" ;
        sh:message "A SHA-256 is 64 lower-case hex digits. A malformed one is worse than none: it looks checkable." ;
    ] ;
    sh:property [
        sh:path pu:fileSize ;
        sh:maxCount 1 ;
        sh:datatype xsd:integer ;
        sh:minInclusive 1 ;
    ] ;
    sh:property [
        sh:path pu:fileKind ;
        sh:maxCount 1 ;
        sh:in ( "archive" "installer" ) ;
    ] ;
    sh:property [
        sh:path pu:operatingSystem ;
        sh:in ( "linux" "mac" "win" ) ;
        sh:message "Operating systems follow the Open Audio Stack naming, which the packaging layer is modelled on." ;
    ] ;
    sh:property [
        sh:path pu:architecture ;
        sh:in ( "arm32" "arm64" "arm64ec" "x32" "x64" ) ;
    ] ;
    sh:property [
        sh:path pu:containsFormat ;
        sh:nodeKind sh:IRI ;
        sh:in ( trn:VST3 trn:VST2 trn:CLAP trn:AudioUnit trn:AudioUnitV3
                trn:LV2 trn:LADSPA trn:AAX trn:Standalone ) ;
    ] .

## ── Measurements ────────────────────────────────────────────────────────────
## An observation of a plugin on a machine at a time. Without the platform and
## the timestamp it is a number rather than a fact, so both are required.

pu:MeasurementShape a sh:NodeShape ;
    sh:targetClass pu:Measurement ;
    rdfs:label "Measurement" ;

    sh:property [
        sh:path pu:subject ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:message "A measurement is of something." ;
    ] ;
    sh:property [
        sh:path pu:metric ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
    ] ;
    sh:property [
        sh:path pu:value ;
        sh:minCount 1 ; sh:maxCount 1 ;
    ] ;
    sh:property [
        sh:path pu:tool ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:message "Which tool, and which version of it. A reading whose tool is unknown cannot be compared with another." ;
    ] ;
    sh:property [
        sh:path pu:platform ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:message "A CPU figure without the machine it was taken on is not a fact, it is a number." ;
    ] ;
    sh:property [
        sh:path prov:generatedAtTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:dateTime ;
    ] .

## ── Categories ──────────────────────────────────────────────────────────────

pu:ConceptShape a sh:NodeShape ;
    sh:targetClass skos:Concept ;
    rdfs:label "Category concept" ;

    sh:property [
        sh:path skos:prefLabel ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
    ] ;
    sh:property [
        sh:path skos:inScheme ;
        sh:minCount 1 ;
        sh:nodeKind sh:IRI ;
    ] ;
    sh:property [
        sh:path skos:broader ;
        sh:maxCount 1 ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^http://purl\\.org/stuff/plugin-universe/category/[a-z0-9-]+$" ;
    ] ;
    sh:property [
        sh:path skos:narrower ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^http://purl\\.org/stuff/plugin-universe/category/[a-z0-9-]+$" ;
    ] ;
    sh:property [
        sh:path skos:related ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^http://purl\\.org/stuff/plugin-universe/category/[a-z0-9-]+$" ;
        sh:message "A related concept is another category in this scheme. Anything outside it is a closeMatch, not a related." ;
    ] ;
    sh:property [
        sh:path skos:altLabel ;
        sh:datatype xsd:string ;
        sh:minLength 1 ;
        sh:message "An alternative label is a non-empty string — it is what somebody might type instead of the preferred label." ;
    ] ;
    sh:property [
        sh:path skos:definition ;
        sh:maxCount 1 ;
        sh:datatype xsd:string ;
    ] ;
    sh:property [
        sh:path skos:scopeNote ;
        sh:maxCount 1 ;
        sh:datatype xsd:string ;
    ] ;
    sh:property [
        # An alignment points outside this scheme by definition: a closeMatch to
        # our own category would say a concept is close to itself.
        sh:path skos:closeMatch ;
        sh:nodeKind sh:IRI ;
        sh:not [ sh:pattern "^http://purl\\.org/stuff/plugin-universe/category/" ] ;
        sh:message "A closeMatch names the same idea in another vocabulary. Within this scheme, use broader, narrower or related." ;
    ] .

## ── Graph registration ──────────────────────────────────────────────────────
## The licensing design rests entirely on these fields being present and right.

pu:SourceGraphShape a sh:NodeShape ;
    sh:targetClass pu:SourceGraph ;
    rdfs:label "Source graph registration" ;

    sh:property [
        sh:path dcterms:license ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:string ;
        # Kept in step with LICENCES in src/store/GraphRegistry.js, which is the
        # authority. tests/rdf/shapes.test.js fails if the two drift — they did,
        # the moment the GitHub harvester started registering a graph per
        # repository with that repository's own licence.
        sh:in (
                "CC0-1.0" "Unlicense" "0BSD" "MIT" "ISC" "BSD-2-Clause" "BSD-3-Clause" 
                "Apache-2.0" "BSL-1.0" "Zlib" "MPL-2.0" "GPL-2.0" "GPL-3.0" "LGPL-2.1" 
                "LGPL-3.0" "AGPL-3.0" "CC-BY-4.0" "CC-BY-SA-4.0" "proprietary-linkout" 
                "personal-data" "unknown"
        ) ;
        sh:message "Every graph declares a licence from the known set. There is no default: a wrong guess here is a licensing error that would not surface until publication." ;
    ] ;
    sh:property [
        sh:path pu:redistributable ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:boolean ;
    ] ;
    sh:property [
        sh:path pu:inCC0Dump ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:boolean ;
        sh:message "Dump assembly is a query over this flag. A graph without it is invisible to the dump." ;
    ] ;
    sh:property [
        sh:path pu:precedence ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:integer ;
    ] ;
    sh:property [
        sh:path prov:wasDerivedFrom ;
        sh:minCount 1 ;
        sh:message "A graph must record where its content came from." ;
    ] ;
    sh:property [
        sh:path prov:generatedAtTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:dateTime ;
    ] .
