Skip to content

Playwright UI suite: the harness - #381

Merged
namedgraph merged 29 commits into
developfrom
ft-ui-tests
Sep 12, 2026
Merged

namedgraph merged 29 commits into
developfrom
ft-ui-tests

Conversation

@namedgraph

Copy link
Copy Markdown
Member

Eight of the last eighty commits report Playwright verification in their message bodies — "20/20 against /admin-units/36/", "8/8 against the running stack", "27 checks green as owner", "the accumulated Playwright batteries run green". Not one Playwright file was ever committed. Every script lived in a per-session scratch directory — 14 of them, ~75 files — and was deleted with it, so the batteries never accumulated.

This is the harness, and only the harness: the parts every one of those scripts rewrote, written once.

What's here

File Does
lib/stack.mjs URLs, keystore, passphrase read from secrets/ — never inlined. Certificates for both origins
lib/console.mjs test extended so a console error, pageerror, alert() or 4xx fails the test that provoked it
lib/settle.mjs Block-count stability, replacing waitForTimeout(4000)
lib/fixtures.mjs ldh-seeded ui-fixtures/; every URI a pure function of its index
global-setup.mjs The preflight, then seeding
playwright.config.mjs owner / anonymous projects, serial
specs/calibration.spec.mjs Transitional probe — measures, asserts nothing

Three decisions worth review

@playwright/test rather than the bare playwright package, which is a departure from the precedent in ../RDFa-Editor. Auto-retrying expect(locator) replaces the waitForTimeout(4000) every ad-hoc script needed for Saxon-JS hydration — the largest source of flake and the thing that would make a CI job unreliable. Projects map onto the anonymous-vs-owner axis, so "no edit pencil for anonymous" becomes a test rather than a separate script. And traces-on-failure are close to mandatory for CI here, since a CSR failure on a runner you cannot attach to is otherwise opaque.

The preflight compares the served SEF against target/ROOT. Nothing currently catches this, and a green run against a stale SEF is worse than a red one. It is local-only by construction rather than by branching on CI: target/ROOT/… exists exactly when docker-compose.override.yml is bind-mounting the working tree, so an image-baked SEF finds no local file and the check stands down on its own.

Fixtures are seeded, not restored. The suite owns one container and deletes it, deliberately not snapshotting and restoring the whole dataset the way run.sh does — right for a suite that owns the instance, wrong for one sharing your dev stack.

The probe is transitional

specs/calibration.spec.mjs asserts nothing. It measures what the instance actually serves so the contract in lib/ds.mjs is written against the running build rather than against the design-system READMEs alone. Two questions already need it:

  • does anonymous browsing return a page, or the 403 that http-tests.yml's readiness probe waits for?
  • how does the div+class rule square with main.ldh-block-body and nav:has(> .ldh-tabs), which the design system and app.css respectively require?

Its findings get reported, not silently absorbed. It has not been run yet — the stack's nginx container is stopped and ldh is not built, so nothing in this PR has executed against a live instance beyond the preflight correctly refusing to start.

Not yet here, in plan order

lib/ds.mjs and the conformance sweep; behaviour specs for blocks, views, dialogs, chrome, forms and modes; and the CI job — which wants http-tests.yml's stack bring-up extracted to a composite action first, landed and verified green on its own before anything depends on it.

🤖 Generated with Claude Code

https://claude.ai/code/session_019EptZ6w7rd883q8N4CMorR

namedgraph and others added 24 commits September 12, 2026 11:17
…F per import set.

A package's ac:stylesheet has only ever affected server-rendered output. The client
SEF is compiled at build time from client.xsl alone, so the first time client.xsl:1104
intercepts a link and re-renders the pane, every package rule disappears - the page is
right on load and wrong on the first click, with no error anywhere. client.xsl:428
documents the symptom and works around it by keeping the server-rendered body on the
initial load, which is why this has been survivable rather than obvious.

Saxon-JS cannot close the gap in the browser: SaxonJS.compile() is gated on inBrowser()
in both the browser and Node builds and throws SXJS0006. Shipping one prebuilt SEF per
package does not work either, because xsl:import precedence is resolved at compile time -
two packages overriding the same mode would both run with nothing deciding which wins,
and each package SEF would carry its own copy of the whole client tree. So the composition
has to happen server-side, per distinct set of imported packages.

