Skip to content

Develop - #358

Draft
namedgraph wants to merge 19 commits into
masterfrom
develop
Draft

Develop#358
namedgraph wants to merge 19 commits into
masterfrom
develop

Conversation

@namedgraph

Copy link
Copy Markdown
Member

No description provided.

namedgraph and others added 11 commits August 19, 2026 20:18
* Client-side constructor instantiation: one prototype per instance

An edited resource is one rdf:Description with N rdf:types, so its
constructor template must be one rdf:Description with those N types and
the union of their constructor properties - the template mirrors the
instance. /ns?forClass= minted a fresh bnode prototype per class, and
every consumer downstream assumed the single-prototype shape:
document-order-dependent property picks (the intermittently vanishing
app-settings Description field), parent-type match misses,
ldh:SetResourceID orphaning N-1 prototypes in EDIT flows, and
xs:anyURI? cardinality errors on multi-range predicates.

Instead of asking the server to construct, the client fetches the
constructor queries with one SPARQL SELECT (rdfs:subClassOf* closure,
DISTINCT - cycle-safe, shared constructors dedup) and expands the
CONSTRUCT templates onto a single instance:

- ldh:constructor-query builds the SELECT; ldh:construct-instance
  parses each sp:text with SPARQL.js (no execution - constructors must
  have an empty WHERE clause; others are skipped with a warning),
  substitutes ?this with one shared instance label, prefixes marker
  bnodes per constructor, collapses same-(predicate, range) duplicates,
  and serializes through ldh:triples-to-descriptions
- ldh:load-constructed-doc/set-constructed-doc rewired to the SELECT +
  instantiation; all EDIT/violation/CREATE chains covered unchanged
- ldh:construct-forClass split by use-when: SAXON keeps /ns?forClass=
  (SSR), SaxonJS instantiates client-side - covers the tunnel-less
  bs2:FormControl default (SSR EditMode, ldh:DocumentForm,
  ldh:AppSettingsForm)
- add-value flow tokenizes multi-type @typeof (was a latent 500)
- forClass cardinalities relaxed to xs:anyURI* where a predicate can
  have several ranges across the type set (spin:query - sp:Query vs
  sp:Select); bs2:FormControlTypeLabel iterates all ranges like its
  marker-bnode sibling
- GET-ns-constructors.sh pins the constructor SELECT server contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove /ns?forClass= - constructor instantiation is client-side only

The client-side instantiation left server-side rendering as the last
consumer of /ns?forClass=. SSR's constructor controls were transient
paint - the client re-render replaces the form on load - so the SAXON
ldh:construct-forClass becomes an empty-document stub (the ac:construct
stub pattern): server-rendered edit forms show data properties, the
client supplies the constructor controls.

- BREAKING: Namespace no longer serves ?forClass= constructed
  instances; it serves SPARQL queries and the raw ontology graph only.
  Removes the Constructor usage and the unmapped-IRIException 500 on
  malformed class URIs
- GET-namespace-forClass-rdfs.sh removed with the feature; the
  constructor SELECT contract is covered by GET-ns-constructors.sh
- CacheInvalidationFilter's forClass ban stays: it serves the typeahead
  ?forClass= filtering on document URLs, not the removed construct
- $ac:forClass in client.xsl stays: the shadowed Web-Client templates
  compiled into the SEF still reference it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Read SPARQL.js parse output via JSON round-trip

SaxonJS does not marshal plain JS arrays for ixsl:get() access - the
'where' array came back as an empty sequence and the next ixsl:get()
failed with a cardinality error. Serialize the parse tree with
JSON.stringify + json-to-xml (the form.xsl SELECT-builder idiom) and
read the CONSTRUCT template as json:map elements instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep violation machinery types out of the row-form instance type set

ldh:row-form-submit-violation swept rdf:types from every non-document
Description in the violation response, including the
spin:ConstraintViolation / http:Response ones. With per-class
prototypes that was harmless (junk classes just yielded no prototype),
but the union-typed single prototype inherits those types and then
fails bs2:FormControl's every-type-satisfies subset test against the
edited resource - constructor controls vanished from the violation
re-render. Filter the swept Descriptions with the same type list the
bs2:RowForm/bs2:Form suppression templates use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Single-source the violation machinery type list as $system-types

One global drives the bs2:RowForm/bs2:Form suppression matches and the
row-form violation type sweep, so the list cannot silently diverge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop base-uri override from the modal violation re-render

ldh:render-modal-form-violation forced base-uri to the violation
response's base URI, a carry-over from before the render dispatchers
gained the about-fallback (both from 197ab9c). In the creation/edit
flows the two values coincide, but in the app-settings flow the
subject is a urn: outside the response's HTTP space, so the override
flipped bs2:FormControl's $show-subject default and exposed the URI
control on constraint violations while the initial render kept it
hidden. The submit-handler context carries no base-uri, so the
dispatchers now fall back to $ctx('about') on the violation path
exactly as on the initial render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#359)

Remaining leftovers of the 197ab9c shared-chain refactor, sibling
fixes to the base-uri override drop:

- property-uris harvested from everything except the edited resource
  (inverted predicate, contradicting the function's own comment and
  ldh:load-edited-resource) - property labels degraded to the
  local-name fallback on modal violation re-renders while metadata was
  fetched for spin:/sh: machinery terms instead. Flip to
  @Rdf:about = $about.
- object-uris (modal + row-form chains) and the row-form property-uris
  swept the whole response body including the violation machinery
  Descriptions, causing junk label lookups. Exclude $system-types-typed
  Descriptions, mirroring the instance type harvest.
- the shared violation render-ctx stamped the Container/Item required
  function for all flows while the app-settings initial chain passes
  always-true; masked today by ldh:AppSettingsForm's hardcoded
  required=true(). Stamp 'required' per flow in the response handlers
  alongside 'render-fn', and correct the stale 'only used for
  Container/Item instances' comment that hid this.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Self-federation: origin validators through the proxy, foreign write targets, federation suite

The federation test (First Principles ch. 18): one instance's client
browses, queries and writes against another, the wire carrying only
spec-terms. Most of the machinery already existed - the proxy forwards
arbitrary methods with bodies and delegated agent identity, panes carry
data-base/data-endpoint from forwarded Link headers so ldt:base() and
sd:endpoint() (and with them the ns-bound constructor SELECT and
metadata fetches) already resolve against the remote app on remote
panes. What was missing:

- the proxy's Model branch stamped re-serialization validators, so a
  client editing a proxied document sent an If-Match the origin had
  never issued - every cross-instance write 412ed. The origin's
  ETag/Last-Modified now forward (replacing locally stamped values);
  the ResultSet branch keeps builder validators.
- the Add-data flow rejected foreign targets with a guard written when
  the remote end refused proxied writes; with delegation the target
  instance's ACL is the arbiter, so the guard is gone and a remote 403
  surfaces as the form error. Import-ontology keeps its local-target
  guard for a different reason, now stated: the constructor derivation
  is scoped to the local /sparql endpoint.
- nothing proved any of it. New http-tests/federation/ suite runs the
  browse/query/write triad between the root and test dataspaces at two
  origins: Link-header endpoint discovery, SPARQL against the
  discovered endpoint, constructor SELECT against the remote ns,
  ETag parity between proxied and direct reads, a graph-scoped SPARQL
  Update PATCH under the origin's If-Match (stale precondition 412s),
  and the unauthenticated negative. The dataspaces share a triplestore
  below the HTTP surface in the test config; the meeting surface
  exercised is the full HTTP stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Forward conditional request headers through the proxy; fix federation negative

CI caught two gaps in the cross-instance write path:

