Skip to content

Govern the graph

Your standard about structure probably lives in review comments. A concept should have one label. A page should belong to a section. A how-to should say which product variant it is about. manni graph check is where those become checks, enforced across the corpus rather than page by page.

This is Sara’s page. The outcome is that your house rules hold in the graph, and that a delivered artifact carries what you decided it should.

Why the graph, and not another per-file lint

Section titled “Why the graph, and not another per-file lint”

A per-file check sees one file. The failures that matter most here exist only after N documents merge into shared nodes:

  • Two pages spell one concept differently, so one node ends up with two preferred labels.
  • Three pages’ broader links close a cycle, so a concept is its own ancestor.
  • A page declares a product variant it is also excluded from.
  • A link resolves to a page that no longer exists.

None of those is visible in any single document. That is what check is for, and why it runs over the built graph rather than over the sources.

manni graph check ships with a shapes file, shapes/graph/shapes-1.4.0.ttl, which is what it uses when you name none. It declares a node shape per class the emitter mints: graph:Document, graph:Section, graph:Block, graph:Verification, skos:Concept, skos:ConceptScheme, iirds:ProductVariant, and the PROV agents, activities, attributions and associations. The strategy’s records get schema:Audience, schema:PeopleAudience and graph:Journey. A block’s lines, pin and pin status, and a verification’s tool and dates, are checked there. It adds three SKOS integrity checks core SHACL cannot express: skos:broader cycles, skos:related conflicting with skos:broaderTransitive, and a mapping such as skos:exactMatch that points into the scheme itself.

Every one of them is closed. A predicate the shapes have not learned fails, the way additionalProperties: false fails an unknown key. That is the feature rather than a rough edge. A change to what the graph contains is not finished until the shapes say so, in the same commit.

A published shapes file is immutable. Evolving them means a new three-segment version file beside the old one. MAJOR means a graph that used to pass now fails. MINOR means one that used to fail may now pass. PATCH means no change in validation behavior.

Write a shapes file and point graph.check.shapes at it:

graph:
check:
shapes:
- node_modules/@hawkeyexl/manni/shapes/graph/shapes-1.4.0.ttl
- shapes/house-rules.ttl

Naming shapes replaces the bundled set rather than adding to it. List the bundled file first when you mean to extend it, as above. Leave it out and you are running your rules alone, which is occasionally what you want and never what you meant by accident.

--shapes <path> does the same for one run. It is repeatable, one path per occurrence, and never split on commas, because a path may hold one.

A house rule looks like any other property shape. This one says every concept must sit in a scheme:

@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix house: <https://example.com/shapes#> .
house:ConceptInScheme
a sh:NodeShape ;
sh:targetClass skos:Concept ;
sh:property [
sh:path skos:inScheme ;
sh:minCount 1 ;
sh:severity sh:Warning ;
sh:message "Concept belongs to no scheme. Give it one, or drop it." ;
] .

Two things to get right. Target a class the emitter actually mints, or the shape matches nothing and passes silently. And do not declare your own shape sh:closed over a class the bundled shapes already close. The two closures would each reject what the other allows.

A finding is read by whoever wrote the page, so a house rule can say what to change in their words. Four parts carry that:

PartWhereWhat the finding shows
sh:messageA shapeThe message. Name the frontmatter key and say what to change.
sh:descriptionA shapeWhat the rule is for, on the line under the message.
sh:nameA property shapeThe frontmatter key that feeds the property.
owl:Ontology headerThe fileThe id from dcterms:identifier, and the guide link from rdfs:seeAlso.
<https://example.com/house/shapes>
a owl:Ontology ;
dcterms:identifier "house:shapes:1.0.0" ;
rdfs:seeAlso <https://example.com/house/guide/> .

A file with no header is named by its path, as given, and its findings carry no guide link. manni graph shapes lists every shape in force with its file’s id, and manni graph shapes show <shape> prints each property with its key and description. See graph shapes.

A shape’s sh:severity maps onto the family scale, and the source value stays in shaclSeverity for anyone who needs it. The mapping is the same one a11y uses for axe’s impact.

sh:severityFamily severityEffect
sh:ViolationerrorExits 1.
sh:WarningwarningReported; exit stays 0.
sh:InfonoticeReported; exit stays 0.

A property shape that names no severity gets SHACL’s default, sh:Violation, and therefore error. That is deliberate and worth knowing: a rule you meant as advisory blocks the build until you say sh:Warning.

This is how a new rule lands without a wall of red. Enter it at sh:Warning, watch what it catches for a sprint, then promote it to sh:Violation when the corpus is clean. The alternative is to land it at error and then weaken the rule to get green, which destroys the standard for good.

Which fields reach an exported artifact is the schema’s call, not this tool’s. Mark a top-level property in the schema that governs the page:

{
"properties": {
"owner": {
"type": "string",
"x-manni-location": "external",
"x-manni-graph-output": false
}
}
}

Absent means true. The filter runs once, before derivation. A dropped field is therefore absent from the Turtle, the JSON-LD, the iiRDS package and the search index alike. There is no post-processing step to forget.

The marks come from the schema set manni meta validate resolves for each page, so the meta: section of manni.config.yaml decides them. graph keeps no schema set of its own. The default set carries manni:stewardship:1.2.0, which marks owner, stakeholders and reviewed-by. Those name people, so a published graph leaves them out with no config line.

The mark sits beside x-manni-location in the same file on purpose. What a page stores and what a delivered artifact says are two decisions. They belong in one place, made by the person who owns the standard.

Where a field is stored does not change its mark. graph reads each page with every field its external-metadata manifest holds, as manni meta validate does. An owner kept in a {page}.meta.yaml is left out like one on the page.

A mark nested inside the graph block is ignored. The mark governs a top-level key, and graph is the top-level key.

One schema that marks a key both true and false on paths every page takes is refused when it compiles, exit 2:

steward.schema.json: "x-manni-graph-output" says both true and false for "owner".

Those paths are the schema’s own properties, its allOf entries, and a local $ref reached from them. Marks that differ across anyOf, oneOf or if/then branches are not an error. When the validator evaluates both, that schema has no preference for the key, and an earlier schema’s mark applies.

A page’s sample-questions are the questions a reader asks to reach it. graph build carries each into the graph as graph:sampleQuestion, in the page’s language. graph check asks each one through the retrieval graph search uses, against an index it builds from the graph. A page outside the top results for its own question is a finding.

graph:
check:
questions:
top: 5
severity: warning

top is how high the page must rank. severity is the finding’s level, and off asks nothing. Start at warning, as with a new shape, and promote to error once the corpus is clean. The check measures whether a reader finds the page. Nothing in it judges whether the page answers, so the result is the same on every run. Fix the graph covers a miss.

/graph/sections, /graph/revision-of and /graph/derived-from are hand-curated. A meta-provenance entry naming one of them is a graph check finding at error.

The guard lives here rather than in the schema. meta-provenance pointers became free-form, and a schema cannot express “this pointer, but not from a machine”. What it protects is the part of the graph a reader may assume a person decided. Which sections a page declares, what it supersedes, and what it was derived from.

graph never decrypts. A value encrypted with the family key is harvested like any other and lands in the graph as its ~… token. The token says a value exists and nothing about what it is. A shape can therefore require the field’s presence without the graph publishing its content.