Skip to content

Package stylesheets reach the client: compose and compile a SEF per import set - #383

Merged
namedgraph merged 23 commits into
developfrom
ft-taxonomy-editor
Sep 12, 2026
Merged

namedgraph merged 23 commits into
developfrom
ft-taxonomy-editor

Conversation

@namedgraph

Copy link
Copy Markdown
Member

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 — right on load, 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.

Saxon-JS cannot close this in the browser (SaxonJS.compile() is gated on inBrowser() and throws SXJS0006), and per-package prebuilt SEFs cannot compose, because xsl:import precedence is resolved at compile time. So the composition happens server-side, once per distinct import set.

How

A generated wrapper, not an edit to client.xsl. client.xsl pulls its 14 modules in with xsl:include, and an included module shares the includer's precedence — which beats template priority outright. Appending a package import into client.xsl would place it below all of 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 packages after. Measured on a composed build, the package lands at import precedence 40 against 39 for client.xsl and every module it includes.

Out-of-process compiler. The compile peaks at 1,495,990,272 bytes resident — measured — which beside a Tomcat heap at 75% of a 2 GB limit is an OOM. The sef-compiler service is built from the same Dockerfile 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, nothing to drift. It is stateless and never touches the network.

Keyed on inputs, not output. A SEF carries a build timestamp, so content-addressing the result would mint a new URI per compile and per node. The key is SHA-1 over the digest of the stock client.xsl.sef.json plus the sorted package URIs — so a platform upgrade invalidates every composed stylesheet and nodes agree on one URI per import set.

Served from /static/xsl/sef/ through a Tomcat alias onto a directory outside the WAR — public by construction, inheriting the immutable caching, CORS and gzip the built-in stylesheets get. Not the upload store, which would need an invented owning document, an anonymous-read authorization per dataspace, and three SPARQL round-trips before streaming 19 MB.

Server-side composition is never withheld. Packages compose on every request exactly as they do today; the compiled SEF is an addition the client picks up once it exists. An earlier commit here got that wrong and is corrected in 062ae7d6f.

Verified

mvn compile clean. The composition compiles end to end through the service against the real client.xsl and the SKOS package — 200 in 19.6 s, 19,166,264 bytes, 927,372 gzipped, package rule at precedence 40, stylesheet directory restored exactly afterwards. The Link rel matcher is unit-tested on SaxonJS across five header shapes. docker buildx --check and docker compose config clean.

Not mergeable yet — blocked on a cross-repo dependency

CI is red, and no further change on this branch fixes it. src/main/resources/com/linkeddatahub/packages/skos/package.ttl:17 pins ac:stylesheet to a branch URL:

raw.githubusercontent.com/AtomGraph/LinkedDataHub-Apps/refs/heads/develop/packages/skos/layout.xsl

so the platform fetches that stylesheet from Apps develop at runtime. The Phase 0 change this needs — making a package ac:stylesheet a leaf module, with no static parameters and no import of the host layout — exists only on an unpushed Apps feature branch. layout.xsl is not in the client closure, and xslt3-he has no CLI option for static parameters, so the live copy cannot be composed: the container dies on 833 XPST0008 errors before the healthcheck passes.

Two coupled problems to sequence before this can go green:

  1. The leaf-module fix must land on LinkedDataHub-Apps develop and be pushed.
  2. Apps develop is already one commit ahead unpushed, and that commit removes the Bootstrap stylesheet injection — the very marker http-tests/misc/PATCH-settings-package-import.sh asserts on. Pushing it makes that test fail for a second, independent reason, on develop as well as here. The test needs a new observable first, since the package no longer emits anything into <head>.

Follow-ups this creates

  • A declared extension surface. With the wrapper a package can override anything, including client/** internals never meant as API — so any rename silently breaks packages, as the bs2:ac: move would have.
  • Integrity pinning. Package code now runs in every visitor's browser on the app's origin, while ldh:import remains a mutable reference to a develop URL.
  • Negative caching. While a key is unpublished every render submits a build; a persistently unreachable compiler means one attempt per request.
  • A compile failure is deliberately invisible on the page, since the server still composes — it surfaces only in the log.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

namedgraph and others added 2 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
namedgraph and others added 21 commits September 12, 2026 11:34
…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
…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
@namedgraph
namedgraph merged commit 45517cb into develop Sep 12, 2026
1 check passed
@namedgraph
namedgraph deleted the ft-taxonomy-editor branch September 12, 2026 21:55
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