This article was written with the help of generative AI. Facts have been checked against primary sources where possible, but errors may remain. Please verify against primary sources before relying on any of this.
At TEI 2026 (University of British Columbia, Vancouver, 10–14 August 2026) I attended Registered Workshop 3: Schematron in ODD (Syd Bauman, Northeastern University, 11 August, 15:30–18:30, BUCH B210).
The workshop was about using Schematron inside <constraintSpec> to express
constraints that PureODD cannot, and most of its second half was set aside for
participants to work on their own ODDs. So I brought the ODD for the
Kōi Genji Monogatari text database.
I wanted rules like "a waka has exactly five lines" and "every @corresp resolves
to something that actually exists."
The abstract also listed prerequisites:
Participants should arrive with the capability to generate RELAX NG and Schematron schemas from their customization ODDs. ... Participants should arrive with the capability to validate their TEI documents against both RELAX NG and Schematron. (oXygen will do, but any other method is fine, too — I use jing and a front-end to SchXslt2 on the commandline, myself.)
Generate schemas from the ODD; validate against both. I assumed I met both. As it turned out, I did not even meet the first.
I never got that far. The ground gave way first.
My own data does not pass my own published schema
As a baseline check, I ran the published RELAX NG against all 54 chapters.
$ jing tei_genji.rng xml/master/*.xml | wc -l
795
795 errors, all of the same shape:
01.xml:253:130: error: element "lg" not allowed anywhere; expected the element
end-tag, text or element "bibl", "date", "graphic", "lb", "name", "pb",
"persName", "placeName", "ref", "s", "seg" or "title"
<lg> is "not allowed anywhere." And 795 is exactly the number of waka in
this database. Every single poem was invalid.
This was not an artifact of a schema I had just regenerated. Fetching the file from the live published URL gives the same result:
$ curl -sL -o live.rng https://kouigenjimonogatari.github.io/lw/tei_genji.rng
$ jing live.rng xml/master/*.xml | wc -l
795
(That URL now 404s — the fix described here moved the file. The output above is what it returned at the time.)
The last RomaJS timestamp in the ODD's <appInfo> is 2024-06-28T00:00:16.741Z, so the
ODD had not been touched since at least then — and in all that time I had not noticed.
The cause: lg and l live in core, not verse
Here is the relevant part of the ODD:
<moduleRef key="core" include="p title lb date respStmt bibl author ref pb resp name graphic publisher"/>
...
<moduleRef key="verse" include="lg l"/>
It looks right. Verse elements belong to the verse module — surely.
But checking the actual module assignment in TEI P5:
$ # pulled from elementSpec/@module in p5subset.xml
lg module=core
l module=core
seg module=linking
pb module=core
Both lg and l are in core. The verse module contains only four elements
(caesura, metDecl, metSym, rhyme) and two classes (att.enjamb, att.metrical).
And here is the part that matters:
<moduleRef key="verse" include="lg l"/>lists element names that do not exist inverse. This mistake is never reported. It simply matches nothing, and nothing is included.
Since core's own include list did not mention lg l either, both elements
vanished from the schema entirely. The generated RNG shows it plainly:
<define name="tei_model.lLike">
<notAllowed/>
</define>
There is no <define> for lg or l at all, and model.lLike is notAllowed.
That is why <lg> is "not allowed anywhere."
As far as I can tell, nothing checks whether the names in @include actually exist.
ODD swallows a typo or a wrong module without a word.
The fix
Move lg l into core's include list. On its own, that breaks @rhyme, which comes from
att.metrical — a class in the verse module. So we still need to reference verse,
but we want none of its elements, so we exclude all four:
<moduleRef key="core" include="p title lb date respStmt bibl author ref pb resp name graphic publisher lg l"/>
...
<!-- We take no elements from verse. It is referenced only to pull in
att.metrical, which provides lg/@rhyme. -->
<moduleRef key="verse" except="caesura metDecl metSym rhyme"/>
795 → 0.
Step by step:
| State | jing errors |
|---|---|
| As found | 795 (lg not allowed) |
lg l moved to core | 795 (now @rhyme is rejected) |
verse referenced | 0 |
A side finding about @rhyme
TEI P5 defines att.metrical/@rhyme as:
@rhyme: specifies the rhyme scheme applicable to a group of verse lines.
It is meant for rhyme schemes — values like abab. The data has
rhyme="tanka" on all 795 poems, which records a verse form, not a rhyme scheme.
Strictly speaking this stretches the attribute's intent.
Changing it now would touch 795 places, so I left the data alone and pinned the value with a closed value list. I have logged it as modelling debt.
Now, the actual Schematron
With the ground back under me, on to what I came for.
Dividing the work between RELAX NG and Schematron
ISO/IEC 19757 splits these deliberately: Part 2 is RELAX NG (grammar-based), Part 3 is Schematron (rule-based). A grammar defines a closed world — anything not permitted is forbidden. Rules define an open world — only what you assert gets checked.
I went through the constraints I wanted, one at a time:
| Constraint | Expressible in RELAX NG? |
|---|---|
@n on a line matches its position | No (comparison with position) |
pb/@corresp resolves to a zone | No (checks the target's element type) |
change/@who resolves | No (same) |
pb/@n increases monotonically | No (comparison between elements) |
A waka requires xml:id | Yes (usage="req") |
@rhyme is tanka | Yes (closed valList) |
A line requires @n | Yes (usage="req") |
seg/@corresp URI pattern | Yes (datatype restriction) |
| A waka has five lines | Yes — but I kept it in Schematron (see below) |
I had written all of them as Schematron at first, then moved everything the grammar could express into PureODD. Anything in the grammar is enforced by every editor, with no extra tooling. Schematron needs a processor before it does anything at all.
There is a second reason to put attributes in the grammar, which I only understood later — see below.
The five-line rule is the interesting exception.
I first assumed that fixing the content model to "five l elements" would make it
illegal to put <pb/> inside lg. That was wrong: interleaving model.global
between the lines lets you have both.
<content><sequence>
<classRef key="model.global" minOccurs="0" maxOccurs="unbounded"/><elementRef key="l"/>
... ×5 ...
</sequence></content>
All 54 chapters pass with this, a <pb/> inserted mid-poem passes, and cutting a poem
to four lines is rejected.
I still kept it in Schematron, for a different reason: the quality of the message.
A grammar cannot say more than "exactly five", and the violation reads
element "lg" incomplete; missing required element "l" — it will not tell you
which poem has how many lines. count(tei:l) eq 5 can say "waka-001 has 4".
I noticed afterwards that the workshop abstract makes the same argument, using the
placement rules for <sic> as its example:
This can be done in PureODD, but is quite difficult, and the resulting error messages are likely to be a somewhat cryptic. (Whereas using Schematron you write your own error messages.)
"Can be expressed in the grammar" and "should be expressed in the grammar" are not the same question.
The PureODD side
<elementSpec ident="lg" module="core" mode="change">
<constraintSpec ident="waka-line-count" scheme="schematron">
...
</constraintSpec>
<attList>
<attDef ident="xml:id" mode="change" usage="req">
<desc xml:lang="en">Required: the API refers to each waka by this identifier.</desc>
</attDef>
<attDef ident="rhyme" mode="change">
<valList type="closed">
<valItem ident="tanka"/>
</valList>
</attDef>
</attList>
</elementSpec>
And here I fell into the very same trap a second time. My first attempt was:
<!-- does nothing -->
<attDef ident="id" ns="http://www.w3.org/XML/1998/namespace" mode="change" usage="req"/>
xml:id is a namespaced attribute, so surely ident and ns go separately — or so I
assumed. When I tested it, the @rhyme constraint took effect but the xml:id requirement did not:
the generated RNG still pulled an optional xml:id from att.global.
TEI P5 4.12.0 declares it like this:
ident=xml:id ns=None usage=opt
ident=n ns=None usage=opt
ident=xml:lang ns=None usage=opt
ident=xml:base ns=None usage=opt
ident=xml:space ns=None usage=opt
ident="xml:id", with no @ns. Applying mode="change" to an attribute that
does not exist is, once again, silently ignored. The correct form is:
<attDef ident="xml:id" mode="change" usage="req"/>
Same failure mode as the moduleRef. ODD says nothing when the thing you are
addressing does not exist.
The Schematron side
The remaining five go into <constraintSpec>:
<constraintSpec ident="pb-corresp-resolves" scheme="schematron">
<desc xml:lang="ja">pb/@corresp は facsimile 内の zone を指していなければならない。</desc>
<desc xml:lang="en">pb/@corresp must point to a zone inside the facsimile.</desc>
<constraint>
<sch:rule context="tei:pb[ @corresp ]">
<sch:assert
test="every $p in tokenize( normalize-space( @corresp ), '\s+' )
satisfies ( starts-with( $p, '#' )
and //tei:zone[ @xml:id eq substring( $p, 2 ) ] )"
role="error"
>ページ <sch:value-of select="@n"/> の @corresp "<sch:value-of select="@corresp"
/>" に、文書中の zone を指していない参照があります
/ @corresp "<sch:value-of select="@corresp"/>" on page <sch:value-of select="@n"
/> contains a reference that does not resolve to a zone in this document</sch:assert>
</sch:rule>
</constraint>
</constraintSpec>
This is the same behaviour I hit in the earlier post:
putting <sch:assert> directly under <constraint> gets it dropped. But this is one of
the rare cases that is not silent:
WARNING: Ignoring invalid sch:assert found directly within <constraint>.
The awkward part is what happens next. odd2relax.xsl does not drop it. It infers
the element context, wraps it in <rule context="tei:l">, and embeds it in the RNG.
The .sch and the .rng generated from the same ODD end up disagreeing. Either way,
wrap it in <sch:rule> explicitly.
<desc> can carry @xml:lang for multiple languages, but <desc> never reaches the
generated artifacts. Only the text of <sch:assert> does — so bilingual messages have to
go in the assertion text itself, as above.
Toolchain findings
Everything below was reproduced locally.
Duplicate xml:id is already caught by RELAX NG
My first draft had a Schematron rule checking for duplicate xml:id. It was unnecessary:
<attribute name="xml:id"><data type="ID"/></attribute>
Because it is xsd:ID, jing catches it:
$ jing tei_kouigenji.rng dup.xml
dup.xml:377:130: error: ID "waka-001" has already been defined
dup.xml:253:130: error: first occurrence of ID "waka-001"
No need for a Schematron rule that walks every element with count(//*[@xml:id eq $id]),
once per node carrying an xml:id.
odd2relax embeds Schematron into the RNG — but jing will not run it
odd2relax.xsl copies the Schematron patterns straight into the generated RELAX NG:
<define name="tei_lg">
<element name="lg">
...
<pattern xmlns="http://purl.oclc.org/dsdl/schematron" id="…waka-line-count…">
<sch:rule context="tei:lg[ @type eq 'waka' ]">
<sch:assert test="$lines eq 5">…</sch:assert>
So one file should be enough, right? No. Running a deliberately broken file produced nothing at all in the RELAX NG pass, even though the rules were sitting right there.
| Tool | Schematron embedded in RNG |
|---|---|
| jing 20241231 | Not executed (skipped as a foreign-namespace annotation) |
| SchXslt2 1.11.2 | Takes a .sch; will not accept an RNG |
| redhat.vscode-xml | Schematron is out of scope (issue #451) |
| oXygen XML Editor 28.1.0 | Supported |
On the command line and in CI, without a standalone .sch no Schematron runs at all.
In practice, oXygen is the only path that runs the embedded copy.
I could not get jing to run ISO Schematron
The jing jar does contain
com/thaiopensource/validate/schematron/ISOSchemaReaderImpl.class, so it looks
capable. Feeding it the .sch directly:
$ java -jar jing.jar tei_kouigenji.sch broken.xml
Exception in thread "main" java.lang.IllegalArgumentException: Unknown XPath version 0
at net.sf.saxon.Configuration.newExpressionParser(Configuration.java:2804)
My first assumption was that it could not handle queryBinding="xslt2". Wrong: a minimal
Schematron with no queryBinding at all (i.e. the default XSLT 1.0 binding) throws
exactly the same exception.
The bottom of the stack is ISOSchemaReaderImpl.createSchema:379 →
TemplatesHandlerImpl.getTemplates:90. jing 20241231 compiles its own ISO Schematron
skeleton (XSLT 1.0) with the bundled Saxon-HE 9.3.0.4, but the validation stylesheet
it generates has no xsl:stylesheet/@version, and that is where it dies. The skeleton
itself runs fine under the same saxon9.
So: at least with the jing 20241231 I have here (bundling Saxon-HE 9.3.0.4), ISO
Schematron validation never starts, regardless of queryBinding. A corroborating detail: jing.jar's manifest declares
Class-Path: saxon9.jar xalan.jar isorelax.jar resolver.jar, but the official
distribution's bin/ contains neither xalan.jar nor resolver.jar.
Schematron needs SchXslt2 plus Saxon.
teitoschematron is not in the release zip
The TEI Stylesheets source tree has bin/teitoschematron (a symlink to transformtei).
Unpacking the official tei-xsl-7.61.0.zip, though, gives you only xml/ and doc/ —
there is no bin/. transformtei is Ant-based and expects an installed layout with
lib/saxon10he.jar and profiles/ (as in the Debian tei-xsl package).
If you only have the release zip, call the stylesheets directly:
saxon -s:odd/tei_kouigenji.odd -xsl:$XSL/odd2odd.xsl \
-o:build/compiled.odd defaultSource=$PWD/tools/p5subset.xml
saxon -s:build/compiled.odd -xsl:$XSL/odd2relax.xsl -o:docs/schema/tei_kouigenji.rng
saxon -s:build/compiled.odd -xsl:$XSL/extract-isosch.xsl -o:docs/schema/tei_kouigenji.sch
saxon -s:docs/schema/tei_kouigenji.sch -xsl:tools/schxslt2/transpile.xsl -o:build/sch.xsl
That absolute path matters. defaultSource is resolved relative to the source ODD,
not to the working directory. Write tools/p5subset.xml and it looks for
odd/tools/p5subset.xml and dies:
Error: odd2odd.xsl: Source document file:.../kouigenji/odd/tools/p5subset.xml is not readable;
from file:.../kouigenji/odd/tei_kouigenji.odd, with loc=tools/p5subset.xml
XTMM9000 Processing terminated
Skipping odd2odd (the merge with P5) leaves elements referenced by moduleRef unresolved.
No "ODD to ISO Schematron" scenario in oXygen's TEI framework
Looking inside the TEI framework shipped with oXygen XML Editor 28.1.0:
$ ls "…/frameworks/tei/xml/tei/stylesheet/odds/"
extract-isosch.xsl odd2dtd.xsl odd2html.xsl odd2json.xsl
odd2lite.xsl odd2odd.xsl odd2relax.xsl odd2xslstripspace.xsl
extract-isosch.xsl is there (the real odds/ holds 15 files; the listing above is
an excerpt). But of the transformation scenarios defined in teip5odd.framework, the
ones that generate a schema are only these four:
- TEI ODD to RELAX NG XML
- TEI ODD to RELAX NG Compact
- TEI ODD to DTD
- TEI ODD to XML Schema
The remaining five are document conversions — XHTML / PDF / EPUB / DOCX / ODT — nine
scenarios in all.
Across the whole teip5odd.framework, schematron and isosch never occur.
No scenario extracts Schematron. The stylesheet is there, so you can define a scenario yourself.
Saxon 12.9 and the xmlresolver version
Saxon-HE-12.9.jar from Maven Central will not start on its own:
Caused by: java.lang.ClassNotFoundException: org.xmlresolver.Resolver
xmlresolver is a hard dependency. Add the latest 6.0.4 and it fails elsewhere,
inside doc-available() in odd2odd.xsl:
Caused by: java.lang.ClassNotFoundException:
org.apache.hc.client5.http.classic.methods.HttpUriRequestBase
at org.xmlresolver.ResourceAccess.getNetResource(ResourceAccess.java:240)
xmlresolver 6.x requires Apache HttpClient5. Either add HttpClient5, or use 5.3.3,
which resolves over java.net alone. 5.3.3 is also what the Homebrew saxon formula bundles.
SVRL has no line numbers
The @location in a Schematron report (SVRL) is an XPath in EQName form, not a line number:
/Q{http://www.tei-c.org/ns/1.0}TEI[1]/Q{…}text[1]/Q{…}body[1]/Q{…}p[1]/Q{…}pb[1]
Saxon-HE has no saxon:line-number() (that is a PE-and-above extension). To feed an
editor's problemMatcher you need line numbers, so I wrote a post-processing step that
re-evaluates @location against the source document and reads sourceline. Rewriting
Q{uri}local into prefixed form makes it evaluable with lxml.
Independent reviews found more holes
At this point everything passed, and deliberately broken files were caught. I still sent the schema through several independent reviews, each with a different lens. They found four genuine defects in the Schematron, plus one loose precondition underneath them all. I had spotted none of them.
xs:integer() aborts validation of the whole document
pb-monotonic was written like this:
<sch:report test="xs:integer( @n ) le xs:integer( $prev )" role="error">
Give it a page number like 7ウ — a non-numeric value. (This particular source is a
Western-style bound book, so it does not occur here, but recto/verso notation in a
Japanese bound book produces exactly this.)
Error code: err:FORG0001 Cannot convert string "7ウ" to an integer
saxon exit code = 2
SVRL produced: 0 bytes
The exception stops everything, so no other constraint on that document is checked
at all — and because no SVRL is written, it is easy to mistake for a clean run.
The fix was to gate the context on castable as xs:integer.
I was picking the wrong preceding pb
<!-- wrong -->
<sch:rule context="tei:pb[ @n ][ preceding::tei:pb/@n ]">
<sch:let name="prev" value="preceding::tei:pb[1]/@n"/>
The context asks whether any preceding pb has @n, but $prev reads the
nearest one. Slip a pb without @n in between and $prev becomes empty;
the result of @n le () is an empty sequence, which sch:report treats as false — so it
passes without a word. A sequence of
6, <pb/> (no @n), 2 produced zero findings. Fixed by using
preceding::tei:pb[@n][1] in both places.
@corresp is not necessarily a single value
@corresp comes from att.global.linking, and its datatype is a list of
teidata.pointer: whitespace-separated multiple references are legal. My
substring-after(@corresp, '#') turned corresp="#zone_0006 #zone_0007" into the
single string "zone_0006 #zone_0007" and reported it as unresolvable — a false
positive. Rewriting it with tokenize() over every token fixed that, and as a
bonus it now also catches values that do not start with #.
The test did not enforce what the <desc> claimed
<desc xml:lang="en">change/@who must point to a respStmt in the teiHeader.</desc>
...
<sch:assert test="//*[ @xml:id eq $id ]">
The prose says respStmt; the test accepted any element in the document.
who="#waka-001" (a poem's id) passed happily. Narrowed to
//tei:teiHeader//tei:respStmt.
And the precondition for Schematron was itself loose
This was the big one. Both waka rules use
context="tei:lg[ @type eq 'waka' ]". But @type comes from att.typed and was
optional, with a free-form value. Which means:
Forget to write
@type, or mistype it asWaka, and both constraints drop out of scope with no error and no warning. A three-line waka sails through.
The same shape applied to pb/@corresp (no attribute, no reference check),
pb/@n (no ordering check) and seg/@corresp.
In the data, 795/795 have type="waka", 1812/1812 have both @corresp and @n,
and 25065/25065 have seg/@corresp. So making all of them required breaks
nothing — which is what I did.
If a Schematron context tests for an attribute, make that attribute required in the grammar. Otherwise dropping one attribute quietly switches the rule off.
Non-reproducible output made the CI gate fail every time
I added a CI step checking that regenerating from the ODD reproduces the committed artifacts. It failed on the first run.
TEI Stylesheets stamps a generation timestamp into its output — in three different formats:
.sch <!-- This file generated 2026-08-12T05:13:22Z by 'extract-isosch.xsl'. -->
.rng Schema generated from ODD source 2026-08-12T05:20:02Z.
.html on 2026-08-12T05:19:46Z.
The same ODD produces a different file every time, so git diff --exit-code can
never pass. A small post-processing step replaces just the timestamps with fixed
text, making the build deterministic. Two consecutive builds now produce byte-identical
output for all three files.
Result
$ ./scripts/validate.zsh
=== RELAX NG (54 files) ===
=== Schematron (54 files) ===
0 findings. All 54 files passed both RELAX NG and Schematron.
After publishing, I fetched the live schema and checked again:
$ curl -sL -o live.rng https://kouigenjimonogatari.github.io/schema/tei_kouigenji.rng
$ jing live.rng xml/master/*.xml | wc -l
0
795 → 0.
And on a deliberately broken file:
build/broken.xml:56:1: error: 改訂記録の @who "#nobody" に、teiHeader の respStmt を指していない参照があります
/ change/@who "#nobody" contains a reference that does not resolve to a respStmt in the teiHeader
build/broken.xml:128:1: error: ページ 5 の @corresp "#zone_9999" に、文書中の zone を指していない参照があります
/ @corresp "#zone_9999" on page 5 contains a reference that does not resolve to a zone in this document
build/broken.xml:254:1: error: 和歌は5句です (waka-001 は 4 句)
/ A waka must have 5 lines (waka-001 has 4)
build/broken.xml:254:1: error: この句の @n は 3 であるはずです (いまは "9")
/ This line's @n should be 3 (found "9")
You have only shown that validation works when you have confirmed both that it stays quiet on good data and that it fires on bad data. As this whole episode shows, a broken schema stays broken indefinitely if nobody is running it.
Making it not happen again
Two things had to line up for this bug: a mistake in the ODD that nothing reports, and nobody running real data through the result. The first is not going away, so CI has to cover the second — and, while we are there, keep the committed artifacts from drifting away from the ODD.
- name: Build schema from ODD
run: ./scripts/build-schema.zsh
# The ODD is the single source of truth; the artifacts must agree with it
- name: Generated schema must match what is committed
run: |
if ! git diff --exit-code -- docs/schema/; then
echo "::error::docs/schema/ does not match the ODD."
exit 1
fi
- name: Validate all chapters
run: ./scripts/validate.zsh
Validating all 54 chapters takes 28 seconds here. The split is 0.3 seconds for jing and all the rest for Schematron — the cost of starting Saxon's JVM 54 times. Whatever the grammar can express is also cheaper in wall-clock time.
I originally kept this separate from the deploy workflow — but that means a failing validation does not stop publication. Turning it into a reusable workflow and calling it from deploy fixes that:
jobs:
validate:
uses: ./.github/workflows/validate.yml
build:
needs: validate
...
Now the site does not publish unless validation passes. The gate proved itself on the very next push — though what it caught was the non-reproducible-output problem above.
Takeaway
I expected to come home from the workshop with a set of Schematron rules. What I actually came home with was a single lesson: ODD ignores your mistakes without telling you. I hit that same shape repeatedly in one day.
| What I wrote | What actually happened |
|---|---|
<moduleRef key="verse" include="lg l"/> | verse has no lg/l → nothing included |
<attDef ident="id" ns="…/XML/1998/namespace"/> | att.global declares ident="xml:id" → no match, ignored |
<datatype> with no maxOccurs | default of 1 applies; multiple values become illegal |
<dataRef key="teidata.pointer" restriction="…"/> | @restriction is dropped; the pattern disappears |
<persName> in the ODD's own teiHeader | the ODD schema is itself a restricted customization without persName |
Only the last one produces an error. Everything else is silent. The generated schema looks healthy, the ODD stays valid, and you learn nothing until real data goes through it.
Schematron adds a second layer of quiet: if the context does not match, the rule
does not fire — and nothing reports that it did not fire. One forgotten @type and the
constraint is gone. "Zero findings because it was checked" and "zero findings because nothing was ever
looked at" are indistinguishable in the output. I only established which one I had
by counting how many nodes each rule actually matched.
So:
- After writing an ODD, run your real data through it. "The schema builds" is not "the schema works"
- Confirm both that it stays quiet on good data and that it fires on bad data
- Count how many nodes each rule matched — separate silence from success
- If a Schematron context tests an attribute, make that attribute required in the grammar
- Then make CI remember all of it
The value of <constraintSpec> is less that you can declare a rule, and more that a
machine will keep confirming the rule is actually alive.
Related: Embedding Schematron in a TEI ODD and validating it in VS Code
Data: Kōi Genji Monogatari text database (base text: Ikeda Kikan, Kōi Genji Monogatari, Chūōkōronsha. TEI data is CC0 1.0)
Versions tested: TEI Stylesheets 7.61.0 / TEI P5 4.12.0 / SchXslt2 1.11.2 / Saxon-HE 12.9 / Jing 20241231 / oXygen XML Editor 28.1.0

Comments
…