- the proxy set only Accept and User-Agent on the outbound request, so
  If-Match never reached the origin - a proxied preconditioned write
  ran unconditionally (stale If-Match got 204 instead of 412, losing
  optimistic concurrency). Forward the conditional request headers
  (If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since);
  identity stays delegated (not via Authorization/Cookie) and Range is
  excluded (byte offsets don't survive Model re-serialization).

- the negative test used an anonymous request, which is not actually
  anonymous at the origin: a proxied request with no user certificate
  rides the server's own credential on the proxy->origin leg. Replace
  it with a delegated write by the signed-up agent, which is a known
  federation identity not authorized on B - so B's ACL refuses it,
  which is the property the test means to assert. Both PATCH tests now
  echo the actual status codes (debug-first).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Relay proxied 4xx/5xx error responses verbatim

The header-forwarding fix made the origin actually evaluate proxied
write preconditions - and exposed that the proxy could not relay the
resulting error responses. A 4xx/5xx from the origin carries a
diagnostic body; getResponse routed it through the Model branch, where
readEntity(Model.class) throws on the non-RDF/error body, and the catch
turned it into 502. So a proxied stale If-Match (origin 412) and an
unauthorized delegated write (origin 403) both surfaced as 502.

Relay CLIENT_ERROR/SERVER_ERROR responses raw - status, Content-Type
and origin validators forwarded, body piped - skipping the
re-serialization branches that only make sense for negotiable success
representations. Existing proxy error tests assert status only and are
unaffected; the acl#agent leak checks are on direct (non-proxied)
requests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Match Accept on the conditional PATCH to the read's variant

The valid If-Match returned 412 because LDH ETags are variant-specific
(getVariantEntityTag folds the negotiated media type into the tag): the
test read the ETag with Accept: application/rdf+xml but the conditional
PATCH sent none, so the origin computed the precondition tag for a
different variant. The local edit flow already sends Accept:
application/rdf+xml on its PATCH for this reason. Align the test's
conditional PATCHes with the read. Not a proxy defect - the proxy
forwards Accept and If-Match faithfully (stale correctly 412s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Declarative packages: ldh:import in dataspace settings replaces install endpoints

An application imports a package with a single <app> ldh:import <package-uri>
triple - in config/dataspaces.trig (permanent, applied on restart) or live via
PATCH /settings. Package components are discovered from the package's Linked
Data description; the stylesheet is loaded from its source URL and composed
into the application stylesheet in memory at compile time, per dataspace, with
no restart and no webapp file mutation.

- XsltExecutableFilter composes the app stylesheet document with package
  xsl:imports (SecureXML-parsed, import-set-derived cache key, fallback to
  the plain stylesheet on compile failure)
- LocalStylesheetResolver serves app-origin /static/ xsl:import URLs from the
  webapp instead of HTTPS round-trips through nginx
- packages/install and packages/uninstall endpoints, the admin packages/
  container and ACL entries, the package Actions UI, CLI scripts and their
  tests removed; XSLTMasterUpdater and Package.getStylesheetPath() deleted
- Package catalog is data at https://packages.linkeddatahub.com/ (bundled
  one-entry copy served via the proxy's mapped-URI resolution)
- Application settings modal lists the packages with Installed checkboxes;
  Save applies the change set as one DELETE/INSERT/WHERE PATCH on /settings
- ac:stylesheet values in dataspaces.trig are absolute per-origin URLs
- Known limitation: package ontologies are not yet auto-imported from
  ldh:import (manual owl:imports for now)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Package checkboxes join the settings form as RDF/POST inputs - one Save

The packages fieldset moves inside the application settings form: each row's
Installed checkbox is a pu/ou input pair that serializes an ldh:import triple,
so the form's single Save submits settings and package imports as one PATCH.
The separate packages Save button, the change-set computation and the
SPARQL-generating handlers are removed.

- ldh:parse-rdf-post skips unchecked checkboxes (successful-controls rule of
  HTML form submission, which client-side DOM serialization bypasses)
- the ldh:import hidden-input round-trip is suppressed in the settings form -
  the checkboxes are the sole representation, so unchecking uninstalls
- the settings form response reloads the page on success: the composed
  stylesheet only manifests through a full server-side render

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep package checkboxes across the settings violation re-render

The modal violation chain had no package-catalog load pair, so the
re-rendered settings form lost the package table - and since the
checkboxes are the sole ldh:import representation (the hidden-input
round-trip is suppressed), a Save after fixing a validation error
would submit no ldh:import triples and uninstall every package.

ldh:settings-form-response stamps the package-catalog load pair as
'load-pairs' alongside 'render-fn'/'required', and
ldh:modal-form-submit-violation joins stamped pairs onto its shared
list. Every pair must bake a request, so optional fetches ride per
flow rather than sitting in the shared list. The re-rendered
checkboxes reflect the submitted state: PackageList's installed set
comes from the echoed ldh:import triples in the violation body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Package ontologies join the imports closure, derived from ldh:import

The ontology half of declarative packages, mirroring the stylesheet
composition: when OntologyFilter assembles an application's ontology
closure, each imported package's ontology (from ldt:ontology in the
package description) is assembled as its own owl:imports closure and
added as a union member. Nothing is materialized - no owl:imports
triple is written anywhere, and the raw ontology graph served by /ns
stays untouched. A package ontology that fails to load is skipped so
a broken package cannot take the application ontology down.

Invalidation stays on the plain ontology-URI key that ClearOntology
already removes and rebuilds (now package-aware): a /settings PATCH -
the only way the ldh:import set changes live - evicts the assembled
closure, and the next request re-derives it. The trig-file route
implies a restart, which starts from empty caches anyway.

getPackage moves from XsltExecutableFilter to the system Application
(the filter delegates) so both composition halves share the package
description resolution. New http-test asserts the skos:Concept
constructor appears on /ns after the ldh:import PATCH and disappears
after removal - closure derivation and eviction in one round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Instrument package-ontology removal path (temporary diagnostics)

PATCH-settings-package-ontology.sh fails only on removal: the package
ontology's skos:Concept constructor stays in the closure after the
ldh:import is deleted (adding works). Eviction fires and updateApp
persists to the live context dataset, so static reading can't explain
it. Log what getPackageOntologies actually sees after the DELETE, and
have the test report whether /settings still carries the ldh:import
triple - one CI run then tells us persist-vs-cache. To be reverted once
the cause is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Settings PATCH evicts the ontology from the repository too, not just the closure

Root cause of PATCH-settings-package-ontology.sh failing only on
removal, confirmed by the CI diagnostics: after the DELETE, /settings
carries 0 ldh:import triples and getPackageOntologies returns [] - the
derivation input is correct - yet the skos:Concept constructor stays on
/ns. The OWL2 imports build materializes the package's terms into the
cached base graph in the OntologyRepository; Settings evicted only the
closure union (getOntologyGraphs), leaving that polluted graph cached,
so the remove-phase rebuild read skos straight back. Adding worked
because the first build ran over a clean cache.

Clear both caches, matching ClearOntology (repository graph + fragment-
stripped document alias + closure union), minus the proxy purge since
settings responses are not cached. Revert the temporary
getPackageOntologies logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Settings delegates ontology invalidation to ClearOntology via ResourceContext

Replaces the inline in-memory eviction with a call-through to the shared
ClearOntology logic (the pattern the old InstallPackage used):
getResourceContext().getResource(ClearOntology.class).post(uri, null).
This gets the full invalidation - repository graph + closure union +
varnish BAN/XKEY-PURGE + eager reload - so a settings-driven package
add/remove no longer leaves stale /ns SPARQL responses in the proxy
caches (the in-memory-only eviction fixed the closure but not varnish;
the http-test masked it with a manual purge).

ClearOntology becomes context-agnostic: it resolved the end-user app by
assuming the current app is admin (getApplication().as(AdminApplication)),
which threw for the end-user-scoped /settings. It now resolves both apps
from whichever the request matched and derives the admin backend from the
resolved admin app rather than the current one, so both backends are
purged either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Import ontology persists annotations only - vocabulary via scratch document (#362)

The flow's output becomes the artifact packages ship statically: an
annotation ontology holding the derived class constructors plus
owl:imports of the canonical vocabulary URI. The fetched vocabulary is
scaffolding: PUT into a scratch document (UUID-slugged, under the app
root) that scopes the construct-constructors CONSTRUCT, then deleted -
on the error paths too (client: error wrapper armed once the scratch
exists; CLI: EXIT trap).

The vocabulary resolves live through the graph repository (bundled
mapping or HTTP), identically to package ontologies. This also fixes
constructor derivation for bundled vocabularies: previously the
shipped file shadowed the local copy that held the constructors, so
they never reached the closure (the old test's TO-DO documented it);
now the annotation document enters the closure under its own URI and
imports the vocabulary. Wiring changes accordingly:
add-ontology-import.sh --import <annotation-document>, not the source.

ontologyQuery in web.xml is untouched - the describing-document branch
keeps resolving own-origin ontologies; this flow simply stops minting
shadowing copies.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The bs2:Row GraphMode branch applied bs2:Graph without the required
canvas-id param, crashing the block render with a cardinality error.
Pass it, and initialize the 3D force graph after the row is rendered
by reusing ldh:InitDocumentGraph3D, fed the whole loaded document to
match the bs2:Graph rendering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since #362 constructors live in the annotation ontology document, but
onConstructorUpdate still cleared the ontology derived from the class'
rdfs:isDefinedBy - the canonical vocabulary URI, where constructors
lived only while the import flow minted shadowing copies. The
annotation graph stayed cached in the OntologyRepository, so the
namespace ontology reload rebuilt its closure from the stale graph and
constructor edits never surfaced in instance forms.

The callback now clears ac:document-uri($constructor-uri) - the very
document its PATCH just updated - before proceeding to the namespace
ontology clear. The rdfs:isDefinedBy DESCRIBE round-trip, the
namespace-guessing fallback and the now-unused $type wiring are
removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… edit

Saving a constructor now updates the instance forms already open on the
page instead of leaving them stale until a reload. Once the namespace
ontology clear completes (the point where the constructor closure is
fresh again), every fieldset with property control groups re-runs the
constructor instantiation chain (ldh:load-constructed-doc /
ldh:set-constructed-doc) for its type set and diffs the prototype
against its controls:

- properties the form is missing get bs2:FormControl groups appended at
  the end, before the re-appended property picker - mirroring the
  add-value flow, ordering deferred to the next full render
- value-less control groups whose property the constructor no longer
  asserts are removed; controls holding entered values and rdf:type
  controls are never touched, so user input survives the sync
- a failed constructor fetch leaves the form as it was - removals only
  act on status-200 data

The ns SPARQL response carries a model-derived ETag and no freshness
lifetime, so the re-fetch always revalidates and cannot be served stale
from the browser cache. ldh:ClearNamespace's bogus ldh:NoOp callback is
replaced by the sync fan-out and the NoOp template is deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Modal document save re-renders with the active layout mode

ldh:modal-form-response dropped the URL's ?mode= on the post-save
DocumentNavigate, so saving the btn-edit modal re-rendered the document
in the default mode instead of the active one (e.g. ac:GraphMode).
Pass the current mode through query-params, guarded to same-document
reloads so a PUT overwrite landing in the same branch navigates clean.
Snapshot params (?version/?timemap) deliberately don't survive the save.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove vestigial forClass URL param usages

No LDH flow puts forClass in a URL anymore: the add-constructor
onclick reads @data-for-class (the button @href was inert HTML), chart
save PATCHes the document URI instead of POSTing to charts/?forClass=,
and the ns?forClass= constructor endpoint was replaced by ns?query=.
Drop the button @hrefs, the ldh:build-query forClass arity, and
CacheInvalidationFilter's unreachable forClass ban branch.

The chart form loses @action entirely rather than being repointed:
no charts/ container exists in the app structure, the form is never
submitted (btn-save-chart is type=button and PATCHes the current
document), and bs2:Chart cannot derive the host document anyway -- its
context node is the data document, whose base URI is the SPARQL
request URL for CSR-fetched results. An absent @action targets the
current page URL, which is where the save actually writes. $type and
$doc-type go with it, being unused once the action is gone.

$ac:forClass in client.xsl stays: the imported Web-Client resource.xsl
and document.xsl reference it and the declaring Web-Client layout.xsl
is not part of the CSR import tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#364)

* Quote the Link type parameter per RFC 8288

com.atomgraph.core.util.Link emits `type=` unquoted. A media type
containing a solidus (application/link-format) is not a tchar token, so
RFC 8288 requires it as a quoted-string. Subclass rather than patching
Core so the fix lands without a Core release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Describe the TimeMap with PROV-O instead of the Memento namespace

http://mementoweb.org/ns# has no published vocabulary — it does not
dereference, and RFC 7089 defines only HTTP relation tokens, headers and
the link-format serialization, no RDF. PROV-O is a REC and covers the
same ground: the TimeMap is a prov:Collection of prov:Entity mementos,
each a prov:specializationOf the Original Resource, generatedAtTime its
commit datetime, and wasRevisionOf its predecessor.

The revision chain follows the path-filtered commit list, so adjacent
entries are adjacent revisions of that graph — git parents are
repository-wide and usually never touched the file.

A TimeMap with no mementos now 404s rather than serving an empty
document: it describes no history, and the Original Resource would not
be derivable from the model since it is reached via prov:specializationOf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Serve TimeMaps as application/link-format

RFC 7089 permits a TimeMap to be serialized in various ways but requires
the link-value format to be supported. TimeMapWriter derives it from the
PROV description: the prov:Collection is the TimeMap, its members the
Mementos, and their shared prov:specializationOf target the Original
Resource. Datetimes convert to RFC 1123 at the boundary.

The variant is scoped to the ?timemap response rather than added to the
global writable Model types, so ordinary documents keep negotiating RDF
and HTML only and answer 406 for link-format. It leads the TimeMap's
list so Accept: */* gets the RFC serialization; browsers still resolve to
HTML because they send text/html at a higher q-value.

The formatter is not DateTimeFormatter.RFC_1123_DATE_TIME, which leaves
the day of month unpadded where the RFC 7089 grammar wants 2DIGIT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Serve Memento hypermedia with the registered relation types

RFC 7089 requires different links per response type, so the filter now
branches on the query parameter that distinguishes them: the Original
Resource advertises rel=timemap and MUST NOT carry rel=original; a
Memento MUST link back to its Original Resource; the TimeMap identifies
itself with rel=self. All three use the IANA-registered tokens instead
of Memento-namespace URIs, which nothing in the ecosystem follows, and
carry type="application/link-format" so a client knows what it will get.

Memento-Datetime moves to the same zero-padded formatter as the TimeMap
body, matching the RFC 7089 grammar.

Both Link parsers follow: default.xsl for the server-rendered History
link, client.xsl for the Saxon-JS pass. MEM.java has no consumers left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Page through commit history so TimeMaps are comprehensive

RFC 7089 defines a TimeMap as a comprehensive list of a resource's
Mementos, but listCommits stopped at the first 100 — so on any longer
history the oldest entry was merely where the page ended, and
rel="first memento" would have been a false claim.

The walk is bounded at MAX_COMMIT_PAGES to cap the API calls one TimeMap
request can make; beyond that the result is truncated and logged. Paging
the TimeMap itself (RFC 7089 section 5) is the real answer for
histories that long.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Cover the RFC 7089 contract in the versioning HTTP tests

Asserts the PROV description of the TimeMap, its link-format
serialization, that link-format stays scoped to the TimeMap (406 on an
ordinary document), that the Original Resource does not carry
rel=original while a Memento does, and that Memento-Datetime is a
zero-padded RFC 1123 value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Order mementos by parsed instant, not datetime string

Lexicographic ordering of xsd:dateTime literals holds only while every
value shares one format; parsing removes the assumption. Also corrects a
comment that named only the Memento case while the branch covers the
TimeMap too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add a Memento TimeGate for Accept-Datetime negotiation

Datetime negotiation is the operation Memento exists for, and without a
TimeGate a client can only enumerate versions, never ask for the
document as it stood at a given time.

?timegate selects the Memento closest to Accept-Datetime and answers 302
with its URI in Location and Vary: accept-datetime, carrying no
Memento-Datetime of its own. RFC 7089 leaves the selection algorithm to
the server as long as it is consistent: smallest absolute distance, ties
towards the more recent Memento, most recent when no datetime is asked
for. Parsing accepts any RFC 1123 spelling while responses emit the one
the RFC grammar specifies.

The Original Resource and Mementos now advertise rel=timegate, and the
TimeMap lists it. Being deployment hypermedia rather than version
history, the TimeGate URI comes from the request: PROV has no term for
it, and inventing one in someone else's namespace is what got the
Memento vocabulary retired in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Keep TimeGate redirects out of caches

Varnish defines no vcl_hash or Vary handling of its own, so it honours
Vary: accept-datetime and stores a variant per distinct value — an
unbounded key space. Worse, the write-time ban matches the document URL
and never the ?timegate URL, so a redirect naming the most recent
Memento would survive the commit that displaced it, for the full 24h
default TTL.

Negotiation has to see the current history, so the redirect is no-store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
namedgraph and others added 7 commits August 25, 2026 22:36
…based on RFC 7089 conformance) (#365)

* Version diffs in the history modal

Add From/To version selection to the Memento history modal and render a
client-side triple diff between the two selected versions as Turtle-style
hunks: subject header, then -/+ predicate-object lines (red/green, matching
the From/To column headers), changed values of the same property paired up.

The diff is the set difference over canonical triple keys, computed with the
new shared ldh:triples-map() extracted from the origin-vs-local comparison in
layout.xsl. The shared key gains an rdf:parseType="Literal" branch (XMLLiteral
objects previously keyed on empty text) and an opt-in numeric normalization:
layout.xsl keeps it for cross-serializer comparison, the version diff compares
exact lexical forms.

Both ?version= representations are fetched with a per-branch-context
ixsl:http-request fan-out joined by ixsl:all; no server changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Render version diffs on the document page instead of Turtle text

Compare in the history modal now navigates to ?version=<to>&diff=<from> -
the diff is display state like ?mode=, read from the URL at render time and
never sent to the server, so back/forward re-render it and a plain reload
degrades to the ?version= snapshot.

ldh:RDFDocumentLoad fetches the compared version through a self-guarding
promise step; ldh:rdf-document-response renders the union of both versions
(ldh:merge-metadata reused, plus a prune pass so a changed typed block keeps
only its current rdf:value) with the one-sided triple-key sets tunneled down
the shared template chain. Blocks get diff-added/diff-removed/diff-changed
borders via ldh:diff-class() in the bs2:Row emitters, individual property
values get marked <dd>s, and a changed XHTML block stacks its old content
(red) above the new (green). A legend alert next to the historical-version
notice explains the colors.

ldh:triple-key() is extracted from ldh:triples-map() so the dd/block
decorators and the map builder share one triple identity. The Turtle-hunk
rendering from the previous commit is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Give the diff legend its third color

bootstrap.css defines three block borders — diff-added, diff-removed and
diff-changed — but the legend named only the first two, leaving an
amber-bordered block with nothing explaining it. .text-warning resolves
to #ff7518 in this theme, the same value as the diff-changed border, so
the swatch matches without new CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The GitHub Contents API takes an optimistic lock on the branch head, not
on the file, so concurrent runs committing to main lose the race against
each other. Versioning is best-effort and asynchronous, so the dropped
commits never surface as an error — the document PUT still returns 201
and an unrelated test times out 30 seconds later waiting for a commit
that was discarded. Six overlapping runs on 2026-08-25 failed this way,
each on a different test.

A per-run path prefix isolates the files but not the head, which is the
contended resource. The run now branches from main, points the dataspace
at that branch, and deletes it afterwards; the suite polls whichever
branch it was given.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Restore a document to an earlier version from the history modal

Rollback was the last gap in the versioning feature: history was
readable but there was no way to act on it.

Restore is a per-row action rather than a button beside Compare, because
the From/To radios bound a comparison range while restoring acts on a
single version — reusing them would leave it ambiguous which endpoint
was being restored.

Nothing new is needed server-side. The client reads the memento back and
writes it to the live document, which the versioning filter records as a
new commit, so the restore rolls forward rather than rewriting history
and the versions rolled past stay in the TimeMap. The memento itself is
never written to, so it stays immutable as RFC 7089 requires and
checkSnapshotReadOnly keeps rejecting writes to ?version=.

Guards: the button appears only where the agent holds acl:Write, never
on the version already being viewed (which would commit identical
content), and asks for confirmation first. A failed read or write
reports into the modal rather than dropping the agent out of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Read restore permission from the live document, not the snapshot view

The restore buttons never appeared when History was opened from a
?version= view — which is exactly where they are most wanted, having
just looked at an old version and decided to bring it back.

acl:mode() reflects the current response, and ResponseHeadersFilter
deliberately caps snapshot views at acl:Read so the UI disables edit
affordances there. The TimeMap response is capped for the same reason,
so neither response can answer whether the live document is writable.

Opening the modal now also HEADs the live document and reads its
acl:mode links, using the same Link parse client.xsl uses to seed
acl:mode(). One extra request per modal open, and the answer is about
the resource the restore actually writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents accumulated a dct:created value per container recreate — the
root document of a development instance had 201 of them going back to
June.

The dataset load appends rather than replaces, so re-running it stamps
the install metadata again. Only dct:created accumulates: dct:creator
and acl:owner are identical on every run and the store deduplicates
them, while the timestamp differs each time. The guard against
re-loading is keyed on the based-datasets directory, which lives in the
container's writable layer — a plain restart keeps it and correctly
skips, but every rebuild loses it and reloads onto a store that still
holds the data.

Install metadata now comes from one SPARQL update per app after the
uploads, with the check in the WHERE clause, instead of being appended
to each .nq file beforehand. Documents that already carry dct:created
keep the timestamp they have.

This replaces enrich_document_metadata, which wrote the triples blind.
Doing the same check per document would have meant an ASK per graph —
21 requests for the admin dataset alone, and a wall of log output to
match.

Skipping the whole load when the app's documents already exist would
also have stopped the accumulation, but a newer dataset's documents
would then never arrive. Re-appending content on top of itself is a
no-op anyway, since RDF is a set.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Give the owner and secretary authorizations stable slugs

The entrypoint minted a fresh UUID for these two documents on every run
that loads datasets, so each container recreate left another copy of the
same grant behind. A development instance had 60 of them — 30 identical
"Public owner's WebID" authorizations and 30 for the secretary — against
24 real ones.

Every authorization bundled in admin.trig is named by what it is:
public/, sign-up/, full-control/, oauth2-login/. These two now follow
that convention, which makes re-running the load a no-op by
construction: same document URI, same triples, and RDF is a set. No
check needed, which is why public/ and sign-up/ never accumulated.

A slug is guessable where a UUID is not, but that is already true of the
bundled authorizations, and ACL enforcement rather than URI obscurity is
what protects them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Point the test owner fixture at the same authorization slug

The fixture named its authorization after the owner's *key* UUID, with a
standing TO-DO saying it should use the authorization UUID instead. That
UUID no longer exists, and the document it names is a fixed grant, so it
takes the same slug the entrypoint now uses.

The two paths converge as a result: the fixture and the generated
authorization describe the same document with the same triples, where
before they were two documents granting the same access under different
URIs. The key document keeps its UUID — that is a per-install identity,
not a role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add ldh CLI: Java port of the bin/ HTTP API scripts

Standalone Maven project under cli/ (picocli + Jena 6 + Core's
GraphStoreClient). One command per script with the same options;
bin/ subdirectories become nested subcommand groups. PKCS12 WebID
client-cert auth, env-var defaults, shaded executable jar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Retarget import-ontology at the scratch-document flow, drop the package commands

The CLI was ported against develop at 0e8c831; three of its commands aimed at
endpoints that have since been removed.

import-ontology POSTed a transform argument to {base}transform, which #347
deleted when the transform moved client-side. It now mirrors the rewritten
script (#362): fetch the vocabulary through the Linked Data proxy, PUT it into
a UUID-slugged scratch document, run construct-constructors over that graph via
the SPARQL Protocol dataset specification, append the derived constructors plus
an owl:imports header to the target document, and delete the scratch document
on every exit path - the failure ones included. The query text is read by
dereferencing the query's own document instead of through a SELECT on /sparql;
Jena parses the RDF where bash needed a SPARQL round trip and xmllint.

install-package/uninstall-package POSTed to packages/install and
packages/uninstall, which #361 deleted along with their shell scripts. Packages
are declarative now - an ldh:import triple in the dataspace settings - so
ldh patch on /settings is the whole interface and the command group goes away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Cover the CLI surface the http-tests swap will lean on

Three gaps had no test anywhere. Exit code 2 and the proxy URI rewriting were
already covered; these were not.

- HttpException.check: the >=400 throw carrying status, reason phrase and URI,
  the body excerpt, and its truncation at 1024 characters. Driven against a
  real inbound response - an outbound one built in-process cannot be read back,
  so the excerpt branch was unreachable from a plain unit test.
- The stdout contract the shell pipelines depend on: a created document's URL is
  the only line on stdout, diagnostics go to stderr, and a failed command prints
  nothing on stdout. `container=$(ldh create-container ...)` breaks the moment
  anything else reaches stdout, and the suite consumes that substitution in
  dozens of places.
- stdin: put parses it and resolves relative URIs against the target, patch
  sends the update verbatim, and a malformed update never reaches the server.

StubServer is a JDK com.sun.net.httpserver wrapper - no new dependency - so the
tests exercise the real Jersey/Apache/PKCS12 stack without an instance.

Also fills in the seven model builders that had none: AddGenericService,
AddResultSetChart, AddClass, AddConstructor, AddPropertyConstraint,
AddRestriction, AddObjectBlock.

41 tests to 63.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drive http-tests fixtures with ldh, deprecating the bin/ HTTP scripts

The suite is the CLI's end-to-end coverage: 260 invocations of 19 commands
against a live instance through nginx, Varnish and Tomcat, on every CI run.
Nothing standalone comes close, and with bin/ on the way out its own coverage
is no longer worth preserving.

Only the arrange phase moves. Every assertion in the suite is a curl call
checking a status code or a response body, and not one of them ever asserted
on a bin/ script - the scripts only ever built fixtures, so ldh's
exit-1-on-error behaviour fits where they sat and `set -euo pipefail` still
aborts the test.

Authentication splits by consumer: ldh reads the PKCS12 keystore the platform
generates, curl keeps the PEM derived beside it. run.sh derives the keystore
paths from the certificate paths it is given and fails fast if either is
missing, so its four-argument interface is unchanged. signup.sh stops deleting
the keystore it downloads - that file was always the p12, converted to PEM only
because the scripts needed one.

CI builds cli/ (running its unit tests on the way) and puts the launcher on
PATH; the certificate permission fix now covers the keystores too, which the
PEM-only chmod would have left unreadable on the host.

bin/ keeps the certificate and WebID tooling - webid-keygen.sh, webid-uri.sh,
webid-modulus.sh, server-cert-gen.sh - which talk to no API and are not
deprecated. The HTTP scripts themselves are left in place for now; deleting
them is a separate move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop the Maven cache that broke the job at setup-java

setup-java runs before the checkout step in this workflow, so cache: 'maven'
had an empty workspace to hash and failed the job outright:

  No file in /home/runner/work/... matched to [**/pom.xml, ...],
  make sure you have checked out the target repository

The cache was an unforced optimization on top of building cli/ on the runner -
removing it restores the previous behaviour. Enabling it would mean moving
Checkout above Set up Java, which is a separate change and not needed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Capture add-file's upload URI instead of leaking it onto create-file.sh's stdout

The only test the ldh swap actually broke. bin/add-file.sh ended in
`curl ... -o /dev/null` and printed nothing; `ldh add-file` prints the
content-addressed upload URI, per the convention every create/append command
follows. create-file.sh called it unredirected and then echoed the URL it
recomputed itself, so its stdout carried two lines - and GET-file-304.sh
consumes that stdout as `file=$(./create-file.sh)`, which left it with a
mangled URL and a failing conditional request.

create-file.sh is the only test script whose stdout another test captures, so
this was the only instance of the pattern; the other 259 rewritten invocations
are either captured with $(...) or land in a per-test log run.sh discards.

Rather than redirect the output away, take the URI from ldh and keep the
independent digest as an assertion. That drops the duplicated sha1 computation
and additionally checks the CLI's content-addressing, which nothing covered
before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Start the CLI JVM tuned for startup rather than throughput

Every command is a short-lived process that exits long before C2 could pay for
itself, and spends most of its life waiting on HTTP. -XX:TieredStopAtLevel=1
-XX:+UseSerialGC measures 0.231s to 0.201s per invocation through the launcher
locally; the http-tests suite pays that cost 260 times per CI run.

Sets the floor for what tuning can do here - the structural cost is one JVM per
invocation, which only batching or a native image would remove. Measured at
0.78s per invocation on the CI runner, roughly 3.6x this machine.

LDH_JAVA_OPTS replaces the defaults rather than appending, so a different
collector cannot collide with the one set here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the 17 commits since 45f4d65: the ldh CLI and the http-tests
swap onto it, RFC 7089 conformance (PROV-O TimeMaps, link-format,
TimeGate), version diffs and restore, the entrypoint metadata and
authorization-slug fixes, and the CI versioning branch.

The existing Unreleased entries follow the same rule, which moves
several bug-shaped items out of Changed and into Fixed - the
constructor-instantiation and proxy lines each carried more than one.
Details of a single change stay on its line; only distinct changes
are split apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Written in 2021 and never registered - the call site in
registerContainerResponseFilters has been commented out for as long as
the class has existed, and its own Javadoc says "Currently unused".

It is not a head start on the PROV-O provenance sidecar (P2.3) either:
it records an agent WebID and a timestamp into a fresh urn:uuid: graph
per request, with no HTTP method, no per-dataspace provenance graph and
no link to a version - and the git SHA is the part that would make such
a record worth querying. The 5.9.2 PROV-O work is read-side only, a
serialization vocabulary for the ?timemap response.

PROV.java stays: TimeMapWriter, GraphVersioningService, ImportExecutor
and InvalidWebIDPublicKeyExceptionMapper all use it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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