From 76590179a6fddec776cf2dc0ddb52fc7888429cd Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Tue, 26 May 2026 14:58:44 +0200 Subject: [PATCH] fix(AuthorityResolver): keep admin seed alive across root-def supersession (#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin-tier seed gated the `npa:hasRootAdmin` seed on the root definition's own invalidation (`invalidationFilter("defNp")`). Since `SpacesExtractor` emits `hasRootAdmin` only for the own-root definition, updating a space publishes a continuation revision that re-roots to the original root (no `hasRootAdmin` of its own) and `npx:supersedes` the prior root. After any update every `hasRootAdmin`-bearing definition is invalidated, the seed empties, and the whole admin closure — plus everything cascading from it — produces nothing. So any space whose definition had ever been updated lost all materialized role state. Fix: gate the seed on the space ref still being alive (some non-invalidated definition of the same ref) via the new `spaceRefAliveFilter()`, instead of on the seed definition's own invalidation. The seed is anchored to the immutable root NPID (the space-ref identity), so it survives supersession of the root nanopub by a live continuation; a fully-retracted ref (every definition invalidated) still drops it. Escalation-safe: the candidate admin RI's `npa:pubkeyHash` must still resolve to the seeded root admin. Verified end-to-end on a freshly-synced instance: knowledgepixels (0 admin RIs on all three live instances) now materializes its admin set; genuinely-retracted spaces stay correctly empty; no regressions. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/design-space-repositories.md | 38 +++++++++++---- .../query/AuthorityResolver.java | 47 +++++++++++++++++-- .../query/AuthorityResolverTest.java | 25 ++++++++++ 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/doc/design-space-repositories.md b/doc/design-space-repositories.md index 4a12701..06e80e1 100644 --- a/doc/design-space-repositories.md +++ b/doc/design-space-repositories.md @@ -133,7 +133,9 @@ If the loaded nanopub is additionally the space's root — detectable by `npa:ro npadef: npa:hasRootAdmin , . ``` -These are the trust seed for the admin closure — trusted by construction because the root's NPID is part of the space ref, so no publisher-agent validation is needed. In the rootless transition case the nanopub is its own root, so the same rule applies and its admins seed the per-declaration space ref. Invalidating the root gen:Space nanopub DELETEs the `npadef:` entry (via `npa:viaNanopub`), taking the `npa:hasRootAdmin` seeds with it. +These are the trust seed for the admin closure — trusted by construction because the root's NPID is part of the space ref, so no publisher-agent validation is needed. In the rootless transition case the nanopub is its own root, so the same rule applies and its admins seed the per-declaration space ref. + +The seed is anchored to the root NPID, which *is* the space-ref identity, so it must outlive supersession of the root *nanopub*. Updating a space publishes a new revision that `npx:supersedes` the prior root and re-roots to the same ref (`gen:hasRootDefinition`); being a continuation, it carries no `npa:hasRootAdmin` of its own. The materialiser therefore does **not** gate the seed on the root definition's own invalidation — it gates on the space ref still having at least one non-invalidated definition (see [Validation rule](#validation-rule) and issue #110). Only retracting *every* definition of the ref — the ref going dead — drops the seed. (An earlier design gated the seed with the per-`npa:viaNanopub` invalidation filter, which silently unmaterialised the entire role state of any space whose definition had ever been updated.) If the assertion contains one or more ` gen:isSubSpaceOf ` triples for any declared `` (rooted or transitional), additionally emit one `npa:SubSpaceDeclaration` per `(spaceIri, parentIri)` pair into `npa:spacesGraph`. See [Sub-space relations](#sub-space-relations). @@ -278,7 +280,20 @@ FILTER NOT EXISTS { } ``` -where `?np` is the source nanopub of the candidate being considered (`?entry npa:viaNanopub ?np`, or `?def npa:viaNanopub ?np` for the admin-seed query). `npa:spacesGraph` itself remains purely add-only — the materialiser doesn't write invalidation records there; it reads the raw `npx:invalidates` triple in `npa:graph`, which the loader maintains symmetrically in both load orderings. Covers the out-of-order case where an invalidation lands before its target. +where `?np` is the source nanopub of the candidate being considered (`?entry npa:viaNanopub ?np`). `npa:spacesGraph` itself remains purely add-only — the materialiser doesn't write invalidation records there; it reads the raw `npx:invalidates` triple in `npa:graph`, which the loader maintains symmetrically in both load orderings. Covers the out-of-order case where an invalidation lands before its target. + +The **admin seed** is the exception: it is *not* filtered on the seed definition's own `npa:viaNanopub`. The `npa:hasRootAdmin` seed is anchored to the immutable space-ref identity, so it must survive supersession of the root nanopub by a continuation revision (issue #110). Instead the seed gates on the space ref still being alive — i.e. some definition of the same ref is non-invalidated: + +```sparql +FILTER EXISTS { + GRAPH npa:spacesGraph { + ?liveDef a npa:SpaceDefinition ; npa:forSpaceRef ?spaceRef ; npa:viaNanopub ?liveNp . + } + FILTER NOT EXISTS { GRAPH npa:graph { ?_inv_liveNp npx:invalidates ?liveNp . } } +} +``` + +A fully-retracted ref (every definition invalidated) has no live definition, so the `FILTER EXISTS` fails and the seed correctly disappears. The candidate-side admin `RoleInstantiation` is still filtered by the per-`?np` invalidation rule above (and its `npa:pubkeyHash` must still resolve to the seeded root admin), so a superseding party cannot seed themselves — only re-affirm the founding admin. ### Mirror step @@ -367,14 +382,21 @@ Triggered by a space-relevant nanopub load or invalidation (which bumps `current npa:agent ?publisher ; npa:pubkey ?pkh . { - # Seed: a root-definition entry's hasRootAdmin for this space + # Seed: a root-definition entry's hasRootAdmin for this space. Gated on the + # space ref still being alive (NOT on ?def's own invalidation) so the seed + # survives supersession of the root nanopub by a continuation — issue #110. GRAPH npa:spacesGraph { ?def a npa:SpaceDefinition ; - npa:forSpaceRef [ npa:spaceIri ?space ] ; - npa:hasRootAdmin ?publisher ; - npa:viaNanopub ?defNp . + npa:forSpaceRef ?spaceRef ; + npa:hasRootAdmin ?publisher . + ?spaceRef npa:spaceIri ?space . + } + FILTER EXISTS { + GRAPH npa:spacesGraph { + ?liveDef a npa:SpaceDefinition ; npa:forSpaceRef ?spaceRef ; npa:viaNanopub ?liveNp . + } + FILTER NOT EXISTS { GRAPH npa:graph { ?_inv_liveNp npx:invalidates ?liveNp . } } } - FILTER NOT EXISTS { GRAPH npa:graph { ?_inv_defNp npx:invalidates ?defNp . } } } UNION { ?prev a gen:RoleInstantiation ; @@ -387,7 +409,7 @@ Triggered by a space-relevant nanopub load or invalidation (which bumps `current } } } ``` - For the admin closure's seed triples (`npadef:<…> npa:hasRootAdmin `), the load-number filter is the usual `?def npa:viaNanopub ?np. ?np npa:hasLoadNumber ?ln. FILTER(?ln > ?lastProcessed)` — same shape as every other extraction entry. Maintainer / member / observer tiers follow the same shape: join `?entry npa:viaNanopub ?np. ?np npa:hasLoadNumber ?ln. FILTER(?ln > ?lastProcessed)`, swap the tier-specific publisher constraints. + The admin seed itself carries no load-number or invalidation filter on the seed definition (it is re-evaluated every cycle and gated only on the space ref being alive, per [Validation rule](#validation-rule)); the delta filter `?np npa:hasLoadNumber ?ln. FILTER(?ln > ?lastProcessed)` applies to the candidate admin `RoleInstantiation`'s source `?np`, so a seed contributes work only when a matching instantiation lands in the window. Maintainer / member / observer tiers follow the same shape: join `?entry npa:viaNanopub ?np. ?np npa:hasLoadNumber ?ln. FILTER(?ln > ?lastProcessed)`, swap the tier-specific publisher constraints. 3. Bump `processedUpTo` to the max load number consumed. **Late-arrival handling.** A candidate whose enabling structural event (admin grant, `gen:hasRole` attachment, or `npa:RoleDeclaration`) hasn't happened yet at its own load-time gets filtered by `FILTER(?ln > ?lastProcessed)` and is never retried via delta-only scans. Rule: if a cycle's INSERTs add any new admin grant, validated attachment, or role declaration, immediately re-run the *downstream* tier INSERTs without the load-number filter on the candidate side (keeping only `FILTER NOT EXISTS` dedup). This catches candidates that landed before their enabler, at the cost of a full-scan pass on cycles with structural changes. Pure instantiation-add cycles stay on the fast delta path. diff --git a/src/main/java/com/knowledgepixels/query/AuthorityResolver.java b/src/main/java/com/knowledgepixels/query/AuthorityResolver.java index 5657a1f..c52fdd9 100644 --- a/src/main/java/com/knowledgepixels/query/AuthorityResolver.java +++ b/src/main/java/com/knowledgepixels/query/AuthorityResolver.java @@ -700,6 +700,12 @@ private static String invalidationFilter(String bareVarName) { * plus closed-over admin grants; insert any {@code gen:RoleInstantiation} with * {@code npa:inverseProperty gen:hasAdmin} whose publisher (resolved via mirrored * trust-approved AccountState) is already in the admin set. + * + *

The seed is gated by {@link #spaceRefAliveFilter} (not the per-nanopub + * {@code invalidationFilter("defNp")}): the {@code hasRootAdmin} seed is anchored + * to the root NPID, which is the immutable space-ref identity, so superseding the + * root nanopub with a continuation revision must not strip the seed — + * only retracting every definition of the ref removes it. See issue #110. */ static String adminTierUpdate(IRI graph, long lastProcessed) { // Order tuned for RDF4J's evaluator: @@ -723,12 +729,15 @@ static String adminTierUpdate(IRI graph, long lastProcessed) { WHERE { # 1. Anchor: who is already an admin of which space? { - # Seed branch: root-admin in a non-invalidated SpaceDefinition. + # Seed branch: root-admin of a space ref that is still alive + # (has at least one non-invalidated definition). NOT filtered on + # ?def's own invalidation — superseding the root nanopub with a + # continuation revision must keep the seed; only a fully-retracted + # ref drops it (issue #110). GRAPH <%4$s> { ?def a npa:SpaceDefinition ; npa:forSpaceRef ?spaceRef ; - npa:hasRootAdmin ?publisher ; - npa:viaNanopub ?defNp . + npa:hasRootAdmin ?publisher . ?spaceRef npa:spaceIri ?space . } %7$s @@ -779,10 +788,40 @@ static String adminTierUpdate(IRI graph, long lastProcessed) { SpacesVocab.SPACES_GRAPH, lastProcessed, invalidationFilter("np"), - invalidationFilter("defNp"), + spaceRefAliveFilter(), NPA.GRAPH); } + /** + * Seed-survival filter for the admin tier (issue #110). The {@code hasRootAdmin} + * seed is anchored to the root NPID, which is the immutable space-ref identity, so + * it must survive supersession of the root nanopub by a continuation + * revision (a later definition re-roots to the same ref via + * {@code gen:hasRootDefinition} and so carries no {@code hasRootAdmin} of its own). + * The previous {@code invalidationFilter("defNp")} dropped the seed the moment the + * root revision was superseded, leaving the whole admin closure — and everything + * cascading from it — unmaterialized for any space whose definition had ever been + * updated. + * + *

Expressed positively: the seed survives iff the space ref still has at least + * one non-invalidated {@link SpacesVocab#SPACE_DEFINITION}. A fully-retracted ref + * (every definition invalidated) has no live definition, so the {@code FILTER + * EXISTS} fails and the seed correctly disappears. Anchored on the already-bound + * {@code ?spaceRef}, so it's a targeted lookup over that ref's (few) definitions. + */ + private static String spaceRefAliveFilter() { + return """ + FILTER EXISTS { + GRAPH <%1$s> { + ?liveDef a npa:SpaceDefinition ; + npa:forSpaceRef ?spaceRef ; + npa:viaNanopub ?liveNp . + } + %2$s + } + """.formatted(SpacesVocab.SPACES_GRAPH, invalidationFilter("liveNp")); + } + /** * {@code gen:hasRole} attachment validation: an attachment is validated iff its * publisher is already a validated admin of the target space. Adds diff --git a/src/test/java/com/knowledgepixels/query/AuthorityResolverTest.java b/src/test/java/com/knowledgepixels/query/AuthorityResolverTest.java index 30a649e..6b27864 100644 --- a/src/test/java/com/knowledgepixels/query/AuthorityResolverTest.java +++ b/src/test/java/com/knowledgepixels/query/AuthorityResolverTest.java @@ -94,6 +94,31 @@ void adminTierUpdate_containsSeedAndClosedOverBranches() { "mirrored-row join"); } + @Test + void adminTierUpdate_seedSurvivesRootSupersession_issue110() { + // Issue #110: the hasRootAdmin seed is anchored to the immutable space-ref + // identity (the root NPID), so superseding the root *nanopub* with a + // continuation revision (which re-roots to the same ref and carries no + // hasRootAdmin of its own) must NOT strip the seed. The previous code gated + // the seed on the root definition's own invalidation (invalidationFilter + // on ?defNp), which dropped it the moment the root was superseded — leaving + // the whole admin closure (and everything cascading from it) unmaterialized + // for any space whose definition had ever been updated. + // + // Semantics verified end-to-end against the three live instances (the + // in-memory UPDATE path is unusable here — see this class's javadoc); this + // test locks in the seed-branch structure that produced the fix. + String sparql = AuthorityResolver.adminTierUpdate(TEST_GRAPH, 17); + assertFalse(sparql.contains("?defNp"), + "seed must not be gated on the root definition's own invalidation"); + assertTrue(sparql.contains("?liveDef") && sparql.contains("?liveNp"), + "seed gated on a live (non-invalidated) definition of the same space ref"); + assertTrue(sparql.contains("FILTER EXISTS"), + "space-ref-alive gate expressed as FILTER EXISTS"); + assertTrue(sparql.contains("?_inv_liveNp"), + "live-definition gate has an inner invalidation check on ?liveNp"); + } + @Test void attachmentValidationUpdate_requiresAdminPublisher() { String sparql = AuthorityResolver.attachmentValidationUpdate(TEST_GRAPH, 5);