Composition is a generated wrapper, not an edit to client.xsl. client.xsl pulls its 14
client modules in with xsl:include, and an included module shares the includer's import
precedence, which beats template priority outright - so appending a package import into
client.xsl would place it BELOW everything in client/**, unable to override ldh:TreeNode,
ldh:RenderRow, any ixsl:on* handler, or the form and modal machinery. It would compile
cleanly and do almost nothing. The wrapper imports client.xsl first and the packages
after, so later imports outrank it; measured on a composed build, the package lands at
import precedence 40 against 39 for client.xsl and every module it includes. This mirrors
what static/xsl/layout.xsl already is for the server: an eleven-line shim whose only job
is to be the thing packages get composed onto.

The compiler runs as its own service because the compile peaks at 1,495,990,272 bytes
resident - measured, not estimated - which beside a Tomcat heap at 75% of a 2048m limit
is an OOM, and capping V8 below the peak just fails the compile instead. It is built from
the same Dockerfile as the platform and takes the static tree by COPY --from the Maven
stage, so the modules it compiles are the deployed bytes by construction: no mount, no
shared volume, and no way for a rebuild to leave it stale. It is stateless - the caller
sends the package modules inline and gets the SEF back - so there is no staging volume
either, and it never touches the network, which keeps the SSRF surface out of the
component doing the compiling.

Package stylesheets are fetched and entity-expanded by the platform first, because
SaxonJS has no DTD internal subset and the bundled packages declare entities: xslt3-he
reports "Reference to unknown entity &ldh;" and stops. That is the same step pom.xml
already applies to every stylesheet in the WAR. It uses SecureXML.newXMLReader(), which
tolerates a benign internal DOCTYPE - newDocumentBuilderFactory() sets
disallow-doctype-decl and would throw on the package.

Keyed on the inputs rather than the output. A SEF is not byte-deterministic - its header
carries a build timestamp - so content-addressing the result would mint a new URI on
every compile and a different one per node. The key is SHA-1 over the digest of the stock
client.xsl.sef.json concatenated with the sorted package URIs: the stock SEF fingerprints
all 66 client modules and changes on every platform build, so an upgrade invalidates every
composed stylesheet, and two nodes agree on one URI for one import set. The digest uses
its own MessageDigest instance; Application's is shared and not thread-safe.

Served from /static/xsl/sef/ through a Tomcat alias onto a directory outside the WAR,
not from the upload store. Uploads would need an invented owning document for Item.init()
to find, an authorization that grants anonymous read in every dataspace, and three SPARQL
round-trips before streaming 19 MB - and varnish passes /uploads/ for every signed-in user,
where its VCL says /static/* is the only safely-shared path. Under /static/ the file is
public by construction and inherits the immutable caching, CORS and gzip the built-in
stylesheets already get. Writes go through a temp file and ATOMIC_MOVE so a reader never
sees a partial 19 MB file.

Server and client stay in lockstep. XsltExecutableFilter moves to USER + 350 so it outranks
ResponseHeadersFilter, which is where the composed stylesheet's URL is advertised - response
filters run in descending priority, so at 200 the decision came too late to be published.
It then makes one choice: no packages, stock; key published, composed executable AND the
client SEF URL; otherwise stock on BOTH sides plus an async build. Composing server-side
while the client cannot is the exact shape of the defect this commit exists to remove, so
it is never done. An instance with no compiler configured keeps composing server-side as
before, so this changes nothing for deployments that do not run the service.

The URL travels as a Link header read back by ldh:client-stylesheet(), the same way
acl:mode and the Memento relations already reach the stylesheets, rather than as a new
Java-to-stylesheet parameter; layout.xsl falls back to the built-in SEF when the header is
absent. The client twin returns the empty sequence: by the time that stylesheet runs, it
is the one already running.

Triggered from three places, because packages declared in config/dataspaces.trig never
PATCH: the settings PATCH, startup, and lazily on an unknown key. The build pool holds one
thread, and the per-key CompletableFuture chain registers its cleanup after computeIfAbsent
returns, the same re-entrancy trap GraphVersioningService documents.

The whole feature is optional at startup - no SEF root, no compiler endpoint, or no stock
SEF to fingerprint with, and it logs and stays off rather than failing to boot.

The bundled SKOS package loses its static ldh:base parameter and its import of the server
layout, in the Apps repository: layout.xsl is not in the client closure, so importing it
would drag the entire server layout in above client.xsl and let server templates beat
client ones, and xslt3-he has no CLI option for static parameters, so the parameter could
not be supplied at all. A package ac:stylesheet is now a leaf module.

Verified: mvn compile clean; the composition compiles through the service end to end
against the real client.xsl and the SKOS package - 200 in 19.6s, 19,166,264 bytes,
927,372 gzipped, the package rule at precedence 40, and the stylesheet directory restored
exactly afterwards; the Link rel matcher unit-tested on SaxonJS across five header shapes;
docker buildx --check and docker compose config clean; and the context.xsl output carries
both the alias and the parameters. Not yet verified: make sef, which would swap the SEF
under the running instance a parallel session is testing against.

docker-compose.yml gains the service, the platform's dependency on it and the SEF volume.
The file had skip-worktree set locally because it also carried an enabled credentials
secret; that is a per-machine choice, so it moves to docker-compose.override.yml - which
already restates this service's secret list - and the base file returns to its shipped
default. Verified through the merged configuration rather than by reading: the secret still
reaches the container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
…seconds later.

The previous commit made server-side composition wait for the compiled client stylesheet,
so that a package could never be live on one renderer and not the other. That broke
misc/PATCH-settings-package-import.sh, which declares an ldh:import and asserts the
package's marker on the very next response - and the test is right. Its own comment, and
the 5.10.0 changelog that introduced declarative imports, state the contract: effective on
the next request, no restart. Holding the server back silently converted that into
"effective in about twenty seconds", which is a different product.

The second consequence was worse than the interleaving it was meant to prevent. The
Dockerfile always sets SEF_COMPILER, so the service is always constructed and the
"no compiler configured" fallback never fires in a real deployment - an instance whose
compiler container was missing or unreachable would have lost package rendering entirely
rather than degrading to the behaviour it had before any of this existed.

So composition is never withheld. Packages compose server-side on every request, exactly
as they did; the compiled SEF is an addition the client picks up once it exists. The window
where the server has the package and the client does not is precisely the behaviour that
shipped in 5.10.0 and has been permanent ever since - it is now bounded to one compile
after an install or a platform upgrade, which is the improvement this branch is for.

One thing this costs, recorded because it is a real change in observability: a compile
failure is no longer visible in the rendered page, since the server still composes. It
surfaces in the build's exceptionally handler and in the compiler's verbatim stderr, and
nowhere else.

Known and not addressed here: while a key is unpublished, every render submits a build.
computeIfAbsent deduplicates concurrent ones, but a failing compiler clears the entry on
completion, so a persistently unreachable compiler means one attempt per request. Cheap to
bound with a negative cache; out of scope for restoring the contract.

Verified: mvn compile clean. The assertion sequence the test drives now holds by
construction - no import, stock; import, composed on the next request; import removed,
stock again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
…y is one consumer of.

A SKOS concept tree is the same widget as the drawer's document tree over a different relation:
the same nested list of links, the same disclosure that is a sibling rather than a child of the
anchor, the same depth ramp, the same lazily loaded placeholder row. Copying it for the taxonomy
editor would have produced two emitters of one anatomy, which is the arrangement that let the
derived view and the authored view drift apart on three axes before anyone noticed.

So client/tree.xsl holds the widget and knows nothing about what fills it. Two seams carry the
domain: ldh:TreeChildrenLoad, dispatched on the disclosure button so a tree selects its loader by
matching the container it sits in, and an $expandable parameter on ldh:TreeNode, because "can this
node be expanded" is a question about a relation the component must not know. The document
hierarchy answers both in navigation.xsl - a rule matching sioc:has_parent that next-matches into
the generic emitter, and a rule matching the drawer's tree that supplies the ldh:SelectChildren
query. Both are same-precedence includes, so priority separates them; import precedence has
nothing to say here.

Deliberately left behind: ldh:doctree-descend expands a path by prefix-matching child hrefs
against the target URI. That is a property of a hierarchy whose URIs nest, not of trees - concepts
are filed flat under concepts/ and a concept tree has to walk explicit links - so generalizing it
would have been generalizing a coincidence.

The extraction also removes a duplicate it exposed: ldh:doctree-descend carried its own inline
copy of the children query, annotated "inline from ldh:DocTreeResourceLoad". Both now call
ldh:doc-tree-children-query, so the relation the document tree follows is written once.

Behaviour is unchanged by construction: every template moved verbatim except for the two
parameters, and the callback's only edit is its name - ldh:left-sidebar-resource-response said
where it rendered rather than what it rendered, which is exactly the assumption being removed.

Verified: make sef compiles clean in 18.9s against a freshly rebuilt overlay, tree.xsl is in the
exported SEF, and the two ldh:TreeNode rules land as intended - the sioc:has_parent override at
priority 1 and the generic emitter at 0.5, same import precedence. The only other *[@Rdf:about]
rule in navigation.xsl is ldh:ClassListItem, a different mode, so nothing is ambiguous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
…y each domain writes.

The extraction left the widget generic and the relation hard-coded twice over: a stored SPIN
query naming sioc:has_parent and sioc:has_container, and a match on sioc:has_parent deciding
which nodes get a disclosure. A concept tree would have had to bring its own SPARQL to say the
one thing that actually differs between the two trees - which link to follow.

So ldh:tree-children-query takes the relation and generates the SELECT. It takes it from both
ends, because RDF puts the link wherever the modeller put it: properties asserted on the child
pointing at its parent, and properties asserted on the parent pointing at its children. The
document hierarchy has two of the first kind and none of the second, and now declares exactly
that:

    ldh:tree-children-query($uri, (sioc:has_parent, sioc:has_container), ())

A SKOS tree asserting skos:broader on the child and possibly skos:narrower on the parent gets
both branches from the same function, which is the case Web-Client's own skos:narrower() and
skos:broader() already union in XPath - the same modelling fact, now expressed once more in the
one place that builds the query.

What the generated query keeps and drops, against the stored ldh:SelectChildren it replaces: the
type requirement stays, so a tree still shows resources that describe themselves; the ORDER BY
and the ?thing binding go, because children are sorted by ac:label() at render time and the topic
variable was already being stripped by ldh:replace-variables before the query was ever run. The
query text is generated rather than stored, so ldh:SelectChildren has no caller left here.

Verified: both relations produce the SPARQL they should, driven on SaxonJS through the exact
string-building expression -

  SELECT DISTINCT ?child WHERE { GRAPH ?childGraph {
    { ?child <sioc:has_parent> <uri> } UNION { ?child <sioc:has_container> <uri> } ?child a ?Type } }

  SELECT DISTINCT ?child WHERE { GRAPH ?childGraph {
    { ?child <skos:broader> <uri> } UNION { <uri> <skos:narrower> ?child } ?child a ?Type } }

- the UNION grouping matching the stored query's, and an empty relation terminating rather than
building a query that would return the whole graph. make sef compiles clean in 18.6s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
…m had no argv left.

Adding ldhc:sefRoot and ldhc:sefCompiler as --stringparam arguments took the container down at
startup: "too many params increase MAX_PARAMETERS", then exited (2). xsltproc stores parameters in
a fixed array of 64 argv slots, two per parameter, so 32 is the ceiling, and the ROOT.xml
transform already carries 52 stringparam definitions of which the optional mail and OAuth ones
collapse when unset. CI stayed under the line because its configuration leaves those empty; a
developer with Google, ORCID and SMTP configured is over it, which is why this passed a full
green run and broke a local make up.

The remedy is the one this codebase already uses for settings of this kind: system properties on
CATALINA_OPTS, read with System.getProperty, as allowInternalUrls and the HTTP client timeouts and
cache expirations are. Infrastructure paths and endpoints belong there anyway - they configure the
process, not a dataspace, and nothing about them needs to reach a stylesheet.

The Tomcat alias is the one piece that genuinely has to be in ROOT.xml, since it is a Context
attribute. It is now written by xmlstarlet straight after the transform rather than by
context.xsl, which keeps a single source of truth in $SEF_ROOT and costs no argv:

    <Context path="" aliases="/static/xsl/sef=/var/www/linkeddatahub/sef">

Both variables also stop being mandatory. The feature is optional by construction - Application
logs and stays off without them, leaving packages server-side - so aborting startup over a missing
value contradicted the design and added a failure mode for anyone with a custom environment.

Verified: the stringparam set is now byte-identical to develop's, 52 definitions, so the transform
is back to the argv budget it shipped with; the alias injection produces the attribute Tomcat
expects, driven against a stand-in ROOT.xml; mvn compile clean. The ldhc:sefRoot and
ldhc:sefCompiler vocabulary terms go with the parameters, since nothing reads them now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
…cheme it must belong to.

The package could already render a concept's neighbours - narrower, broader, collection members,
and the concepts in a scheme - but nothing rendered a scheme's entry level, which is where reading
a taxonomy starts and where the concept tree has to root itself. skos:hasTopConcept now carries a
view, and it counts a top concept from either end: a scheme may assert skos:hasTopConcept, a
concept may assert skos:topConceptOf, and SKOS declares those inverses without preferring one, so
a query that reads only the forward direction returns an empty tree for half of the taxonomies in
the wild.

Two more views and a constraint, all pointed at the same failure mode - a concept that exists but
cannot be reached:

- skos:inScheme gains a forward view, so a concept shows which scheme it belongs to rather than
  only schemes showing their concepts.
- :OrphanConcepts lists concepts a scheme claims that nothing places in its hierarchy - neither a
  top concept nor narrower than anything. Such a taxonomy is not invalid, but a tree that descends
  from the top cannot reach them, so the scheme surfaces them instead of leaving them invisible.
  It carries ldh:showWhenEmpty false, because a well-formed scheme should show no such block at
  all, and it attaches as a second ldh:inverseView on skos:inScheme - a property can carry several,
  and the injection groups results by block.
- :MissingInScheme makes skos:inScheme mandatory on a concept. SKOS does not require it; this
  package does, because a concept outside every scheme is unreachable from any tree.

Verified: both copies of the ontology parse - 96 triples bundled, 106 in the Apps repo, which
already differed - all seven views are attached to a property so the injection can find them, both
constraints are on skos:Concept, and the three new queries parse under Jena qparse.

Noted and deliberately not changed here: :SelectConceptsInScheme filters prefLabel with a
hardcoded langMatches(..., "en"), which contradicts the platform's rule that the reader's
Accept-Language is the only language preference. It predates this change and is not the taxonomy
editor's to fix in passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
ldh:tree-children-query put the type requirement in the same GRAPH block as the relation:

    GRAPH ?childGraph { { ?child P $this } UNION { $this C ?child } ?child a ?Type }

Every document is its own named graph, so that only holds when the child asserts the link. When
the parent asserts it - a scheme's skos:hasTopConcept, a concept's skos:narrower - the link is in
the parent's graph while the child's rdf:type is in the child's, and one GRAPH variable cannot
bind to both. The inverse branch matched nothing at all.

The document tree never showed this, which is why it survived a 23-assertion Playwright pass:
sioc:has_parent and sioc:has_container are both asserted on the child, so link and type are always
in the same graph. The defect is invisible until a relation is read from the other end, which is
exactly what the taxonomy editor needs and what the inverse parameter was added for.

Measured rather than reasoned, against a fixture built for it - a scheme with one top concept
linked from the concept and one linked from the scheme. Before: one row. After, with the type
requirement in its own graph, both. And expanding a concept now returns all three children where
the relation is split across directions - two asserting skos:broader on themselves, one linked by
skos:narrower from the parent.

The document tree is unaffected by construction and confirmed by re-running the drawer suite:
23/23, with the child count rising 9 to 10 because the fixture container is itself now a document
in the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
…mposed SEF is served again.

Two independent faults kept a locally bundled package from reaching either renderer, and both were
invisible until measured on a running container.

The Context "aliases" attribute does not exist after Tomcat 7. Tomcat 10 logs "failed to set
property [aliases]" as a warning and carries on, so /static/xsl/sef/ 404'd with nothing else to show
for it and every composed SEF was unreachable however well it compiled. The supported mechanism is a
<Resources><PostResources> set with className DirResourceSet, base and webAppMount, and POST is the
right half of the pair: it is consulted only when the WAR itself has no such resource, so a mounted
directory can never shadow anything the platform ships. Deleted before being inserted, because a
restart reuses the container filesystem and runs the edit again - two aliases attributes produced an
invalid ROOT.xml and the whole webapp stopped deploying. It stays in entrypoint.sh rather than
context.xsl because that transform has no argv budget left: xsltproc caps at 32 parameters and the
set is already full, which is why the system-like settings moved to CATALINA_OPTS.

The second fault is that a bundled package's ac:stylesheet is a URI nothing dereferences. It is
mapped to a classpath file through the prefix-mapping graph repository, so ClientStylesheetService
now consults the repository's isMapped/resolve before reaching for the network, and
LocalStylesheetResolver gains the same branch for the server-side compile. The stylesheet never
leaves the JVM, which is also why that URI need not resolve over HTTP at all - and it adds no SSRF
surface, since the compiler still receives module bytes rather than a URL to fetch.

That required reordering Application: the repository and resolver were constructed after the
stylesheet service that now depends on the repository, so the service received null and silently
fell through to the HTTP path. They move above it, unchanged otherwise.

.gitignore gains /sef, the host side of the bind mount docker-compose.yml already declares - each
composed SEF there is ~18.6 MB of build artefact and belongs in the repository no more than
/uploads does.
…can sit beside the content.

Three changes to the tree component, each one a defect measured on the running instance rather than
read off the code.

The emitter moves to the shared trunk. client/tree.xsl held both halves of the widget, so
ldh:TreeNode existed only in the client closure and any caller had to be declared use-when Saxon-JS
or fail the server compile with XTSE0650. That is fatal rather than cosmetic, because the client
keeps the server-rendered body on a direct page load and never re-renders it: a tree emitted only
client-side was absent from every bookmarked or shared URL until the reader navigated somewhere
else, and appeared on a click navigation alone. The markup half - ldh:TreeNode, ixsl-free, calling
only ldh:class-icon and ac:label - becomes xsl:tree.xsl, imported by the common.xsl trunk so both
products compile it. The handlers stay in client/tree.xsl. The two meet at the button the emitter
writes: the server paints it, the client binds it, which is what a direct load now exercises.

The children query is sent as written. It went through SPARQLBuilder twice - parsed from a string in
ldh:tree-children-query, re-serialised from XML in ldh:TreeChildrenFetch - and that round-trip
MERGED the two sibling GRAPH blocks into one, keeping only the last graph variable. Measured, a
correctly scoped query arrived at the endpoint as GRAPH ?childGraph { {..} UNION {..} ?child a
?Type }, putting the type requirement back inside the link's graph and reinstating exactly the defect
d777357 fixed: every child linked from the parent side disappeared, two top concepts returning one.
Wrapping the second block in a group graph pattern did not survive the round-trip either. Nothing
needed the parse - the query is generated here, never authored or edited - so the function now
returns the DESCRIBE whole and the fetch hands it over as the string it already is. Both round-trips
and ldh:wrap-describe go, along with the duplicate URI-building in ldh:doctree-descend: 91 lines out
of client/tree.xsl, 14 out of navigation.xsl, and the endpoint receives what the comment describes.
Verified against the running instance: both link directions resolve at both levels - a scheme's
hasTopConcept child and its topConceptOf child, a parent's narrower child and its broader children -
and the drawer's document tree passes 23/23 unchanged, including lazy load, sort, collapse and the
no-refetch-on-re-expand path.

ldh:ContentBody gains a declared slot. ldh:ContentColumn is a deep-skip mode applied as the content
body's first child, for a vocabulary with a shape worth navigating beside the content; empty, and
costing nothing, unless something fills it. It is a slot INSIDE the content body rather than a
wrapper around it, and the difference is measurable: .content-body carries the page gutter and the
content width, is addressed by rules as a direct child of .document-body, and is the containing block
the sticky create dock measures its full bleed against - so a column wrapped around it put the tree
flush against the viewport edge at x=0 and left the dock starting at x=212 and overflowing the right
edge, while a column emitted into it leaves every one of those rules matching. ldh.css turns the body
into two columns only when the slot is filled, reusing the design system's vendored ontology-editor
values, with the dock spanning both columns because it is the document's affordance and not the
content column's. Measured at 1440: columns 300px + 912px, tree inset to x=104 and sticky, content
starting right of it, dock x=0 w=1440 still sticky with its button inside the viewport, and no
horizontal scroll. app.css is untouched.
The package's stylesheet is bundled rather than fetched: package.ttl's ac:stylesheet becomes a
relative URI resolving against the registry location, and prefix-mapping.ttl maps it to the
classpath copy beside ns.ttl, so an instance with no route to raw.githubusercontent.com still
composes the package - and CI stops compiling whatever happens to be on a branch at the time.

What the stylesheet contains is the residue of what nothing existing could express, which is one
widget and one suppression. The tree fills ldh:ContentColumn, so the concept hierarchy is read
alongside the document's content instead of inside one statement list; the card, its stickiness and
the column width are the design system's own ontology-editor layout. It is anchored on the primary
topic of THIS document rather than on any SKOS resource in the graph, because a container listing
concept documents describes every child's topic too, and matching those hung a tree off every such
listing rooted at whichever concept came first - measured, and now zero columns on /taxonomies/.
One rule, not two: what separates a scheme from a concept is which relation the root expands over,
and that is a single boolean the class carries for the handlers to read at click time.

The emitter is deliberately not guarded by use-when, which is the whole point of moving ldh:TreeNode
into the shared trunk - a scheme's tree is now in the server's first paint. Only the two
ldh:TreeChildrenLoad rules stay Saxon-JS, since they reach the handlers; without that guard the
server compile fails and the platform falls back to a stylesheet carrying no package rules at all,
so the package would stop working entirely rather than degrade.

The hierarchy predicates stay suppressed from the generic property list, so the tree is not
duplicated as statement rows beside itself.

Verified on the running instance with the composed SEF: the column renders on a direct load and
survives a client-side navigation, on a scheme and on a concept; the disclosure the server painted
is bound by the client and loads children over both assertion directions; and the console stays
clean.
A view's Create button renders where the view carries ldh:container and a class can be inferred, and
the whole flow behind it - constructor, modal, PUT, link, refresh - has been shipped and working for
some time. It had never rendered once. A search of both repositories finds zero ldh:container values
on any view: the only occurrences were the ldh:ViewConstructor form shape and one absolute
https://localhost:4443/products/ in an Apps docs example. The Broader and Narrower concept blocks
carrying no Create button was one instance of a general absence.

The property asked the view for the one fact a view cannot hold. A package ontology is published at a
foreign base, so the documented relative form - ldh:container <concepts/> - resolves against
raw.githubusercontent.com, and relative IRIs are resolved by the parser, so even the authorial intent
"relative" is gone before the platform sees a value. Successive attempts to relocate it (onto the
class, onto a concept scheme, onto a scheme's document) each broke on a case the next one patched,
because taking the declaration as given and asking only where to put it is the wrong question.

A view is a SELECT and its rows are its solutions, so Create means: make a new solution appear in
this projection. That is satisfied by solving the pattern with the projected variable bound to a
fresh resource - $about is already substituted, the pattern IS the triples to insert, and the only
variable nothing supplies is the graph of the new resource's own description. A graph here is a
document, so that unbound graph variable IS the container question. The pattern cannot answer it
because the document does not exist yet, but the existing solutions can: their descriptions already
live in graphs that sit in containers. Measured before building anything - every view in both corpora
resolves to exactly one container: the four taxonomy views to /taxonomies/, and Northwind's orders
and products by construction, since orders.rq binds ?container explicitly and derives every document
and entity from it before throwing it away.

So the container is determined per view by a dedicated request, modelled on ldh:ResultCount and for
the same reason: the rendered rows are one page, and inferring from them would let the destination
depend on which page the reader was looking at. LIMIT, OFFSET and ORDER BY come off first through the
existing rewrite modes, then ldh:container-select projects the container alone. The two joins need no
string operations on URIs - the graph a solution is described in is its document, and that document's
sioc:has_container is a triple the platform asserts on every one of them. Deliberately not via
foaf:primaryTopic, because a document's fragment entities link back to it differently from one
vocabulary to the next: Northwind's #delivery uses foaf:page. DISTINCT is load-bearing rather than
tidy, since without it two solutions in one container return two rows and read as ambiguity, and
LIMIT 2 is the whole answer - one row means determined, two mean ambiguous, and no row beyond the
second carries information. There is deliberately no GROUP BY and no COUNT: the query is unable to
say which container holds the most rows, because placement decided by counting is placement nobody
stated, so anyone adding a majority rule has to add the aggregate first and notice what they are
changing.

Exactly one container renders the button. None - the empty projection, which is the bootstrap case
and the one that matters most, since the first concept in a new scheme cannot be created by
generalising from its siblings - renders nothing, and so does more than one. A heterogeneous
projection is the honest case for refusing: "add a row" is ill-defined when the rows are of different
kinds, and the class inference would be ambiguous too. The host document's own container is not a
fallback either, since that keys placement on where the view is rendered rather than on what is being
created.

The ACL probe moves as a consequence. It used to HEAD the declared container before the results were
fetched; a determined container is not known until the request resolves, so the probe follows it and
the button appears a moment after the toolbar rather than with it. ldh:set-container-acl-modes is
reused verbatim for the parse. Rendering optimistically and letting the PUT 403 would be worse than
appearing late.

ldh:container therefore goes entirely - the property, its ldh:ViewConstructor input, the ontology
query's OPTIONAL and binding, the data-container stamp, and the RDFa fallback read. That fallback
could never have worked: only @Data-container had one, while @data-for-class and @data-acl-modes were
written solely by ldh:ontology-view-insert, so a hand-authored view block failed the other two
conjuncts regardless. ldh:ontology-view-render-thunk loses its branch and its HEAD, and
@Data-container keeps its meaning for the click handler and both submit handlers, which are untouched.

Measured on the running instance rather than read off the code. make sef compiles clean; with the
composed SEF rebuilt and both Varnish layers restarted, /taxonomies/coffee/ issues two determination
queries whose text is the view's own pattern plus the two joins, with SELECT DISTINCT, LIMIT 2, no
ORDER BY, no GROUP BY, no COUNT and no primaryTopic, each returning exactly one row
(https://localhost:4443/taxonomies/), followed by a HEAD on that container and two Create buttons
carrying it. /taxonomies/espresso/ proves both halves of the rule on one page: the block whose
projection is empty gets no button while the block with one row gets one. An anonymous session gets
none. A projection shaped like the rewrite but spanning containers returns two rows, which is the
count the gate refuses. The console stays clean throughout.

Two notes for whoever debugs this next. xsl:message output stops after the first few messages inside
this promise chain - the requests were firing while the later messages never reached the console,
which cost two build cycles chasing a failure that did not exist. And a tunnel parameter declared
without a default downstream is a required one: the rewrite failed outright, and silently, until
every helper variable name was derived once and passed on explicitly.
SKOS declares skos:broader/skos:narrower and skos:hasTopConcept/skos:topConceptOf as inverse pairs
and leaves the choice to the modeller, so a parent may point down or a child may point up and both
are correct. ldh:tree-children-query has unioned the two directions since the tree was extracted;
:SelectNarrowerConcepts and :SelectBroaderConcepts read one each, so a view and the tree on the same
page answered the same question differently.

Measured on the fixture, whose seven hierarchy triples deliberately use both conventions: Hot drinks'
Narrower block showed 1 child where the tree showed 3, because Coffee and Tea assert skos:broader
upwards while only Juice is pointed at from above; and Juice's Broader block showed nothing at all
while the tree nested it under Hot drinks. :SelectTopConcepts already read both directions, and the
remaining queries have no inverse to add - skos:inScheme and skos:member are not halves of a pair -
so this is the whole of it.

The union stays INSIDE the link's GRAPH rather than being flattened into a sibling block, which is
not a stylistic choice: SPARQLBuilder's parse/serialise round-trip merges sibling GRAPH blocks into
one, keeping the last graph variable, and would then scope the child's label to the document holding
the link - the same defect that cost the tree every parent-asserted child twice already. Nested GRAPH
blocks survive the round-trip, as the captured container query shows.

Verified on the running instance: Hot drinks' Narrower block is 3 and Juice's Broader block is 1,
both matching the tree, while Cold drinks stays 0 both ways because its only hierarchy triple is the
scheme's skos:hasTopConcept and nothing claims it as a parent - that page was reporting the data
correctly all along. Console clean.
Rooting the tree at the document's own primary topic made it useless as a taxonomy tree:
/taxonomies/juice/ rendered a single node, "Juice", with a chevron. What a taxonomy tree is for is
placing a concept among the others, so it has to start where the taxonomy starts, regardless of which
concept is open.

On a scheme document the topic IS the root. On a concept document the scheme is named by
skos:inScheme, which is already in the rendered RDF, so the URI costs nothing; its label comes from
the platform's $object-metadata - the same label-only CONSTRUCT that already renders "Drinks" as the
link text in the property list beside the tree - so the root node costs no request either. That
tunnel parameter does reach this mode: it is set once per render, server-side in layout.xsl and per
pane in client.xsl, and flows down through ldh:ContentBody. A concept in no scheme falls back to
itself, so an unplaced concept still gets a tree rather than an empty card.

The scheme-root class goes with it. It existed to tell the root's disclosure to expand over
hasTopConcept/topConceptOf rather than broader/narrower, but with the root now always a scheme it
distinguished nothing: depth alone says which relation applies, and count(ancestor::li) already
carried that.

Verified on the running instance across every shape the fixture has - a deep concept, a leaf, a top
concept, the scheme itself, and an orphan - all five root at Drinks, link to the scheme resource and
offer a disclosure, with no scheme-root token anywhere. Expanding that disclosure returns both top
concepts, one asserted as hasTopConcept from the scheme and the other as topConceptOf from the
concept, so the root's own relation reads both directions like every other level. Console clean.

What this does NOT yet do is reveal where the open concept sits: the tree starts collapsed at the
scheme, so on /taxonomies/espresso/ you get Drinks and have to walk down. That reveal is the second
half and is deliberately not in this commit - see the plan for why it needs a shared renderer with
separate server and client fetch paths.
ldh:ContentColumn was general in every respect except the one that decided whether it did anything:
the two-column layout keyed on `.content-body:has(> .ldh-onto-list)`, and .ldh-onto-list belongs to
the design system's Ontology editor section. So a general extension point fired only for one
component's class - every package would have had to borrow that card to get a column, and an aside
that is not a card (a bare list, a full-height panel) got no layout at all. A slot that only lays out
when its content happens to carry one particular class is not an extension point, it is that
component's private arrangement.

ldh:ContentBody now owns the wrapper. The slot's output is materialised first, so an unfilled slot
emits nothing and the content body is untouched, and a filled one is wrapped in .ldh-content-aside,
which is what the grid keys on. A filler therefore needs no layout class of its own and may look like
anything; the SKOS package keeps .ldh-onto-list inside the wrapper for the card-and-sticky treatment,
which is now a choice rather than a requirement. The grid still applies to .content-body itself
rather than an outer wrapper, for the reasons already recorded there: the gutter, the content width
and the create dock's containing block all depend on .content-body staying a direct child of
.document-body.

Verified on the running instance: on /taxonomies/espresso/ the wrapper is present with the package's
card nested inside it, the body computes as a grid at 300px + 912px, the aside is inset to x=104
rather than flush to the viewport, stickiness survives the extra nesting level, the create dock is
still full-bleed at x=0 w=1440, and there is no horizontal scroll. On /taxonomies/ - a container,
so nothing fills the slot - no wrapper is emitted and the body stays a flex column exactly as before.

The CSS deliberately stays in ldh.css rather than going upstream to the design system: the rule is
written against `.document-body > .content-body`, which is application document markup the kit does
not model, so moving it would put LDH selectors into the DS. What was missing upstream was the
documented composition, and that went instead: a new preview card, Layout / "Content aside", showing
a filled slot with a card treatment, a filled slot with a bare list, an unfilled slot, and the narrow
collapse - recording that the wrapper is the platform's and never the filler's, that the aside owns
position and not appearance, and that the ontology editor's .ldh-onto-grid is the same arrangement
with the same values. Additive only; no existing DS file touched, and app.css stays byte-identical to
the hosted copy.
The tree appeared in every document mode, because ldh:ContentColumn was applied without regard to
which one was active. In ContentMode that put it beside an authoring surface for the document's own
content blocks - on a concept with no blocks, a tree card alone in an otherwise empty body, which is
what prompted this. The Map, Chart and Graph canvases are worse: ldh.css zeroes the content body's
padding and drops its max-width for them so the canvas can take the whole body, and a two-column grid
fights that directly.

The mode is now passed into the slot rather than filtered at it. Which modes a column belongs in is
the filler's judgement, not the platform's: a taxonomy tree is a reading affordance, while an
editor's class list might well want to stay visible while its document's content is being authored.
So the platform hands over ac:mode()'s value and the SKOS package restricts itself to ReadMode.

The test can be positive because ac:mode() always resolves to a concrete URI - ?mode= when given,
else ContentMode for a document that has rdf:_N content blocks, else ReadMode - so there is no empty
case to tolerate. One consequence worth knowing: a concept document that carries content blocks
defaults to ContentMode and therefore shows no tree until the reader switches to Read, which is the
same rule applied consistently rather than a special case.

Verified on the running instance across all six modes of /taxonomies/hot-drinks/: ReadMode explicit
and ReadMode by default both render the wrapper and the tree with the body computing as a grid, while
ContentMode, MapMode, ChartMode and GraphMode render neither and leave the body flex or block as each
mode intends. Console clean.
…annot drop it.

The concept tree rendered every row flush left while nesting the markup correctly - six rows at
padding-left 0 with ancestor LI counts of 0, 1, 2, 1, 2, 2. The depth was being computed correctly
all along: instrumenting ldh:tree-children-response showed count(ancestor::li) returning 1 for the
first level and 2 for the second, with the container connected to the document and parented by its LI.
It simply never arrived.

ldh:TreeNode took depth as a plain parameter, and a domain narrows that template by matching and
delegating with xsl:next-match - which forwards only the parameters it names. The SKOS package's
override names expandable and nothing else, so depth fell back to its default of 0 for every node
below the root. The drawer's own override (navigation.xsl, sioc:has_parent priority 1) has exactly the
same shape and therefore the same latent defect, which only escaped notice because a container's
children are usually dh:Items that do not match it, so the generic template kept the parameter.

This is the third time xsl:next-match's parameter rule has cost a defect here - ldh:ContentBody's
three parameters had to be re-declared and forwarded for the same reason - so the fix is at the source
rather than in each override: depth becomes a tunnel parameter, which crosses next-match without being
named. expandable stays a plain parameter, and the difference is the point: the indent ramp is ambient
state belonging to the level being rendered, while whether a node opens is a per-node decision the
domain makes and must therefore state. Only two call sites apply ldh:TreeNode - the recursive one,
which now tunnels, and the package's root, which passes nothing and correctly gets 0 - and no other
parameter named depth exists in the tree, so there is no tunnel-name collision.

Measured on the running instance: the concept tree now reads 0, 14, 14, 28, 28, 28 pixels of
padding-left against ancestor LI counts of 0, 1, 1, 2, 2, 2. The drawer's document tree passes 23/23
unchanged, and its container rows now indent for the first time as a side effect.
…sitory.

21a3c41 gave LocalStylesheetResolver a classpath branch for bundled package stylesheets, and it
called getSystem().getRepository().isMapped(...) unguarded. The repository is optional - an
application can be constructed without one - so every resolution that is not a bundled package threw
a NullPointerException before reaching its own branch. The resolver's own tests are exactly such
applications and are the ones that caught it: testReturnsNullForFileScheme (a file: URI) and
testDelegatesUnknownOriginToRemoteFetch (an unknown origin), neither of which is a mapped package and
neither of which should have consulted a repository at all.

ClientStylesheetService.expandEntities already guarded the same call the same way in the same commit,
so this was one of a pair and only one half got the check. Read through a local now, so the guard and
the two uses cannot drift apart again.

mvn test: 181 tests, 0 failures, 0 errors, BUILD SUCCESS - including the 4 in LocalStylesheetResolverTest
that were 2 errors before. The riot "more than 64000 entity expansions" line in that output is
BillionLaughsTest asserting the limit holds, not a failure.
skos:prefLabel rendered "Juice" with an en tag and a language field, while a value added with Add
came out as xsd:string with no language control at all - so a label authored through the UI landed
untagged beside the tagged ones already in the data.

The two paths never shared a source. An existing value's control is built from the DATA: its text()
becomes the ol input and its @xml:lang the ll input. A value added with Add is built from the
CONSTRUCTOR: ldh:render-add-value renders ac:FormControl over the constructor's own property element,
so [ a xsd:string ] was the whole reason the new field had a datatype and no language. There was no
way in the vocabulary to say otherwise - rdf:langString appeared in no constructor anywhere, and only
inside two object-metadata FILTERs in the stylesheets.

So the constructor now declares skos:prefLabel, skos:altLabel and skos:definition as
[ a rdf:langString ]. Not merely to satisfy the form: a SKOS label IS natural language, and SKOS's
own integrity rule - at most one prefLabel per language - is not expressible without a tag.

Three stylesheet changes make that declaration mean something:

Web-Client gained the langString control (its own commit), and this file SHADOWS it. That is
deliberate, and measured rather than assumed: LDH imports Web-Client, so any LDH template matching
this node wins on import precedence whatever priority the Client's rule carries. Excluding
rdf:langString from LDH's non-XSD resource lookup did not reach the Client's rule - it handed the
node to LDH's generic bnode control, which rendered an ob (object blank node) field. The xsd:*
template above shadows the Client's the same way and for the same reason.

The non-XSD resource lookup also stops claiming rdf:langString outright. It is a literal datatype
that merely happens to live outside the xsd: namespace, and treating it as a class is what turned a
text property into a URI picker - which on save wrote skos:altLabel <...#id03f1366d-...> into the
fixture rather than a literal. That is the same defect seen from the write side.

And a stored literal carrying a language tag now annotates as rdf:langString rather than "Literal".
RDF 1.1 gives every tagged literal that datatype, so "Literal" said less than the data does - and
said something different from the field the same property gets before a value exists.

Verified on the running instance, controls and round trip both. On /taxonomies/juice/ all three
declared properties render [pu, ol, ll] with an rdf:langString tag, no lt input and no combobox,
while broader, narrower, related, inScheme and type correctly stay comboboxes; a row added with Add
does the same. Driving it to completion - select altLabel, Add, type Sula, set the language to lv,
Save - stores skos:altLabel "Sula"@LV with datatype rdf:langString, leaving prefLabel "Juice"@en
untouched. Console clean throughout, and the fixture is back to its original three triples.

Not changed: ldh:TitleConstructor and :DescriptionConstructor declare dct:title and dct:description
as [ a xsd:string ] and have the same untagged-on-add behaviour. That switch would affect every
document title in an instance, so it wants its own decision rather than riding along here.
…oncept you are reading.

A tree rooted at the document's own topic showed a single node with a chevron - the concept you were already looking at, with no context - so it now roots at the scheme, on a scheme document the topic itself and on a concept document the skos:inScheme object, whose label is already in $object-metadata and costs no request. Rooting it there means the tree opens where you are rather than where you came from, which is what the reveal below does.

The reveal is shaped after the document hierarchy's descent rather than after a closure query, and the difference is the whole design. ldh:doctree-descend issues one constant-size children query per level and decides where to go next from what is already in the DOM: starts-with($target-uri, $current-href), which works only because the document hierarchy is path-nested, so a target's ancestors are exactly its lexical prefixes. Concept URIs are not nested - /taxonomies/espresso/#this sits under no prefix of /taxonomies/coffee/#this - so the path is asked for instead of derived, and it is asked for the same way: one hop, one request, constant query text, no depth bound, stopping when a hop returns nothing new.

The first attempt asked for it as a bounded transitive closure, a UNION of one explicit chain per depth, and it was wrong on both counts that matter. It grew quadratically in text - 4.6kB at six hops, which percent-encodes past Tomcat's header limit and came back as a 400, Request header is too large - and it had to name a maximum depth, which a taxonomy has no reason to respect. Per-hop GRAPH scoping is not the part that was negotiable: a property path cannot leave its GRAPH and with one concept per document every hop crosses one, so skos:broader* reaches the first ancestor and stops because that ancestor's own parent link lives in another document, and the default graph is empty, leaving an unscoped path nowhere to run.

The frontier is a set, so one request covers a whole level however wide it branches, and a concept with two broader concepts puts both in the next VALUES block. That is also the cycle guard - only parents not already seen enter the next frontier, so A broader B with B broader A, malformed but legal SKOS, runs out of new nodes and terminates, where a depth bound alone would have walked the two of them as deep as the bound allowed. The climb stops itself at the top concept, whose link to the scheme is topConceptOf rather than broader, and that is the right place to stop because the scheme is the tree's root node and the descent starts from it.

Descending expands every child in the ancestor set, not the first, which is how a polyhierarchical concept comes out under each of its parents the way Skosmos renders it; the activation pass needed no special case because it already iterated every row whose href matches. No test on the ancestor set being non-empty guards the expansion: it is legitimately empty when the concept being read is a top concept, and that is the one level the root must still open to reveal it.

Continuations are fired as ixsl:promise instructions rather than returned, which is what lets one level fan out into several branches - a function returns a single map, so N branches cannot be N return values. Returning both a branch's chain and the context is exactly what the first version did, and it threw a cardinality error on every page load while the tree still rendered correctly, because the DOM writes had already landed before the throw. Two further errors of the same silent kind: map:merge defaults to duplicates=use-first, so every merge that re-keyed an existing entry was a no-op and the request stayed the first hop's query forever, looping 768 requests deep - ldh:rethread-response passes use-last for this reason - and ldh:TreeNode's depth had to become tunnelled upstream because xsl:next-match forwards only the parameters it names.

No new platform hook was needed for the trigger: client.xsl applies ldh:RenderRow to every direct child of .content-body on a direct load and on a client-side navigation alike, and the ldh:ContentColumn slot's wrapper is such a child, so one implementation serves both paths. The mode's contract is a factory rather than work, because it is evaluated in a non-updating variable binding and the factories are invoked inside ixsl:promise, which is also what gives ixsl:http-request the active promise it requires.

The tree renders in ReadMode only. It is a navigation aid for reading a taxonomy, and a document laid out in ContentMode or being edited has its own column claims.

Measured on the running instance rather than read off the code. Five page shapes, each with a dialog listener registered - a SaxonJS runtime error surfaces as an alert, which Playwright silently dismisses when nothing listens, and that is how the cardinality error hid through a full passing run: espresso reveals Drinks > Hot drinks > Coffee > Espresso with the off-path siblings present and collapsed, 3 hops up and 3 levels down; hot-drinks reveals on an empty ancestor set, 1 hop; juice reveals through a link the child does not assert, 2 hops; a scheme document roots at itself and climbs not at all; and with a second parent added to Latte from the opposite end from Coffee's, it renders under both parents and is marked active in each, both parents arriving in one hop. Zero dialogs, zero console errors, no 4xx, no leftover loading rows, every appended node in the XHTML namespace, and collapse-then-re-expand refetches nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
…nd a failure during it stops being silent.

ldh:doctree-descend opens the path down to the document being read, one level per request, and it loaded each level with its own copy of ldh:TreeChildrenFetch. The copy was worse in three ways, all of them invisible until something went wrong: a bare <ul/> instead of the loading row, so a slow level looked like an empty container rather than one still arriving; no ldh:busy-cursor() and no ixsl:finally(ldh:reset-cursor#0), so the pointer never showed the work; and no on-failure, so a failed request during pre-expansion produced nothing at all - no row, no message, no console error, just a branch that stayed shut.

It now opens and fetches with the widget's own templates, which makes pre-expanding a level the same act as clicking it. That needed two things extracted and one added. The click handler's two halves become ldh:TreeNodeDisclose and ldh:TreeChildrenPlaceholder, kept separate because the handler needs them separately: it flips unconditionally, since a node whose children are already in the DOM re-shows off aria-expanded alone, and appends a placeholder only when there is nothing to re-show. Extracting them is also what keeps this change from adding a third copy of that markup. And ldh:TreeChildrenFetch gains a $then continuation, spliced after ldh:tree-children-response, so the descent carries on where the children landed instead of repeating the request that put them there; ldh:tree-children-response already returned its context, so it composes. The splice goes through a function because a promise chain cannot be assembled conditionally - ldh:tree-children-continue applies the continuation when one was given and passes the context through when none was.

A caller that passes no continuation gets the chain it got before, so the drawer's click path and the SKOS package's are unchanged.

Measured on the running instance. The drawer's descent to a document three levels down now shows the loading row and the busy cursor while it walks - both sampled mid-descent with the children response held back, and neither was observable before, the bare <ul/> having nothing to show - and ends with 20 rows across three indent levels, the open document marked active, the cursor back to default and no leftover loading rows. The refactored click path expands the concept tree's root to its two top concepts at --depth 1, shows the loading row and the busy cursor, leaves none behind, and collapse-then-re-expand returns the same two children with no refetch. The reveal suite still passes on all five page shapes. Zero dialogs, zero console errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
ui-tests/ is ft-ui-tests' directory (#381), and its .gitignore carries these three lines. They are
needed on any branch the suite has been run from, though: switching away leaves the ignorable part
of the tree behind - node_modules, out and test-results - because git only removes the files it
tracks. That left `?? ui-tests/` standing in the status here, one `git add -A` away from putting
five npm packages and an HTML report into a commit.

Same three lines, in the same position as on ft-ui-tests, so the merge sees one addition rather
than a conflict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
Eight of the last eighty commits report Playwright verification in their message
bodies - "20/20 against /admin-units/36/", "8/8 against the running stack", "27
checks green as owner", "the accumulated Playwright batteries run green" - and not
one Playwright file was ever committed. Every script lived in a per-session scratch
directory (14 of them, ~75 files) and was deleted with it, so the batteries never
accumulated: each session rewrote the client-cert context, the console/pageerror/4xx
collectors, a check() tally and a blunt waitForTimeout(4000), then rediscovered the
same two traps - /static/ is Varnish-cached per encoding so both layers need
restarting, and the admin origin needs its own certificate entry or the page-load
XHR fails against half the page. The passphrase got hardcoded inline each time, in
files nobody reviewed.

This is the harness, and only the harness: the parts every one of those scripts
rewrote, written once. lib/stack.mjs reads the passphrase from
secrets/owner_cert_password.txt and hands out certificates for both origins, keyed
off the same environment variable names http-tests/run.sh exports so the two suites
point at one instance from one set of exports. lib/console.mjs extends test so that
a console error, a pageerror, an alert() or a 4xx fails the test that provoked it,
because a broken Saxon-JS template reports itself that way and then renders nothing
- a spec asserting only what it came to assert would call such a page fine.
Preexisting noise is excluded by pattern with its reason attached, the way 3305741
excluded the 502 on the unlimited DESCRIBE, rather than by loosening the assertion
for everything. lib/settle.mjs replaces the sleep with block-count stability, which
is what the ixsl:all view injection actually needs since it appends one chain per
view and the count rises in steps.

The preflight is the part that pays for itself first. It refuses to run against a
stack that would answer misleadingly, and each refusal names the command that fixes
it: the base URL not answering means nginx is stopped, no ldh on PATH means the
fixtures cannot be built, and - the one nothing currently catches - the served SEF
not matching target/ROOT means the browser would execute stale XSLT and the suite
would measure the wrong build. That check is local-only by construction rather than
by branching on CI: target/ROOT exists exactly when docker-compose.override.yml is
bind-mounting the working tree over the image, so an image-baked SEF finds no local
file and the check stands down.

Fixtures are seeded with ldh, the same tool http-tests builds its fixtures with, so
a broken create path fails in setup instead of halfway through a spec and the suite
behaves identically against a virgin CI instance and a lived-in dev one. ui-fixtures/
holds 25 items across three repeating facet values (a facet whose values are all
distinct is as dead a control as one whose values are all the same), a SELECT, a
view, a bar chart, a prose block and an Object embed of the first item. Every URI is
a pure function of its index: globalSetup runs in the main process and specs run in
workers, so a URI a spec needs has to be derivable, not remembered. The suite owns
one container and deletes it, deliberately not snapshotting and restoring the whole
dataset the way run.sh does - right for a suite that owns the instance, wrong for
one sharing a dev stack with the person running it.

Two projects, owner and anonymous, run the same specs over the authorization axis,
so "no edit pencil for anonymous" is a test rather than a separate script. Serial by
construction, since several specs write to one shared instance and parallel workers
would race on the container.

specs/calibration.spec.mjs asserts nothing and is transitional. It measures what the
instance actually serves - the anatomy counts, which of the retired patterns still
appear and where, what anonymous gets - so that the contract in lib/ds.mjs is written
against the running build rather than against the design-system READMEs alone. Two
questions are already known to need it: whether anonymous browsing returns a page or
the 403 that http-tests.yml's readiness probe waits for, and how the div+class rule
squares with main.ldh-block-body and nav:has(> .ldh-tabs), which the design system
and app.css respectively require. Its findings get reported, not silently absorbed.

Not yet here, in plan order: lib/ds.mjs and the conformance sweep, the behaviour
specs for blocks, views, dialogs, chrome, forms and modes, and the CI job - which
wants http-tests.yml's stack bring-up extracted to a composite action first, landed
and verified green on its own before anything depends on it.

make ui-tests builds the CLI and drives the stack; make ui-tests-install is separate
so the common path does not pay for a dependency resolution it almost never needs.
Both are in .PHONY, which is what keeps make from reading the ui-tests directory as
a satisfied target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EptZ6w7rd883q8N4CMorR
… page it never saw.

Two specs, ported from the scratch scripts that measured the concept tree and the drawer's
descent while they were being built. Both of those changes were verified by scripts that were
deleted with their session, which is the thing this suite exists to stop.

specs/concept-tree.spec.mjs covers the taxonomy package's tree: that it roots at the concept
scheme rather than at the concept being read, that it opens the path down to that concept and
marks it, that a top concept reveals on an ancestor set which is legitimately empty (its link to
the scheme is topConceptOf, not broader), that a concept reached only through the link its parent
asserts reveals too, that a scheme document roots at itself and does not climb, that a concept
with two parents renders under each and is active in both, that a revealed level stays in the DOM
and is never refetched, that the appended nodes are in the XHTML namespace, and that none of it
renders outside ReadMode. The request counts are asserted, not just the markup, because the walk's
whole design is one hop per level with no depth bound - a spec that only checked the resulting tree
would pass just as happily against the bounded closure this replaced.

specs/document-tree.spec.mjs covers the drawer, and exists mostly for the descent, which had no
coverage and the most room to fail quietly. It asserts the path is pre-expanded and the open
document marked, that every level's --depth matches its nesting (the parameter is tunnelled
precisely because an override dropped it and rendered the tree flat), that the loading row and the
busy cursor show while the descent walks, and that a click-expanded node keeps its children when
collapsed. The loading row and the cursor are there because the descent used to hand-roll the fetch
and had neither, along with no failure handler - so the two specs that hold the children response
back are asserting the states that omission made unobservable.

lib/taxonomy.mjs seeds the taxonomy, and is separate from the generic fixtures because the tree
belongs to the package rather than to the platform: it imports the package when the dataspace does
not have it and removes it again afterwards, since that is the one seeding step that changes how
every document renders. The hierarchy asserts each level from a different end, because SKOS lets
either end carry the link and the tree has to follow both. Two things it has to do that the API
does not: write foaf:primaryTopic itself, since ldh create writes none and the tree reads the scheme
off the topic, and write skos:inScheme in the same request as the concept, since the package
constrains it and refuses the write otherwise with a 422.

Four harness changes came out of making them run, each of which was a way for a spec to pass
without having seen the page:

The shared fixture now seeds the welcome-message cookie. The modal is a centred backdrop shown
once per browser to an authenticated agent, and it intercepts every click a spec makes until it is
dismissed - so the specs that only asserted passed and the two that clicked did not. Seeding the
cookie is what a returning reader has, and it is race-free where dismissing the modal is not,
since the modal appears when Saxon-JS gets to it and a spec that clicks early loses.

The drawer is opened by a move to clientX exactly 0. Its handler tests `$x = 0` rather than a
threshold, so the scripts this was ported from moved to x=2, never opened it, and read its subtree
out of the hidden DOM - the assertions passed on markup no reader could see. openDrawer() now
asserts toBeVisible() before anything drives it, which is what makes that failure loud.

The busy cursor is recorded rather than sampled. It is an inline style set for the duration of each
fetch and reset after it, so an expect.poll() races the gaps between levels: it read "default" ten
seconds running against a descent that was demonstrably showing "progress". A MutationObserver
installed with addInitScript collects every transition.

And ldh() takes stdin, because `ldh patch` reads its SPARQL update that way, which is how a
fixture writes the triples no create command expresses.

Measured against the running instance: 17 passed as owner, the calibration probes included, and
all 14 tree specs skip as anonymous with their reason. One locator finding is recorded in the
README rather than worked around silently - Playwright resolves a relative :has(> ...) inside a
'>'-prefixed chained locator to nothing, measured as 0 against the 25 that '> ul > li' finds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
docker-compose.yml bind-mounts ./sef over the container's SEF directory, so running the stack from
this branch drops two 18MB compiled stylesheets into the working tree - the per-dataspace
executables the application composes and caches at runtime. They showed as `?? sef/` here because
the line that ignores them was added on ft-taxonomy-editor (21a3c41, with the change that made
the composed SEF a real file again) and has not reached develop, which this branch is cut from.

Nothing about it is specific to that branch: the mount is in docker-compose.yml and applies
wherever the stack runs. Placed where the other branch placed it, so the merge sees one addition
rather than a conflict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
namedgraph and others added 5 commits September 12, 2026 22:36
…fter it.

Pressing Create in a view PUTs the new document, and the graph store builds the containing dh:Item
around it: rdf:type, sioc:has_container, dct:created, dct:creator, acl:owner. Two triples it cannot
supply were nobody's: the document had no foaf:primaryTopic and no dct:title, so it rendered an empty
body - ldh:ContentList descends from the document resource, which mentioned nothing - and the
list/table/grid renderers, which pair a document with its topic through
key('resources', foaf:primaryTopic/@Rdf:resource), had no pair to make and showed the document and
its topic as two rows, one unlabelled and one inert.

Both now ship inside the PUT body, as json:map triples folded in by ldh:triples-to-descriptions -
the mechanism an inverse view's linking triple already used, so they reach the request without
reaching the $resources re-rendered on a constraint violation.

The two submit handlers collapse into one. Once the forward flow also has to build a body, both need
the identical preamble - preventDefault, ldh:FormPreSubmit, ldh:parse-rdf-post - and the only
difference left is whether the linking triple is there, so it becomes an optional element rather than
a second template.

The title is the topic's own label, read through the ac:label MODE rather than the ac:label()
function: the function upper-cases the first letter for display, which is right on screen and wrong
in stored data - a concept called "iPhone accessories" would have been titled "IPhone accessories".
The mode is applied to a copy of the topic's Description carrying its properties but not its subject
attribute, which leaves the vocabulary templates as the only ones that can match: the URI-derived
fallbacks at the bottom of that chain answer for any URI resource, and an unlabelled topic would
otherwise have been titled after its own '#id...' fragment. Untagged, as document titles are
throughout the platform - AccessRequest.java and every cli/ addProperty(DCTerms.title, ...) store
them so, even where the topic's own label carries a language.

Unit-tested against the compiled client.xsl under SaxonJS (the browser's engine, so the label chain
is the real one): skos:prefLabel "iPhone accessories"@en titles the document exactly that; a
geo:Point carrying only geo:lat yields no title at all, where the same Description with its subject
attribute would have yielded "id9c81de"; and the assembled body carries the instance Description with
the inverse link triple folded in, plus the document's own with foaf:primaryTopic and dct:title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
…d in.

Every hierarchy and membership view in the SKOS package joined a prefLabel and filtered it to English
or untagged. That filter was never a selector - the label is not projected, it is the ORDER BY key -
so all it did was drop concepts labelled in some other language.

Which is every concept created through the UI here: the form's language control follows the browser,
so a concept authored in a Lithuanian session gets "..."@lt and nothing else. Measured on the fixture:
creating a broader concept from Juice's Broader block wrote <juice/#this> skos:broader <new> exactly
as it should, and the block that created it went on showing Cold drinks alone. The new concept's own
Narrower block did show Juice - because Juice also carries "Juice"@en and passed the same filter. A
taxonomy that hides what you just added to it, while the triple sits in the graph.

Which language a reader wants is decided at render time, where the label chain already picks per
value against the accepted languages. A stored query cannot know it, and hard-coding one is how this
happened.

The join stays: it is the sort key, and a concept with no prefLabel at all has none - the package
makes one mandatory anyway (:MissingPrefLabel). What is given up is the order among a multi-label
concept's own labels, now the engine's choice, which is what an unprojected sort key means.

Verified against the endpoint, running the query texts extracted from this file with $about
substituted: Juice's Broader goes from Cold drinks to Cold drinks and the new concept; Cold drinks'
Narrower still returns Juice once, not twice, despite its two prefLabels; the Drinks scheme lists 9
concepts where it listed 8, still alphabetically. All seven queries parse under qparse and the file
passes riot --validate.

Not changed: http-tests/imports/rdf-test.rq picks an English prefLabel as the document title when
importing the UNESCO thesaurus. That one is choosing one title out of many rather than deciding
membership, and it sits inside an OPTIONAL, so it drops nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
A workflow of its own rather than a second job in HTTP-tests. The two share a prelude -
image build, CLI, certificates - that measures 3.5 of the HTTP job's 12 minutes, and they
pay it in parallel, so folding them together would serialise ~19 minutes to save ~4 of
runner time. It would also couple a suite that snapshots and restores the whole dataset to
one that owns the instance and reseeds it, hand a browser job the versioning special-case
(a per-run GitHub branch, a token, an extra compose overlay, a system.trig append) it has
no use for, and leave one red X to say which of the two broke.

It reuses HTTP-tests' scaffolding instead of its own, so no new repository secrets: the
same server-cert-gen.sh, the same three password files from HTTP_TEST_*, the same compose
overlay and --env-file, the same wait-for-403. All three password files, not just the
owner's, because the base compose declares each as a secret and `up` fails outright on a
missing source. No service list on `up`, because the sef-compiler container has to be
running: importing a package recomposes the client stylesheet, and the suite's taxonomy
fixture imports one. admin.localhost resolves on the runners, which is what makes an
admin-origin spec possible at all - http-tests/run.sh already exports exactly that URL.

The preflight gains a check, and it is the one that makes a cold instance work. A package's
rules reach the browser only through a SEF composed from the platform's modules and the
package's, compiled asynchronously and best-effort: until it is published every page is
served the STOCK stylesheet, which carries none of the package's rules - no concept tree,
no hierarchy column. A dev stack composed one long ago and the wait returns in
milliseconds; CI imports the package for the first time against a freshly built image, and
every spec after it would race the compile and fail as though the feature were missing.

That key covers the platform build as well as the import set, and is digested when the
application starts - which cost this session an hour of a green run that proved nothing.
A `make sef` plus a Varnish restart leaves the application still publishing the stylesheet
it started with, so a reverted stylesheet went on behaving like the fixed one, and a
regression check passed. Restarting linkeddatahub is what invalidates it. Recorded in the
README next to the check.

The wait polls the taxonomy container rather than the root: a dataspace's ROOT document is
served the stock stylesheet even where its children get the composed one, so polling the
root would wait forever. That asymmetry is not this commit's to fix, but it is why the
target is what it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
Two fixes landed on the taxonomy branch with nothing to hold them: a concept labelled in
one language went missing from the blocks that place it, and a document created from a view
said nothing about what it was about. The suite had specs for the concept TREE and none for
the blocks beside it, which is where both bugs were - the tree unions the link directions
itself and joins no label at all, so neither was visible from there.

concept-hierarchy.spec.mjs is about what the stored queries return: a child asserted from
either end of the link, a document paired with its topic into one labelled row, a concept
whose only prefLabel is Lithuanian read by a browser asking for English, and one row for a
concept carrying labels in several languages - the property the removed FILTER was mistaken
for providing, and the reason removing it costs nothing.

view-create.spec.mjs drives Create in a block through to a saved document and asserts in
two halves. Both were needed, and only one of them works: measured against the pre-fix
build, the DOM was IDENTICAL either way, because this view projects the topic alone and a
row's href is its document with the fragment stripped - so a document with no topic link
renders exactly like one with it. What catches the bug is reading the document back through
the API, and the comment says so rather than leaving the next reader to believe the row
count did it.

Each subject test was run against the build it is about, both ways round. With the en-only
FILTER restored in the staged copy, the language test failed on the missing row and the
other three passed. With view.xsl reverted to its parent commit and the application
restarted so it re-digested the stylesheet, the created document came back carrying neither
foaf:primaryTopic nor dct:title and the creation test failed on exactly that. Both pass
with the fixes in place; the whole suite is 25 passed, 19 skipped, 3.1 minutes.

lib/blocks.mjs locates an ontology-defined view - by the property it is attached to, since
a derived block has no stored subject to name - and drives the constructor modal. A
resource field is filled with the URI rather than through its type-ahead: nothing validates
or rewrites what is in that box and focusout only hides the panel, so a spec that already
knows the URI stays a spec about what the save produces. The modal's @about and
@data-instance are read BEFORE saving, because they are the only record of what the client
minted and the teardown needs them even when an assertion fails.

Teardown removes the linking triple as well as the created document: the link lives in the
graph of the concept it was created from, and one left dangling is a row every later spec
would see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
namedgraph added a commit that referenced this pull request Sep 12, 2026
…mport set (#383)

* Package stylesheets reach the client, by composing and compiling a SEF per import set.

A package's ac:stylesheet has only ever affected server-rendered output. The client
SEF is compiled at build time from client.xsl alone, so the first time client.xsl:1104
intercepts a link and re-renders the pane, every package rule disappears - the page is
right on load and wrong on the first click, with no error anywhere. client.xsl:428
documents the symptom and works around it by keeping the server-rendered body on the
initial load, which is why this has been survivable rather than obvious.

Saxon-JS cannot close the gap in the browser: SaxonJS.compile() is gated on inBrowser()
in both the browser and Node builds and throws SXJS0006. Shipping one prebuilt SEF per
package does not work either, because xsl:import precedence is resolved at compile time -
two packages overriding the same mode would both run with nothing deciding which wins,
and each package SEF would carry its own copy of the whole client tree. So the composition
has to happen server-side, per distinct set of imported packages.

Composition is a generated wrapper, not an edit to client.xsl. client.xsl pulls its 14
client modules in with xsl:include, and an included module shares the includer's import
precedence, which beats template priority outright - so appending a package import into
client.xsl would place it BELOW everything in client/**, unable to override ldh:TreeNode,
ldh:RenderRow, any ixsl:on* handler, or the form and modal machinery. It would compile
cleanly and do almost nothing. The wrapper imports client.xsl first and the packages
after, so later imports outrank it; measured on a composed build, the package lands at
import precedence 40 against 39 for client.xsl and every module it includes. This mirrors
what static/xsl/layout.xsl already is for the server: an eleven-line shim whose only job
is to be the thing packages get composed onto.

The compiler runs as its own service because the compile peaks at 1,495,990,272 bytes
resident - measured, not estimated - which beside a Tomcat heap at 75% of a 2048m limit
is an OOM, and capping V8 below the peak just fails the compile instead. It is built from
the same Dockerfile as the platform and takes the static tree by COPY --from the Maven
stage, so the modules it compiles are the deployed bytes by construction: no mount, no
shared volume, and no way for a rebuild to leave it stale. It is stateless - the caller
sends the package modules inline and gets the SEF back - so there is no staging volume
either, and it never touches the network, which keeps the SSRF surface out of the
component doing the compiling.

Package stylesheets are fetched and entity-expanded by the platform first, because
SaxonJS has no DTD internal subset and the bundled packages declare entities: xslt3-he
reports "Reference to unknown entity &ldh;" and stops. That is the same step pom.xml
already applies to every stylesheet in the WAR. It uses SecureXML.newXMLReader(), which
tolerates a benign internal DOCTYPE - newDocumentBuilderFactory() sets
disallow-doctype-decl and would throw on the package.

Keyed on the inputs rather than the output. A SEF is not byte-deterministic - its header
carries a build timestamp - so content-addressing the result would mint a new URI on
every compile and a different one per node. The key is SHA-1 over the digest of the stock
client.xsl.sef.json concatenated with the sorted package URIs: the stock SEF fingerprints
all 66 client modules and changes on every platform build, so an upgrade invalidates every
composed stylesheet, and two nodes agree on one URI for one import set. The digest uses
its own MessageDigest instance; Application's is shared and not thread-safe.

Served from /static/xsl/sef/ through a Tomcat alias onto a directory outside the WAR,
not from the upload store. Uploads would need an invented owning document for Item.init()
to find, an authorization that grants anonymous read in every dataspace, and three SPARQL
round-trips before streaming 19 MB - and varnish passes /uploads/ for every signed-in user,
where its VCL says /static/* is the only safely-shared path. Under /static/ the file is
public by construction and inherits the immutable caching, CORS and gzip the built-in
stylesheets already get. Writes go through a temp file and ATOMIC_MOVE so a reader never
sees a partial 19 MB file.

Server and client stay in lockstep. XsltExecutableFilter moves to USER + 350 so it outranks
ResponseHeadersFilter, which is where the composed stylesheet's URL is advertised - response
filters run in descending priority, so at 200 the decision came too late to be published.
It then makes one choice: no packages, stock; key published, composed executable AND the
client SEF URL; otherwise stock on BOTH sides plus an async build. Composing server-side
while the client cannot is the exact shape of the defect this commit exists to remove, so
it is never done. An instance with no compiler configured keeps composing server-side as
before, so this changes nothing for deployments that do not run the service.

The URL travels as a Link header read back by ldh:client-stylesheet(), the same way
acl:mode and the Memento relations already reach the stylesheets, rather than as a new
Java-to-stylesheet parameter; layout.xsl falls back to the built-in SEF when the header is
absent. The client twin returns the empty sequence: by the time that stylesheet runs, it
is the one already running.

Triggered from three places, because packages declared in config/dataspaces.trig never
PATCH: the settings PATCH, startup, and lazily on an unknown key. The build pool holds one
thread, and the per-key CompletableFuture chain registers its cleanup after computeIfAbsent
returns, the same re-entrancy trap GraphVersioningService documents.

The whole feature is optional at startup - no SEF root, no compiler endpoint, or no stock
SEF to fingerprint with, and it logs and stays off rather than failing to boot.

The bundled SKOS package loses its static ldh:base parameter and its import of the server
layout, in the Apps repository: layout.xsl is not in the client closure, so importing it
would drag the entire server layout in above client.xsl and let server templates beat
client ones, and xslt3-he has no CLI option for static parameters, so the parameter could
not be supplied at all. A package ac:stylesheet is now a leaf module.

Verified: mvn compile clean; the composition compiles through the service end to end
against the real client.xsl and the SKOS package - 200 in 19.6s, 19,166,264 bytes,
927,372 gzipped, the package rule at precedence 40, and the stylesheet directory restored
exactly afterwards; the Link rel matcher unit-tested on SaxonJS across five header shapes;
docker buildx --check and docker compose config clean; and the context.xsl output carries
both the alias and the parameters. Not yet verified: make sef, which would swap the SEF
under the running instance a parallel session is testing against.

docker-compose.yml gains the service, the platform's dependency on it and the SEF volume.
The file had skip-worktree set locally because it also carried an enabled credentials
secret; that is a per-machine choice, so it moves to docker-compose.override.yml - which
already restates this service's secret list - and the base file returns to its shipped
default. Verified through the merged configuration rather than by reading: the secret still
reaches the container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

* A package keeps taking effect on the next request, instead of twenty seconds later.

The previous commit made server-side composition wait for the compiled client stylesheet,
so that a package could never be live on one renderer and not the other. That broke
misc/PATCH-settings-package-import.sh, which declares an ldh:import and asserts the
package's marker on the very next response - and the test is right. Its own comment, and
the 5.10.0 changelog that introduced declarative imports, state the contract: effective on
the next request, no restart. Holding the server back silently converted that into
"effective in about twenty seconds", which is a different product.

The second consequence was worse than the interleaving it was meant to prevent. The
Dockerfile always sets SEF_COMPILER, so the service is always constructed and the
"no compiler configured" fallback never fires in a real deployment - an instance whose
compiler container was missing or unreachable would have lost package rendering entirely
rather than degrading to the behaviour it had before any of this existed.

So composition is never withheld. Packages compose server-side on every request, exactly
as they did; the compiled SEF is an addition the client picks up once it exists. The window
where the server has the package and the client does not is precisely the behaviour that
shipped in 5.10.0 and has been permanent ever since - it is now bounded to one compile
after an install or a platform upgrade, which is the improvement this branch is for.

One thing this costs, recorded because it is a real change in observability: a compile
failure is no longer visible in the rendered page, since the server still composes. It
surfaces in the build's exceptionally handler and in the compiler's verbatim stderr, and
nowhere else.

Known and not addressed here: while a key is unpublished, every render submits a build.
computeIfAbsent deduplicates concurrent ones, but a failing compiler clears the entry on
completion, so a persistently unreachable compiler means one attempt per request. Cheap to
bound with a negative cache; out of scope for restoring the contract.

Verified: mvn compile clean. The assertion sequence the test drives now holds by
construction - no import, stock; import, composed on the next request; import removed,
stock again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

* The document tree becomes a tree component that the document hierarchy is one consumer of.

A SKOS concept tree is the same widget as the drawer's document tree over a different relation:
the same nested list of links, the same disclosure that is a sibling rather than a child of the
anchor, the same depth ramp, the same lazily loaded placeholder row. Copying it for the taxonomy
editor would have produced two emitters of one anatomy, which is the arrangement that let the
derived view and the authored view drift apart on three axes before anyone noticed.

So client/tree.xsl holds the widget and knows nothing about what fills it. Two seams carry the
domain: ldh:TreeChildrenLoad, dispatched on the disclosure button so a tree selects its loader by
matching the container it sits in, and an $expandable parameter on ldh:TreeNode, because "can this
node be expanded" is a question about a relation the component must not know. The document
hierarchy answers both in navigation.xsl - a rule matching sioc:has_parent that next-matches into
the generic emitter, and a rule matching the drawer's tree that supplies the ldh:SelectChildren
query. Both are same-precedence includes, so priority separates them; import precedence has
nothing to say here.

Deliberately left behind: ldh:doctree-descend expands a path by prefix-matching child hrefs
against the target URI. That is a property of a hierarchy whose URIs nest, not of trees - concepts
are filed flat under concepts/ and a concept tree has to walk explicit links - so generalizing it
would have been generalizing a coincidence.

The extraction also removes a duplicate it exposed: ldh:doctree-descend carried its own inline
copy of the children query, annotated "inline from ldh:DocTreeResourceLoad". Both now call
ldh:doc-tree-children-query, so the relation the document tree follows is written once.

Behaviour is unchanged by construction: every template moved verbatim except for the two
parameters, and the callback's only edit is its name - ldh:left-sidebar-resource-response said
where it rendered rather than what it rendered, which is exactly the assumption being removed.

Verified: make sef compiles clean in 18.9s against a freshly rebuilt overlay, tree.xsl is in the
exported SEF, and the two ldh:TreeNode rules land as intended - the sioc:has_parent override at
priority 1 and the generic emitter at 0.5, same import precedence. The only other *[@rdf:about]
rule in navigation.xsl is ldh:ClassListItem, a different mode, so nothing is ambiguous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

* The relation a tree follows becomes its parameter, rather than a query each domain writes.

The extraction left the widget generic and the relation hard-coded twice over: a stored SPIN
query naming sioc:has_parent and sioc:has_container, and a match on sioc:has_parent deciding
which nodes get a disclosure. A concept tree would have had to bring its own SPARQL to say the
one thing that actually differs between the two trees - which link to follow.

So ldh:tree-children-query takes the relation and generates the SELECT. It takes it from both
ends, because RDF puts the link wherever the modeller put it: properties asserted on the child
pointing at its parent, and properties asserted on the parent pointing at its children. The
document hierarchy has two of the first kind and none of the second, and now declares exactly
that:

    ldh:tree-children-query($uri, (sioc:has_parent, sioc:has_container), ())

A SKOS tree asserting skos:broader on the child and possibly skos:narrower on the parent gets
both branches from the same function, which is the case Web-Client's own skos:narrower() and
skos:broader() already union in XPath - the same modelling fact, now expressed once more in the
one place that builds the query.

What the generated query keeps and drops, against the stored ldh:SelectChildren it replaces: the
type requirement stays, so a tree still shows resources that describe themselves; the ORDER BY
and the ?thing binding go, because children are sorted by ac:label() at render time and the topic
variable was already being stripped by ldh:replace-variables before the query was ever run. The
query text is generated rather than stored, so ldh:SelectChildren has no caller left here.

Verified: both relations produce the SPARQL they should, driven on SaxonJS through the exact
string-building expression -

  SELECT DISTINCT ?child WHERE { GRAPH ?childGraph {
    { ?child <sioc:has_parent> <uri> } UNION { ?child <sioc:has_container> <uri> } ?child a ?Type } }

  SELECT DISTINCT ?child WHERE { GRAPH ?childGraph {
    { ?child <skos:broader> <uri> } UNION { <uri> <skos:narrower> ?child } ?child a ?Type } }

- the UNION grouping matching the stored query's, and an empty relation terminating rather than
building a query that would return the whole graph. make sef compiles clean in 18.6s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

* The SEF settings move to CATALINA_OPTS, because the ROOT.xml transform had no argv left.

Adding ldhc:sefRoot and ldhc:sefCompiler as --stringparam arguments took the container down at
startup: "too many params increase MAX_PARAMETERS", then exited (2). xsltproc stores parameters in
a fixed array of 64 argv slots, two per parameter, so 32 is the ceiling, and the ROOT.xml
transform already carries 52 stringparam definitions of which the optional mail and OAuth ones
collapse when unset. CI stayed under the line because its configuration leaves those empty; a
developer with Google, ORCID and SMTP configured is over it, which is why this passed a full
green run and broke a local make up.

The remedy is the one this codebase already uses for settings of this kind: system properties on
CATALINA_OPTS, read with System.getProperty, as allowInternalUrls and the HTTP client timeouts and
cache expirations are. Infrastructure paths and endpoints belong there anyway - they configure the
process, not a dataspace, and nothing about them needs to reach a stylesheet.

The Tomcat alias is the one piece that genuinely has to be in ROOT.xml, since it is a Context
attribute. It is now written by xmlstarlet straight after the transform rather than by
context.xsl, which keeps a single source of truth in $SEF_ROOT and costs no argv:

    <Context path="" aliases="/static/xsl/sef=/var/www/linkeddatahub/sef">

Both variables also stop being mandatory. The feature is optional by construction - Application
logs and stays off without them, leaving packages server-side - so aborting startup over a missing
value contradicted the design and added a failure mode for anyone with a custom environment.

Verified: the stringparam set is now byte-identical to develop's, 52 definitions, so the transform
is back to the argv budget it shipped with; the alias injection produces the attribute Tomcat
expects, driven against a stand-in ROOT.xml; mvn compile clean. The ldhc:sefRoot and
ldhc:sefCompiler vocabulary terms go with the parameters, since nothing reads them now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

* The SKOS ontology gains the views a taxonomy is navigated by, and a scheme it must belong to.

The package could already render a concept's neighbours - narrower, broader, collection members,
and the concepts in a scheme - but nothing rendered a scheme's entry level, which is where reading
a taxonomy starts and where the concept tree has to root itself. skos:hasTopConcept now carries a
view, and it counts a top concept from either end: a scheme may assert skos:hasTopConcept, a
concept may assert skos:topConceptOf, and SKOS declares those inverses without preferring one, so
a query that reads only the forward direction returns an empty tree for half of the taxonomies in
the wild.

Two more views and a constraint, all pointed at the same failure mode - a concept that exists but
cannot be reached:

- skos:inScheme gains a forward view, so a concept shows which scheme it belongs to rather than
  only schemes showing their concepts.
- :OrphanConcepts lists concepts a scheme claims that nothing places in its hierarchy - neither a
  top concept nor narrower than anything. Such a taxonomy is not invalid, but a tree that descends
  from the top cannot reach them, so the scheme surfaces them instead of leaving them invisible.
  It carries ldh:showWhenEmpty false, because a well-formed scheme should show no such block at
  all, and it attaches as a second ldh:inverseView on skos:inScheme - a property can carry several,
  and the injection groups results by block.
- :MissingInScheme makes skos:inScheme mandatory on a concept. SKOS does not require it; this
  package does, because a concept outside every scheme is unreachable from any tree.

Verified: both copies of the ontology parse - 96 triples bundled, 106 in the Apps repo, which
already differed - all seven views are attached to a property so the injection can find them, both
constraints are on skos:Concept, and the three new queries parse under Jena qparse.

Noted and deliberately not changed here: :SelectConceptsInScheme filters prefLabel with a
hardcoded langMatches(..., "en"), which contradicts the platform's rule that the reader's
Accept-Language is the only language preference. It predates this change and is not the taxonomy
editor's to fix in passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

* A tree stops losing every child that is linked from above.

ldh:tree-children-query put the type requirement in the same GRAPH block as the relation:

    GRAPH ?childGraph { { ?child P $this } UNION { $this C ?child } ?child a ?Type }

Every document is its own named graph, so that only holds when the child asserts the link. When
the parent asserts it - a scheme's skos:hasTopConcept, a concept's skos:narrower - the link is in
the parent's graph while the child's rdf:type is in the child's, and one GRAPH variable cannot
bind to both. The inverse branch matched nothing at all.

The document tree never showed this, which is why it survived a 23-assertion Playwright pass:
sioc:has_parent and sioc:has_container are both asserted on the child, so link and type are always
in the same graph. The defect is invisible until a relation is read from the other end, which is
exactly what the taxonomy editor needs and what the inverse parameter was added for.

Measured rather than reasoned, against a fixture built for it - a scheme with one top concept
linked from the concept and one linked from the scheme. Before: one row. After, with the type
requirement in its own graph, both. And expanding a concept now returns all three children where
the relation is split across directions - two asserting skos:broader on themselves, one linked by
skos:narrower from the parent.

The document tree is unaffected by construction and confirmed by re-running the drawer suite:
23/23, with the child count rising 9 to 10 because the fixture container is itself now a document
in the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

* A bundled package's stylesheet is read from the classpath, and the composed SEF is served again.

Two independent faults kept a locally bundled package from reaching either renderer, and both were
invisible until measured on a running container.

The Context "aliases" attribute does not exist after Tomcat 7. Tomcat 10 logs "failed to set
property [aliases]" as a warning and carries on, so /static/xsl/sef/ 404'd with nothing else to show
for it and every composed SEF was unreachable however well it compiled. The supported mechanism is a
<Resources><PostResources> set with className DirResourceSet, base and webAppMount, and POST is the
right half of the pair: it is consulted only when the WAR itself has no such resource, so a mounted
directory can never shadow anything the platform ships. Deleted before being inserted, because a
restart reuses the container filesystem and runs the edit again - two aliases attributes produced an
invalid ROOT.xml and the whole webapp stopped deploying. It stays in entrypoint.sh rather than
context.xsl because that transform has no argv budget left: xsltproc caps at 32 parameters and the
set is already full, which is why the system-like settings moved to CATALINA_OPTS.

The second fault is that a bundled package's ac:stylesheet is a URI nothing dereferences. It is
mapped to a classpath file through the prefix-mapping graph repository, so ClientStylesheetService
now consults the repository's isMapped/resolve before reaching for the network, and
LocalStylesheetResolver gains the same branch for the server-side compile. The stylesheet never
leaves the JVM, which is also why that URI need not resolve over HTTP at all - and it adds no SSRF
surface, since the compiler still receives module bytes rather than a URL to fetch.

That required reordering Application: the repository and resolver were constructed after the
stylesheet service that now depends on the repository, so the service received null and silently
fell through to the HTTP path. They move above it, unchanged otherwise.

.gitignore gains /sef, the host side of the bind mount docker-compose.yml already declares - each
composed SEF there is ~18.6 MB of build artefact and belongs in the repository no more than
/uploads does.

* A tree is in the server's first paint, sends the query it wrote, and can sit beside the content.

Three changes to the tree component, each one a defect measured on the running instance rather than
read off the code.

The emitter moves to the shared trunk. client/tree.xsl held both halves of the widget, so
ldh:TreeNode existed only in the client closure and any caller had to be declared use-when Saxon-JS
or fail the server compile with XTSE0650. That is fatal rather than cosmetic, because the client
keeps the server-rendered body on a direct page load and never re-renders it: a tree emitted only
client-side was absent from every bookmarked or shared URL until the reader navigated somewhere
else, and appeared on a click navigation alone. The markup half - ldh:TreeNode, ixsl-free, calling
only ldh:class-icon and ac:label - becomes xsl:tree.xsl, imported by the common.xsl trunk so both
products compile it. The handlers stay in client/tree.xsl. The two meet at the button the emitter
writes: the server paints it, the client binds it, which is what a direct load now exercises.

The children query is sent as written. It went through SPARQLBuilder twice - parsed from a string in
ldh:tree-children-query, re-serialised from XML in ldh:TreeChildrenFetch - and that round-trip
MERGED the two sibling GRAPH blocks into one, keeping only the last graph variable. Measured, a
correctly scoped query arrived at the endpoint as GRAPH ?childGraph { {..} UNION {..} ?child a
?Type }, putting the type requirement back inside the link's graph and reinstating exactly the defect
d7773573a fixed: every child linked from the parent side disappeared, two top concepts returning one.
Wrapping the second block in a group graph pattern did not survive the round-trip either. Nothing
needed the parse - the query is generated here, never authored or edited - so the function now
returns the DESCRIBE whole and the fetch hands it over as the string it already is. Both round-trips
and ldh:wrap-describe go, along with the duplicate URI-building in ldh:doctree-descend: 91 lines out
of client/tree.xsl, 14 out of navigation.xsl, and the endpoint receives what the comment describes.
Verified against the running instance: both link directions resolve at both levels - a scheme's
hasTopConcept child and its topConceptOf child, a parent's narrower child and its broader children -
and the drawer's document tree passes 23/23 unchanged, including lazy load, sort, collapse and the
no-refetch-on-re-expand path.

ldh:ContentBody gains a declared slot. ldh:ContentColumn is a deep-skip mode applied as the content
body's first child, for a vocabulary with a shape worth navigating beside the content; empty, and
costing nothing, unless something fills it. It is a slot INSIDE the content body rather than a
wrapper around it, and the difference is measurable: .content-body carries the page gutter and the
content width, is addressed by rules as a direct child of .document-body, and is the containing block
the sticky create dock measures its full bleed against - so a column wrapped around it put the tree
flush against the viewport edge at x=0 and left the dock starting at x=212 and overflowing the right
edge, while a column emitted into it leaves every one of those rules matching. ldh.css turns the body
into two columns only when the slot is filled, reusing the design system's vendored ontology-editor
values, with the dock spanning both columns because it is the document's affordance and not the
content column's. Measured at 1440: columns 300px + 912px, tree inset to x=104 and sticky, content
starting right of it, dock x=0 w=1440 still sticky with its button inside the viewport, and no
horizontal scroll. app.css is untouched.

* The taxonomy package renders a concept tree in the content column.

The package's stylesheet is bundled rather than fetched: package.ttl's ac:stylesheet becomes a
relative URI resolving against the registry location, and prefix-mapping.ttl maps it to the
classpath copy beside ns.ttl, so an instance with no route to raw.githubusercontent.com still
composes the package - and CI stops compiling whatever happens to be on a branch at the time.

What the stylesheet contains is the residue of what nothing existing could express, which is one
widget and one suppression. The tree fills ldh:ContentColumn, so the concept hierarchy is read
alongside the document's content instead of inside one statement list; the card, its stickiness and
the column width are the design system's own ontology-editor layout. It is anchored on the primary
topic of THIS document rather than on any SKOS resource in the graph, because a container listing
concept documents describes every child's topic too, and matching those hung a tree off every such
listing rooted at whichever concept came first - measured, and now zero columns on /taxonomies/.
One rule, not two: what separates a scheme from a concept is which relation the root expands over,
and that is a single boolean the class carries for the handlers to read at click time.

The emitter is deliberately not guarded by use-when, which is the whole point of moving ldh:TreeNode
into the shared trunk - a scheme's tree is now in the server's first paint. Only the two
ldh:TreeChildrenLoad rules stay Saxon-JS, since they reach the handlers; without that guard the
server compile fails and the platform falls back to a stylesheet carrying no package rules at all,
so the package would stop working entirely rather than degrade.

The hierarchy predicates stay suppressed from the generic property list, so the tree is not
duplicated as statement rows beside itself.

Verified on the running instance with the composed SEF: the column renders on a direct load and
survives a client-side navigation, on a scheme and on a concept; the disclosure the server painted
is bound by the client and loads children over both assertion directions; and the console stays
clean.

* A view works out where a new row would go, and ldh:container is deleted.

A view's Create button renders where the view carries ldh:container and a class can be inferred, and
the whole flow behind it - constructor, modal, PUT, link, refresh - has been shipped and working for
some time. It had never rendered once. A search of both repositories finds zero ldh:container values
on any view: the only occurrences were the ldh:ViewConstructor form shape and one absolute
https://localhost:4443/products/ in an Apps docs example. The Broader and Narrower concept blocks
carrying no Create button was one instance of a general absence.

The property asked the view for the one fact a view cannot hold. A package ontology is published at a
foreign base, so the documented relative form - ldh:container <concepts/> - resolves against
raw.githubusercontent.com, and relative IRIs are resolved by the parser, so even the authorial intent
"relative" is gone before the platform sees a value. Successive attempts to relocate it (onto the
class, onto a concept scheme, onto a scheme's document) each broke on a case the next one patched,
because taking the declaration as given and asking only where to put it is the wrong question.

A view is a SELECT and its rows are its solutions, so Create means: make a new solution appear in
this projection. That is satisfied by solving the pattern with the projected variable bound to a
fresh resource - $about is already substituted, the pattern IS the triples to insert, and the only
variable nothing supplies is the graph of the new resource's own description. A graph here is a
document, so that unbound graph variable IS the container question. The pattern cannot answer it
because the document does not exist yet, but the existing solutions can: their descriptions already
live in graphs that sit in containers. Measured before building anything - every view in both corpora
resolves to exactly one container: the four taxonomy views to /taxonomies/, and Northwind's orders
and products by construction, since orders.rq binds ?container explicitly and derives every document
and entity from it before throwing it away.

So the container is determined per view by a dedicated request, modelled on ldh:ResultCount and for
the same reason: the rendered rows are one page, and inferring from them would let the destination
depend on which page the reader was looking at. LIMIT, OFFSET and ORDER BY come off first through the
existing rewrite modes, then ldh:container-select projects the container alone. The two joins need no
string operations on URIs - the graph a solution is described in is its document, and that document's
sioc:has_container is a triple the platform asserts on every one of them. Deliberately not via
foaf:primaryTopic, because a document's fragment entities link back to it differently from one
vocabulary to the next: Northwind's #delivery uses foaf:page. DISTINCT is load-bearing rather than
tidy, since without it two solutions in one container return two rows and read as ambiguity, and
LIMIT 2 is the whole answer - one row means determined, two mean ambiguous, and no row beyond the
second carries information. There is deliberately no GROUP BY and no COUNT: the query is unable to
say which container holds the most rows, because placement decided by counting is placement nobody
stated, so anyone adding a majority rule has to add the aggregate first and notice what they are
changing.

Exactly one container renders the button. None - the empty projection, which is the bootstrap case
and the one that matters most, since the first concept in a new scheme cannot be created by
generalising from its siblings - renders nothing, and so does more than one. A heterogeneous
projection is the honest case for refusing: "add a row" is ill-defined when the rows are of different
kinds, and the class inference would be ambiguous too. The host document's own container is not a
fallback either, since that keys placement on where the view is rendered rather than on what is being
created.

The ACL probe moves as a consequence. It used to HEAD the declared container before the results were
fetched; a determined container is not known until the request resolves, so the probe follows it and
the button appears a moment after the toolbar rather than with it. ldh:set-container-acl-modes is
reused verbatim for the parse. Rendering optimistically and letting the PUT 403 would be worse than
appearing late.

ldh:container therefore goes entirely - the property, its ldh:ViewConstructor input, the ontology
query's OPTIONAL and binding, the data-container stamp, and the RDFa fallback read. That fallback
could never have worked: only @data-container had one, while @data-for-class and @data-acl-modes were
written solely by ldh:ontology-view-insert, so a hand-authored view block failed the other two
conjuncts regardless. ldh:ontology-view-render-thunk loses its branch and its HEAD, and
@data-container keeps its meaning for the click handler and both submit handlers, which are untouched.

Measured on the running instance rather than read off the code. make sef compiles clean; with the
composed SEF rebuilt and both Varnish layers restarted, /taxonomies/coffee/ issues two determination
queries whose text is the view's own pattern plus the two joins, with SELECT DISTINCT, LIMIT 2, no
ORDER BY, no GROUP BY, no COUNT and no primaryTopic, each returning exactly one row
(https://localhost:4443/taxonomies/), followed by a HEAD on that container and two Create buttons
carrying it. /taxonomies/espresso/ proves both halves of the rule on one page: the block whose
projection is empty gets no button while the block with one row gets one. An anonymous session gets
none. A projection shaped like the rewrite but spanning containers returns two rows, which is the
count the gate refuses. The console stays clean throughout.

Two notes for whoever debugs this next. xsl:message output stops after the first few messages inside
this promise chain - the requests were firing while the later messages never reached the console,
which cost two build cycles chasing a failure that did not exist. And a tunnel parameter declared
without a default downstream is a required one: the rewrite failed outright, and silently, until
every helper variable name was derived once and passed on explicitly.

* A concept's hierarchy blocks agree with its tree.

SKOS declares skos:broader/skos:narrower and skos:hasTopConcept/skos:topConceptOf as inverse pairs
and leaves the choice to the modeller, so a parent may point down or a child may point up and both
are correct. ldh:tree-children-query has unioned the two directions since the tree was extracted;
:SelectNarrowerConcepts and :SelectBroaderConcepts read one each, so a view and the tree on the same
page answered the same question differently.

Measured on the fixture, whose seven hierarchy triples deliberately use both conventions: Hot drinks'
Narrower block showed 1 child where the tree showed 3, because Coffee and Tea assert skos:broader
upwards while only Juice is pointed at from above; and Juice's Broader block showed nothing at all
while the tree nested it under Hot drinks. :SelectTopConcepts already read both directions, and the
remaining queries have no inverse to add - skos:inScheme and skos:member are not halves of a pair -
so this is the whole of it.

The union stays INSIDE the link's GRAPH rather than being flattened into a sibling block, which is
not a stylistic choice: SPARQLBuilder's parse/serialise round-trip merges sibling GRAPH blocks into
one, keeping the last graph variable, and would then scope the child's label to the document holding
the link - the same defect that cost the tree every parent-asserted child twice already. Nested GRAPH
blocks survive the round-trip, as the captured container query shows.

Verified on the running instance: Hot drinks' Narrower block is 3 and Juice's Broader block is 1,
both matching the tree, while Cold drinks stays 0 both ways because its only hierarchy triple is the
scheme's skos:hasTopConcept and nothing claims it as a parent - that page was reporting the data
correctly all along. Console clean.

* The concept tree starts at the scheme, not at the concept you are on.

Rooting the tree at the document's own primary topic made it useless as a taxonomy tree:
/taxonomies/juice/ rendered a single node, "Juice", with a chevron. What a taxonomy tree is for is
placing a concept among the others, so it has to start where the taxonomy starts, regardless of which
concept is open.

On a scheme document the topic IS the root. On a concept document the scheme is named by
skos:inScheme, which is already in the rendered RDF, so the URI costs nothing; its label comes from
the platform's $object-metadata - the same label-only CONSTRUCT that already renders "Drinks" as the
link text in the property list beside the tree - so the root node costs no request either. That
tunnel parameter does reach this mode: it is set once per render, server-side in layout.xsl and per
pane in client.xsl, and flows down through ldh:ContentBody. A concept in no scheme falls back to
itself, so an unplaced concept still gets a tree rather than an empty card.

The scheme-root class goes with it. It existed to tell the root's disclosure to expand over
hasTopConcept/topConceptOf rather than broader/narrower, but with the root now always a scheme it
distinguished nothing: depth alone says which relation applies, and count(ancestor::li) already
carried that.

Verified on the running instance across every shape the fixture has - a deep concept, a leaf, a top
concept, the scheme itself, and an orphan - all five root at Drinks, link to the scheme resource and
offer a disclosure, with no scheme-root token anywhere. Expanding that disclosure returns both top
concepts, one asserted as hasTopConcept from the scheme and the other as topConceptOf from the
concept, so the root's own relation reads both directions like every other level. Console clean.

What this does NOT yet do is reveal where the open concept sits: the tree starts collapsed at the
scheme, so on /taxonomies/espresso/ you get Drinks and have to walk down. That reveal is the second
half and is deliberately not in this commit - see the plan for why it needs a shared renderer with
separate server and client fetch paths.

* The content-column slot stops depending on the ontology editor's class.

ldh:ContentColumn was general in every respect except the one that decided whether it did anything:
the two-column layout keyed on `.content-body:has(> .ldh-onto-list)`, and .ldh-onto-list belongs to
the design system's Ontology editor section. So a general extension point fired only for one
component's class - every package would have had to borrow that card to get a column, and an aside
that is not a card (a bare list, a full-height panel) got no layout at all. A slot that only lays out
when its content happens to carry one particular class is not an extension point, it is that
component's private arrangement.

ldh:ContentBody now owns the wrapper. The slot's output is materialised first, so an unfilled slot
emits nothing and the content body is untouched, and a filled one is wrapped in .ldh-content-aside,
which is what the grid keys on. A filler therefore needs no layout class of its own and may look like
anything; the SKOS package keeps .ldh-onto-list inside the wrapper for the card-and-sticky treatment,
which is now a choice rather than a requirement. The grid still applies to .content-body itself
rather than an outer wrapper, for the reasons already recorded there: the gutter, the content width
and the create dock's containing block all depend on .content-body staying a direct child of
.document-body.

Verified on the running instance: on /taxonomies/espresso/ the wrapper is present with the package's
card nested inside it, the body computes as a grid at 300px + 912px, the aside is inset to x=104
rather than flush to the viewport, stickiness survives the extra nesting level, the create dock is
still full-bleed at x=0 w=1440, and there is no horizontal scroll. On /taxonomies/ - a container,
so nothing fills the slot - no wrapper is emitted and the body stays a flex column exactly as before.

The CSS deliberately stays in ldh.css rather than going upstream to the design system: the rule is
written against `.document-body > .content-body`, which is application document markup the kit does
not model, so moving it would put LDH selectors into the DS. What was missing upstream was the
documented composition, and that went instead: a new preview card, Layout / "Content aside", showing
a filled slot with a card treatment, a filled slot with a bare list, an unfilled slot, and the narrow
collapse - recording that the wrapper is the platform's and never the filler's, that the aside owns
position and not appearance, and that the ontology editor's .ldh-onto-grid is the same arrangement
with the same values. Additive only; no existing DS file touched, and app.css stays byte-identical to
the hosted copy.

* The concept tree renders in ReadMode alone.

The tree appeared in every document mode, because ldh:ContentColumn was applied without regard to
which one was active. In ContentMode that put it beside an authoring surface for the document's own
content blocks - on a concept with no blocks, a tree card alone in an otherwise empty body, which is
what prompted this. The Map, Chart and Graph canvases are worse: ldh.css zeroes the content body's
padding and drops its max-width for them so the canvas can take the whole body, and a two-column grid
fights that directly.

The mode is now passed into the slot rather than filtered at it. Which modes a column belongs in is
the filler's judgement, not the platform's: a taxonomy tree is a reading affordance, while an
editor's class list might well want to stay visible while its document's content is being authored.
So the platform hands over ac:mode()'s value and the SKOS package restricts itself to ReadMode.

The test can be positive because ac:mode() always resolves to a concrete URI - ?mode= when given,
else ContentMode for a document that has rdf:_N content blocks, else ReadMode - so there is no empty
case to tolerate. One consequence worth knowing: a concept document that carries content blocks
defaults to ContentMode and therefore shows no tree until the reader switches to Read, which is the
same rule applied consistently rather than a special case.

Verified on the running instance across all six modes of /taxonomies/hot-drinks/: ReadMode explicit
and ReadMode by default both render the wrapper and the tree with the body computing as a grid, while
ContentMode, MapMode, ChartMode and GraphMode render neither and leave the body flex or block as each
mode intends. Console clean.

* A tree indents its levels again: depth is tunnelled, so an override cannot drop it.

The concept tree rendered every row flush left while nesting the markup correctly - six rows at
padding-left 0 with ancestor LI counts of 0, 1, 2, 1, 2, 2. The depth was being computed correctly
all along: instrumenting ldh:tree-children-response showed count(ancestor::li) returning 1 for the
first level and 2 for the second, with the container connected to the document and parented by its LI.
It simply never arrived.

ldh:TreeNode took depth as a plain parameter, and a domain narrows that template by matching and
delegating with xsl:next-match - which forwards only the parameters it names. The SKOS package's
override names expandable and nothing else, so depth fell back to its default of 0 for every node
below the root. The drawer's own override (navigation.xsl, sioc:has_parent priority 1) has exactly the
same shape and therefore the same latent defect, which only escaped notice because a container's
children are usually dh:Items that do not match it, so the generic template kept the parameter.

This is the third time xsl:next-match's parameter rule has cost a defect here - ldh:ContentBody's
three parameters had to be re-declared and forwarded for the same reason - so the fix is at the source
rather than in each override: depth becomes a tunnel parameter, which crosses next-match without being
named. expandable stays a plain parameter, and the difference is the point: the indent ramp is ambient
state belonging to the level being rendered, while whether a node opens is a per-node decision the
domain makes and must therefore state. Only two call sites apply ldh:TreeNode - the recursive one,
which now tunnels, and the package's root, which passes nothing and correctly gets 0 - and no other
parameter named depth exists in the tree, so there is no tunnel-name collision.

Measured on the running instance: the concept tree now reads 0, 14, 14, 28, 28, 28 pixels of
padding-left against ancestor LI counts of 0, 1, 1, 2, 2, 2. The drawer's document tree passes 23/23
unchanged, and its container rows now indent for the first time as a side effect.

* The stylesheet resolver tolerates an application without a graph repository.

21a3c410b gave LocalStylesheetResolver a classpath branch for bundled package stylesheets, and it
called getSystem().getRepository().isMapped(...) unguarded. The repository is optional - an
application can be constructed without one - so every resolution that is not a bundled package threw
a NullPointerException before reaching its own branch. The resolver's own tests are exactly such
applications and are the ones that caught it: testReturnsNullForFileScheme (a file: URI) and
testDelegatesUnknownOriginToRemoteFetch (an unknown origin), neither of which is a mapped package and
neither of which should have consulted a repository at all.

ClientStylesheetService.expandEntities already guarded the same call the same way in the same commit,
so this was one of a pair and only one half got the check. Read through a local now, so the guard and
the two uses cannot drift apart again.

mvn test: 181 tests, 0 failures, 0 errors, BUILD SUCCESS - including the 4 in LocalStylesheetResolverTest
that were 2 errors before. The riot "more than 64000 entity expansions" line in that output is
BillionLaughsTest asserting the limit holds, not a failure.

* A label added through the form keeps its language tag.

skos:prefLabel rendered "Juice" with an en tag and a language field, while a value added with Add
came out as xsd:string with no language control at all - so a label authored through the UI landed
untagged beside the tagged ones already in the data.

The two paths never shared a source. An existing value's control is built from the DATA: its text()
becomes the ol input and its @xml:lang the ll input. A value added with Add is built from the
CONSTRUCTOR: ldh:render-add-value renders ac:FormControl over the constructor's own property element,
so [ a xsd:string ] was the whole reason the new field had a datatype and no language. There was no
way in the vocabulary to say otherwise - rdf:langString appeared in no constructor anywhere, and only
inside two object-metadata FILTERs in the stylesheets.

So the constructor now declares skos:prefLabel, skos:altLabel and skos:definition as
[ a rdf:langString ]. Not merely to satisfy the form: a SKOS label IS natural language, and SKOS's
own integrity rule - at most one prefLabel per language - is not expressible without a tag.

Three stylesheet changes make that declaration mean something:

Web-Client gained the langString control (its own commit), and this file SHADOWS it. That is
deliberate, and measured rather than assumed: LDH imports Web-Client, so any LDH template matching
this node wins on import precedence whatever priority the Client's rule carries. Excluding
rdf:langString from LDH's non-XSD resource lookup did not reach the Client's rule - it handed the
node to LDH's generic bnode control, which rendered an ob (object blank node) field. The xsd:*
template above shadows the Client's the same way and for the same reason.

The non-XSD resource lookup also stops claiming rdf:langString outright. It is a literal datatype
that merely happens to live outside the xsd: namespace, and treating it as a class is what turned a
text property into a URI picker - which on save wrote skos:altLabel <...#id03f1366d-...> into the
fixture rather than a literal. That is the same defect seen from the write side.

And a stored literal carrying a language tag now annotates as rdf:langString rather than "Literal".
RDF 1.1 gives every tagged literal that datatype, so "Literal" said less than the data does - and
said something different from the field the same property gets before a value exists.

Verified on the running instance, controls and round trip both. On /taxonomies/juice/ all three
declared properties render [pu, ol, ll] with an rdf:langString tag, no lt input and no combobox,
while broader, narrower, related, inScheme and type correctly stay comboboxes; a row added with Add
does the same. Driving it to completion - select altLabel, Add, type Sula, set the language to lv,
Save - stores skos:altLabel "Sula"@lv with datatype rdf:langString, leaving prefLabel "Juice"@en
untouched. Console clean throughout, and the fixture is back to its original three triples.

Not changed: ldh:TitleConstructor and :DescriptionConstructor declare dct:title and dct:description
as [ a xsd:string ] and have the same untagged-on-add behaviour. That switch would affect every
document title in an instance, so it wants its own decision rather than riding along here.

* The concept tree roots at the scheme and opens the path down to the concept you are reading.

A tree rooted at the document's own topic showed a single node with a chevron - the concept you were already looking at, with no context - so it now roots at the scheme, on a scheme document the topic itself and on a concept document the skos:inScheme object, whose label is already in $object-metadata and costs no request. Rooting it there means the tree opens where you are rather than where you came from, which is what the reveal below does.

The reveal is shaped after the document hierarchy's descent rather than after a closure query, and the difference is the whole design. ldh:doctree-descend issues one constant-size children query per level and decides where to go next from what is already in the DOM: starts-with($target-uri, $current-href), which works only because the document hierarchy is path-nested, so a target's ancestors are exactly its lexical prefixes. Concept URIs are not nested - /taxonomies/espresso/#this sits under no prefix of /taxonomies/coffee/#this - so the path is asked for instead of derived, and it is asked for the same way: one hop, one request, constant query text, no depth bound, stopping when a hop returns nothing new.

The first attempt asked for it as a bounded transitive closure, a UNION of one explicit chain per depth, and it was wrong on both counts that matter. It grew quadratically in text - 4.6kB at six hops, which percent-encodes past Tomcat's header limit and came back as a 400, Request header is too large - and it had to name a maximum depth, which a taxonomy has no reason to respect. Per-hop GRAPH scoping is not the part that was negotiable: a property path cannot leave its GRAPH and with one concept per document every hop crosses one, so skos:broader* reaches the first ancestor and stops because that ancestor's own parent link lives in another document, and the default graph is empty, leaving an unscoped path nowhere to run.

The frontier is a set, so one request covers a whole level however wide it branches, and a concept with two broader concepts puts both in the next VALUES block. That is also the cycle guard - only parents not already seen enter the next frontier, so A broader B with B broader A, malformed but legal SKOS, runs out of new nodes and terminates, where a depth bound alone would have walked the two of them as deep as the bound allowed. The climb stops itself at the top concept, whose link to the scheme is topConceptOf rather than broader, and that is the right place to stop because the scheme is the tree's root node and the descent starts from it.

Descending expands every child in the ancestor set, not the first, which is how a polyhierarchical concept comes out under each of its parents the way Skosmos renders it; the activation pass needed no special case because it already iterated every row whose href matches. No test on the ancestor set being non-empty guards the expansion: it is legitimately empty when the concept being read is a top concept, and that is the one level the root must still open to reveal it.

Continuations are fired as ixsl:promise instructions rather than returned, which is what lets one level fan out into several branches - a function returns a single map, so N branches cannot be N return values. Returning both a branch's chain and the context is exactly what the first version did, and it threw a cardinality error on every page load while the tree still rendered correctly, because the DOM writes had already landed before the throw. Two further errors of the same silent kind: map:merge defaults to duplicates=use-first, so every merge that re-keyed an existing entry was a no-op and the request stayed the first hop's query forever, looping 768 requests deep - ldh:rethread-response passes use-last for this reason - and ldh:TreeNode's depth had to become tunnelled upstream because xsl:next-match forwards only the parameters it names.

No new platform hook was needed for the trigger: client.xsl applies ldh:RenderRow to every direct child of .content-body on a direct load and on a client-side navigation alike, and the ldh:ContentColumn slot's wrapper is such a child, so one implementation serves both paths. The mode's contract is a factory rather than work, because it is evaluated in a non-updating variable binding and the factories are invoked inside ixsl:promise, which is also what gives ixsl:http-request the active promise it requires.

The tree renders in ReadMode only. It is a navigation aid for reading a taxonomy, and a document laid out in ContentMode or being edited has its own column claims.

Measured on the running instance rather than read off the code. Five page shapes, each with a dialog listener registered - a SaxonJS runtime error surfaces as an alert, which Playwright silently dismisses when nothing listens, and that is how the cardinality error hid through a full passing run: espresso reveals Drinks > Hot drinks > Coffee > Espresso with the off-path siblings present and collapsed, 3 hops up and 3 levels down; hot-drinks reveals on an empty ancestor set, 1 hop; juice reveals through a link the child does not assert, 2 hops; a scheme document roots at itself and climbs not at all; and with a second parent added to Latte from the opposite end from Coffee's, it renders under both parents and is marked active in each, both parents arriving in one hop. Zero dialogs, zero console errors, no 4xx, no leftover loading rows, every appended node in the XHTML namespace, and collapse-then-re-expand refetches nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

* The drawer's tree stops hand-rolling the fetch it pre-expands with, and a failure during it stops being silent.

ldh:doctree-descend opens the path down to the document being read, one level per request, and it loaded each level with its own copy of ldh:TreeChildrenFetch. The copy was worse in three ways, all of them invisible until something went wrong: a bare <ul/> instead of the loading row, so a slow level looked like an empty container rather than one still arriving; no ldh:busy-cursor() and no ixsl:finally(ldh:reset-cursor#0), so the pointer never showed the work; and no on-failure, so a failed request during pre-expansion produced nothing at all - no row, no message, no console error, just a branch that stayed shut.

It now opens and fetches with the widget's own templates, which makes pre-expanding a level the same act as clicking it. That needed two things extracted and one added. The click handler's two halves become ldh:TreeNodeDisclose and ldh:TreeChildrenPlaceholder, kept separate because the handler needs them separately: it flips unconditionally, since a node whose children are already in the DOM re-shows off aria-expanded alone, and appends a placeholder only when there is nothing to re-show. Extracting them is also what keeps this change from adding a third copy of that markup. And ldh:TreeChildrenFetch gains a $then continuation, spliced after ldh:tree-children-response, so the descent carries on where the children landed instead of repeating the request that put them there; ldh:tree-children-response already returned its context, so it composes. The splice goes through a function because a promise chain cannot be assembled conditionally - ldh:tree-children-continue applies the continuation when one was given and passes the context through when none was.

A caller that passes no continuation gets the chain it got before, so the drawer's click path and the SKOS package's are unchanged.

Measured on the running instance. The drawer's descent to a document three levels down now shows the loading row and the busy cursor while it walks - both sampled mid-descent with the children response held back, and neither was observable before, the bare <ul/> having nothing to show - and ends with 20 rows across three indent levels, the open document marked active, the cursor back to default and no leftover loading rows. The refactored click path expands the concept tree's root to its two top concepts at --depth 1, shows the loading row and the busy cursor, leaves none behind, and collapse-then-re-expand returns the same two children with no refetch. The reveal suite still passes on all five page shapes. Zero dialogs, zero console errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

* The UI suite's build artifacts are ignored here too.

ui-tests/ is ft-ui-tests' directory (#381), and its .gitignore carries these three lines. They are
needed on any branch the suite has been run from, though: switching away leaves the ignorable part
of the tree behind - node_modules, out and test-results - because git only removes the files it
tracks. That left `?? ui-tests/` standing in the status here, one `git add -A` away from putting
five npm packages and an HTML report into a commit.

Same three lines, in the same position as on ft-ui-tests, so the merge sees one addition rather
than a conflict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

* A document created from a view says what it is about, and is titled after it.

Pressing Create in a view PUTs the new document, and the graph store builds the containing dh:Item
around it: rdf:type, sioc:has_container, dct:created, dct:creator, acl:owner. Two triples it cannot
supply were nobody's: the document had no foaf:primaryTopic and no dct:title, so it rendered an empty
body - ldh:ContentList descends from the document resource, which mentioned nothing - and the
list/table/grid renderers, which pair a document with its topic through
key('resources', foaf:primaryTopic/@rdf:resource), had no pair to make and showed the document and
its topic as two rows, one unlabelled and one inert.

Both now ship inside the PUT body, as json:map triples folded in by ldh:triples-to-descriptions -
the mechanism an inverse view's linking triple already used, so they reach the request without
reaching the $resources re-rendered on a constraint violation.

The two submit handlers collapse into one. Once the forward flow also has to build a body, both need
the identical preamble - preventDefault, ldh:FormPreSubmit, ldh:parse-rdf-post - and the only
difference left is whether the linking triple is there, so it becomes an optional element rather than
a second template.

The title is the topic's own label, read through the ac:label MODE rather than the ac:label()
function: the function upper-cases the first letter for display, which is right on screen and wrong
in stored data - a concept called "iPhone accessories" would have been titled "IPhone accessories".
The mode is applied to a copy of the topic's Description carrying its properties but not its subject
attribute, which leaves the vocabulary templates as the only ones that can match: the URI-derived
fallbacks at the bottom of that chain answer for any URI resource, and an unlabelled topic would
otherwise have been titled after its own '#id...' fragment. Untagged, as document titles are
throughout the platform - AccessRequest.java and every cli/ addProperty(DCTerms.title, ...) store
them so, even where the topic's own label carries a language.

Unit-tested against the compiled client.xsl under SaxonJS (the browser's engine, so the label chain
is the real one): skos:prefLabel "iPhone accessories"@en titles the document exactly that; a
geo:Point carrying only geo:lat yields no title at all, where the same Description with its subject
attribute would have yielded "id9c81de"; and the assembled body carries the instance Description with
the inverse link triple folded in, plus the document's own with foaf:primaryTopic and dct:title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

* A concept is in its taxonomy's blocks whatever language it is labelled in.

Every hierarchy and membership view in the SKOS package joined a prefLabel and filtered it to English
or untagged. That filter was never a selector - the label is not projected, it is the ORDER BY key -
so all it did was drop concepts labelled in some other language.

Which is every concept created through the UI here: the form's language control follows the browser,
so a concept authored in a Lithuanian session gets "..."@lt and nothing else. Measured on the fixture:
creating a broader concept from Juice's Broader block wrote <juice/#this> skos:broader <new> exactly
as it should, and the block that created it went on showing Cold drinks alone. The new concept's own
Narrower block did show Juice - because Juice also carries "Juice"@en and passed the same filter. A
taxonomy that hides what you just added to it, while the triple sits in the graph.

Which language a reader wants is decided at render time, where the label chain already picks per
value against the accepted languages. A stored query cannot know it, and hard-coding one is how this
happened.

The join stays: it is the sort key, and a concept with no prefLabel at all has none - the package
makes one mandatory anyway (:MissingPrefLabel). What is given up is the order among a multi-label
concept's own labels, now the engine's choice, which is what an unprojected sort key means.

Verified against the endpoint, running the query texts extracted from this file with $about
substituted: Juice's Broader goes from Cold drinks to Cold drinks and the new concept; Cold drinks'
Narrower still returns Juice once, not twice, despite its two prefLabels; the Drinks scheme lists 9
concepts where it listed 8, still alphabetically. All seven queries parse under qparse and the file
passes riot --validate.

Not changed: http-tests/imports/rdf-test.rq picks an English prefLabel as the document title when
importing the UNESCO thesaurus. That one is choosing one title out of many rather than deciding
membership, and it sits inside an OPTIONAL, so it drops nothing.

Co-Authored-By: Claude Opus 5 (1M context) <nore…
@namedgraph
namedgraph merged commit ccdc1a0 into develop Sep 12, 2026
1 of 2 checks passed
@namedgraph
namedgraph deleted the ft-ui-tests branch September 12, 2026 21:56
namedgraph added a commit that referenced this pull request Sep 13, 2026
…king one nothing requests.

#383 landed the composed client stylesheet - a dataspace that imports a package runs a SEF
composed from the platform's modules plus the package's, published under ./sef and named by
the page in its bootstrap script. The harness landed in #381, one commit earlier, so its SEF
check still compared static/com/atomgraph/linkeddatahub/xsl/client.xsl.sef.json: a file the
browser no longer requests. "SEF served copy matches the working tree" went green while the
page executed a build hours old, which cost a session - an xsl:message that never appeared
and a working fix that looked broken were both that stale stylesheet.

So read stylesheetLocation off the page and check whatever it names. For the stock path the
byte comparison stands. For a composed one the artifact cannot be compared to anything local
- it is a different stylesheet - so the check is freshness instead: ClientStylesheetService
keys a composition on the digest of the stock SEF plus the import set, and digests the stock
SEF once, AT STARTUP. An app left running after `make sef` therefore keeps publishing the key
it started with. Every build stamps a new buildDateTime, so the digest and the key always move
when the app is current, and a key whose file in ./sef predates the stock build is exactly the
app that has not been restarted.

It asks a CHILD document, never the root, and so runs after seeding: the root is served the
stock stylesheet even on a dataspace whose children get the composed one, so probing it
reports "stock matches" and re-lands the very false guarantee this removes. Verified in both
directions - green as it stands, and failing with the composed key's timestamps once the stock
SEF is touched newer.

The remedy it prints now restarts linkeddatahub first, because Varnish alone cannot help when
the app is publishing a stale key, and nginx last, because restarting Varnish changes the
container IPs nginx resolved at startup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EptZ6w7rd883q8N4CMorR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant