diff --git a/doc/design-space-repositories.md b/doc/design-space-repositories.md index 06e80e1..a449887 100644 --- a/doc/design-space-repositories.md +++ b/doc/design-space-repositories.md @@ -751,10 +751,60 @@ SELECT DISTINCT ?resource WHERE { The inverse direction (find the maintaining space(s) of a resource) uses ` npa:isMaintainedBy ?space` with the same shape. +## Space aliases (`owl:sameAs`) + +A space may declare ` owl:sameAs ` in its `gen:Space` nanopub — typically when a space is renamed/re-created under a new IRI and earlier role/member nanopubs still point at the old (alias) IRI. Without alias handling, a `gen:hasRole` attachment or role instantiation whose `npa:forSpace` is the alias is keyed on an IRI that has no admin closure (the alias's own definition was superseded when the canonical one was created), so the attachment never validates and the role — with all its members — silently vanishes from the materialized state. See issue #113. + +The design **honors** `owl:sameAs`: an admin of the canonical space governs roles/members attached to a declared alias. Role assignments and instantiations stay keyed on the IRI they were attached to (no row rewriting); authority flows canonical→alias via the admin-authority lookups. + +### Extraction entry (in `npa:spacesGraph`) + +For each ` owl:sameAs ` triple in a `gen:Space` nanopub whose subject is the Space IRI being defined (the embedded path; standalone `owl:sameAs` nanopubs are out of scope), emit one entry. Self-aliases (` owl:sameAs `) are rejected. Prefix `npaalias:` = ``: + +```turtle +GRAPH npa:spacesGraph { + npaalias:_ a npa:SpaceAliasDeclaration ; + npa:canonicalSpace ; # owl:sameAs subject — the space declaring the alias + npa:aliasSpace ; # owl:sameAs object — the absorbed alias + npa:viaNanopub ; + npx:signedBy ; + npa:pubkeyHash "" ; + dct:created ""^^xsd:dateTime . +} +``` + +### Authority rule + +The alias-admit pass runs after the admin closure has settled and before the attachment / role tiers. An alias edge is validated — emitting ` npa:sameAsSpace ` into the space-state graph — iff **both** hold against the current admin closure: + +1. **Authority** — the declaration's publisher (resolved via the mirrored trust-approved `AccountState`) is a validated admin of the **canonical** space. Same evidence rule as a `gen:hasRole` attachment; the alias is declared inside the canonical space's own `gen:Space` nanopub. +2. **Anti-hijack** — the alias must have **no admin who is not also an admin of the canonical space** (`admins(alias) ⊆ admins(canonical)`). The common rename case (`admins(alias) = ∅`, because the alias's definition was superseded) passes trivially; an attacker publishing ` owl:sameAs ` is rejected because the active space has admins not in evil's set. This protects *active* spaces; it is intentionally permissive about fully-defunct alias IRIs (no current stakeholder to harm). + +### Alias-aware authority lookups + +Wherever a tier checks "publisher is an admin (or tier-role holder) of `?space`", the check also accepts authority over a canonical space that `?space` is an alias of: + +```sparql +{ ?adminRI npa:forSpace ?space ; npa:inverseProperty gen:hasAdmin ; npa:forAgent ?publisher . } +UNION +{ ?space npa:sameAsSpace ?canon . + ?adminRI npa:forSpace ?canon ; npa:inverseProperty gen:hasAdmin ; npa:forAgent ?publisher . } +``` + +This branch is added to the `gen:hasRole` attachment validation and to the admin / tiered-role publisher constraints of the maintainer/member/observer tiers. Single alias hop only (no transitive `sameAs` chains). + +### Invalidation + +Invalidating an alias declaration's nanopub is **structural** (flips `npa:needsFullRebuild`): the `npa:SpaceAliasDeclaration` row is DELETEd by subject, while the convenience ` npa:sameAsSpace ` edge is left sticky and cleaned by the next periodic full rebuild — same staleness policy as sub-space declarations. + +### Consumer pattern + +Roles/members remain queryable under the alias IRI directly (they keep their `npa:forSpace`); the canonical view resolves the alias via the alt-ids Nanodash forwards from the space's `owl:sameAs`. The validated equivalence is also readable as ` npa:sameAsSpace ` in the current space-state graph. + ## Implementation phases -1. **Raw loading** — `TripleStore` init, loader writes full nanopubs of predefined types into `spaces` and emits add-only extraction triples into `npa:spacesGraph` with `npa:hasLoadNumber` stamps. Includes `npa:SubSpaceDeclaration` extraction (both embedded-in-`gen:Space` and standalone `gen:isSubSpaceOf` paths), `npa:MaintainedResourceDeclaration` extraction (both embedded-in-`gen:Space` and standalone `gen:isMaintainedBy` paths; see [Maintained resources](#maintained-resources)), and `npa:hasIdPrefix` triples on `npa:SpaceRef` aggregates (see [Sub-space relations](#sub-space-relations)). Invalidations land in `npa:graph` as raw `npx:invalidates` triples (no separate extraction entry); see [Invalidations](#invalidations). -2. **Materialization** — new `AuthorityResolver` drives per-tier SPARQL UPDATE loops on load-number deltas for incremental updates; runs full rebuilds on trust-state flips and on the periodic `npa:needsFullRebuild` signal; manages the `npa:hasCurrentSpaceState` pointer and old-graph cleanup. Includes the explicit-declaration sub-space admit pass (Mode A + Mode B, copying validated `npa:SubSpaceDeclaration` rows into `npass:<…>`), the URL-prefix fallback admit pass (suppressed per child by any non-invalidated declaration), the maintained-resource admit pass (Mode A only, copying validated `npa:MaintainedResourceDeclaration` rows into `npass:<…>` plus convenience ` npa:isMaintainedBy ` / ` npa:hasMaintainedResource ` triples), and the structural-rebuild flag on validated-declaration DELETE. +1. **Raw loading** — `TripleStore` init, loader writes full nanopubs of predefined types into `spaces` and emits add-only extraction triples into `npa:spacesGraph` with `npa:hasLoadNumber` stamps. Includes `npa:SubSpaceDeclaration` extraction (both embedded-in-`gen:Space` and standalone `gen:isSubSpaceOf` paths), `npa:MaintainedResourceDeclaration` extraction (both embedded-in-`gen:Space` and standalone `gen:isMaintainedBy` paths; see [Maintained resources](#maintained-resources)), `npa:SpaceAliasDeclaration` extraction (embedded `owl:sameAs` in `gen:Space` nanopubs; see [Space aliases](#space-aliases-owlsameas)), and `npa:hasIdPrefix` triples on `npa:SpaceRef` aggregates (see [Sub-space relations](#sub-space-relations)). Invalidations land in `npa:graph` as raw `npx:invalidates` triples (no separate extraction entry); see [Invalidations](#invalidations). +2. **Materialization** — new `AuthorityResolver` drives per-tier SPARQL UPDATE loops on load-number deltas for incremental updates; runs full rebuilds on trust-state flips and on the periodic `npa:needsFullRebuild` signal; manages the `npa:hasCurrentSpaceState` pointer and old-graph cleanup. Includes the explicit-declaration sub-space admit pass (Mode A + Mode B, copying validated `npa:SubSpaceDeclaration` rows into `npass:<…>`), the URL-prefix fallback admit pass (suppressed per child by any non-invalidated declaration), the maintained-resource admit pass (Mode A only, copying validated `npa:MaintainedResourceDeclaration` rows into `npass:<…>` plus convenience ` npa:isMaintainedBy ` / ` npa:hasMaintainedResource ` triples), the alias-admit pass (publisher-admin + anti-hijack gates, emitting ` npa:sameAsSpace ` consumed by the alias-aware authority lookups; see [Space aliases](#space-aliases-owlsameas)), and the structural-rebuild flag on validated-declaration DELETE. 3. **Routes / metrics** — `/spaces` listing route (HTML + JSON), Prometheus gauges (rebuild duration, delta size, `processedUpTo` lag, distinct-subject totals). 4. **Nanodash migration** — publish with `gen:hasRootDefinition` and the predefined type IRIs; replace the 4-query chain with one query that resolves the current `npass:*` graph from the pointer (see [Querying the current space-state graph](#querying-the-current-space-state-graph)); drop `isAdminPubkey` gate and pinned templates/queries. Replace `SpaceRepository.findSubspaces(...)` URL regex with the single-query consumer pattern in [Sub-space relations](#sub-space-relations). diff --git a/src/main/java/com/knowledgepixels/query/AuthorityResolver.java b/src/main/java/com/knowledgepixels/query/AuthorityResolver.java index c52fdd9..040b864 100644 --- a/src/main/java/com/knowledgepixels/query/AuthorityResolver.java +++ b/src/main/java/com/knowledgepixels/query/AuthorityResolver.java @@ -43,7 +43,7 @@ * *

Incremental cycle order: invalidation DELETEs (admin RI / RoleAssignment / * non-admin RI) → mirror-step delta is implicit (rebuilt only on full build) → - * per-tier INSERTs (admin → attachment → maintainer → member → observer) → + * per-tier INSERTs (admin → alias → attachment → maintainer → member → observer) → * late-arrival sweep (re-run downstream tiers without the load-number filter * iff this cycle added any structural rows). Sets {@code npa:needsFullRebuild} * when an admin RI / RoleAssignment / RoleDeclaration was invalidated; periodic @@ -241,18 +241,18 @@ synchronized void runFullBuild(String trustStateHash) { TierSubjectTotals totals = computeTierSubjectTotals(newGraph); long durationMs = (System.nanoTime() - startNanos) / 1_000_000L; lastSubjectTotals = totals; - lastInsertedTriplesTotal = (long) counts.admin + counts.attachment + lastInsertedTriplesTotal = (long) counts.admin + counts.alias + counts.attachment + counts.maintainer + counts.member + counts.observer + counts.subSpace + counts.subSpacePrefix + counts.maintainedResource; lastFullBuildDurationMs = durationMs; lastProcessedUpToLag = 0L; log.info("AuthorityResolver: full build complete — graph={} mirrored={} rows loadCounter={} " + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} " - + "(inserted-triples: admin={} attachment={} maintainer={} member={} observer={} " + + "(inserted-triples: admin={} alias={} attachment={} maintainer={} member={} observer={} " + "subspace={} subspace-prefix={} maintained-resource={}) durationMs={}", newGraph, mirrored, loadCounter, totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(), - counts.admin, counts.attachment, counts.maintainer, counts.member, counts.observer, + counts.admin, counts.alias, counts.attachment, counts.maintainer, counts.member, counts.observer, counts.subSpace, counts.subSpacePrefix, counts.maintainedResource, durationMs); } @@ -297,6 +297,7 @@ synchronized void runIncrementalCycle(IRI graph) { boolean structuralInvalidation = applyInvalidations(graph, lastProcessed); TierInsertedTriples counts = runAllTierLoops(graph, lastProcessed); boolean structuralAdds = (counts.admin > 0) + || (counts.alias > 0) || (counts.attachment > 0) || (counts.subSpace > 0) || newRoleDeclarationsArrived(lastProcessed); @@ -310,6 +311,7 @@ synchronized void runIncrementalCycle(IRI graph) { // admin tier — its only enabling event is the admin grant itself, // already handled by the regular pass. TierInsertedTriples lateCounts = runDownstreamWithoutLoadFilter(graph); + counts.alias += lateCounts.alias; counts.attachment += lateCounts.attachment; counts.maintainer += lateCounts.maintainer; counts.member += lateCounts.member; @@ -324,18 +326,18 @@ synchronized void runIncrementalCycle(IRI graph) { TierSubjectTotals totals = computeTierSubjectTotals(graph); long durationMs = (System.nanoTime() - startNanos) / 1_000_000L; lastSubjectTotals = totals; - lastInsertedTriplesTotal = (long) counts.admin + counts.attachment + lastInsertedTriplesTotal = (long) counts.admin + counts.alias + counts.attachment + counts.maintainer + counts.member + counts.observer + counts.subSpace + counts.subSpacePrefix + counts.maintainedResource; lastIncrementalCycleDurationMs = durationMs; log.info("AuthorityResolver: incremental cycle complete — graph={} delta=({}, {}] " + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} " - + "(inserted-triples: admin={} attachment={} maintainer={} member={} observer={} " + + "(inserted-triples: admin={} alias={} attachment={} maintainer={} member={} observer={} " + "subspace={} subspace-prefix={} maintained-resource={}) " + "structuralInvalidation={} structuralAdds={} durationMs={}", graph, lastProcessed, currentLoadCounter, totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(), - counts.admin, counts.attachment, counts.maintainer, counts.member, counts.observer, + counts.admin, counts.alias, counts.attachment, counts.maintainer, counts.member, counts.observer, counts.subSpace, counts.subSpacePrefix, counts.maintainedResource, structuralInvalidation, structuralAdds, durationMs); } @@ -376,6 +378,16 @@ boolean applyInvalidations(IRI graph, long lastProcessed) { executeUpdate(subSpaceInvalidationDelete(graph, lastProcessed)); structural = true; } + // Space-alias declarations are structural — invalidating one removes an + // owl:sameAs edge that feeds the admin-authority closure (issue #113). The + // DELETE removes the per-declaration row; the convenience npa:sameAsSpace edge + // is left sticky and cleaned on the next periodic rebuild (same policy as + // sub-space declarations). + if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false, + aliasInvalidationCheckWhere(graph, lastProcessed))) { + executeUpdate(aliasInvalidationDelete(graph, lastProcessed)); + structural = true; + } // Leaf-tier RI deletes — no flag. executeUpdate(leafTierInvalidationDelete(graph, lastProcessed)); // Maintained-resource declaration deletes — no flag (leaf relation, no @@ -393,6 +405,11 @@ boolean applyInvalidations(IRI graph, long lastProcessed) { */ TierInsertedTriples runDownstreamWithoutLoadFilter(IRI graph) { TierInsertedTriples c = new TierInsertedTriples(); + // Alias late-arrival: catches alias declarations whose canonical admin grant + // became valid only in this same cycle (the load-number filter on the + // declaration's nanopub would otherwise exclude it). Runs first so the + // attachment / role tiers below see this cycle's fresh npa:sameAsSpace edges. + c.alias = runTierLabeled("alias(late)", graph, aliasAdmitUpdate(graph, -1)); // Sub-space late-arrival: catches Mode-B candidates whose primary // declaration is older than lastProcessed but whose partner just landed. c.subSpace = runTierLabeled("subspace(late)", graph, @@ -467,6 +484,7 @@ boolean newRoleDeclarationsArrived(long lastProcessed) { */ static final class TierInsertedTriples { int admin; + int alias; int attachment; int maintainer; int member; @@ -493,6 +511,11 @@ record TierSubjectTotals(long adminRIs, long attachmentRAs, long nonAdminRIs) {} TierInsertedTriples runAllTierLoops(IRI graph, long lastProcessed) { TierInsertedTriples c = new TierInsertedTriples(); c.admin = runTierLabeled("admin", graph, adminTierUpdate(graph, lastProcessed)); + // Alias admit runs after the admin closure has settled (both the authority + // gate and the anti-hijack check read the admin set) and before attachment / + // role tiers (their alias-aware admin lookups consume the npa:sameAsSpace edge + // this pass emits). See issue #113. + c.alias = runTierLabeled("alias", graph, aliasAdmitUpdate(graph, lastProcessed)); // Sub-space admit runs after admin closure has settled (Mode A + Mode B both // need the admin set). Independent of role tiers — order between subspace // and attachment / maintainer / member / observer doesn't matter. @@ -548,9 +571,20 @@ private static String publisherIsTieredRole(IRI tierClass) { ?acct a npa:AccountState ; npa:pubkey ?pkh ; npa:agent ?publisher . - ?tierRI a gen:RoleInstantiation ; - npa:forSpace ?space ; - npa:forAgent ?publisher . + # Tier-role holder in ?space directly, or in a canonical space that + # ?space is an owl:sameAs alias of (issue #113). + { + ?tierRI a gen:RoleInstantiation ; + npa:forSpace ?space ; + npa:forAgent ?publisher . + } + UNION + { + ?space npa:sameAsSpace ?canon . + ?tierRI a gen:RoleInstantiation ; + npa:forSpace ?canon ; + npa:forAgent ?publisher . + } ?rdT a npa:RoleDeclaration ; npa:hasRoleType <%1$s> . { ?tierRI npa:regularProperty ?predT . ?rdT gen:hasRegularProperty ?predT . } @@ -853,10 +887,22 @@ static String attachmentValidationUpdate(IRI graph, long lastProcessed) { ?acct a npa:AccountState ; npa:agent ?publisher ; npa:pubkey ?pkh . - ?adminRI a gen:RoleInstantiation ; - npa:forSpace ?space ; - npa:inverseProperty gen:hasAdmin ; - npa:forAgent ?publisher . + # Admin of ?space directly, or admin of a canonical space that + # ?space is an owl:sameAs alias of (issue #113). + { + ?adminRI a gen:RoleInstantiation ; + npa:forSpace ?space ; + npa:inverseProperty gen:hasAdmin ; + npa:forAgent ?publisher . + } + UNION + { + ?space npa:sameAsSpace ?canon . + ?adminRI a gen:RoleInstantiation ; + npa:forSpace ?canon ; + npa:inverseProperty gen:hasAdmin ; + npa:forAgent ?publisher . + } } %6$s FILTER NOT EXISTS { GRAPH <%3$s> { @@ -888,10 +934,22 @@ static String attachmentValidationUpdate(IRI graph, long lastProcessed) { ?acct a npa:AccountState ; npa:pubkey ?pkh ; npa:agent ?publisher . - ?adminRI a gen:RoleInstantiation ; - npa:forSpace ?space ; - npa:inverseProperty gen:hasAdmin ; - npa:forAgent ?publisher . + # Admin of ?space directly, or admin of a canonical space that ?space is + # an owl:sameAs alias of (issue #113). + { + ?adminRI a gen:RoleInstantiation ; + npa:forSpace ?space ; + npa:inverseProperty gen:hasAdmin ; + npa:forAgent ?publisher . + } + UNION + { + ?space npa:sameAsSpace ?canon . + ?adminRI a gen:RoleInstantiation ; + npa:forSpace ?canon ; + npa:inverseProperty gen:hasAdmin ; + npa:forAgent ?publisher . + } """; /** Observer self-evidence: the assignee's own pubkey signed the instantiation. */ @@ -1189,6 +1247,107 @@ static String maintainedResourceAdmitUpdate(IRI graph, long lastProcessed) { NPA.GRAPH); } + /** + * Space-alias admit pass (issue #113). Copies validated + * {@code npa:SpaceAliasDeclaration} extraction rows into the space-state graph + * (preserving the {@code npaalias:} subject) and emits the directional + * {@code npa:sameAsSpace } edge consumed by the alias-aware + * admin-authority lookups in {@link #attachmentValidationUpdate}, + * {@link #PUBLISHER_IS_ADMIN}, and {@link #publisherIsTieredRole}. + * + *

Two gates, both read against the (already-settled) admin closure in the + * space-state graph: + *

    + *
  • Authority — the declaration's publisher (resolved via the mirrored + * trust-approved {@code AccountState}) is a validated admin of the + * canonical space. The alias is declared inside the canonical + * space's own {@code gen:Space} nanopub, so this is the same evidence rule + * as a {@code gen:hasRole} attachment.
  • + *
  • Anti-hijack — the alias must not be an independently-governed live + * space: it must have no admin who is not also an admin of the canonical + * space ({@code admins(alias) ⊆ admins(canonical)}). The common rename case + * (the alias's own definition was superseded, so it has no live admin + * closure) passes trivially; an attacker publishing + * {@code owl:sameAs } is rejected because the active + * space has admins not in evil's set.
  • + *
+ * + *

Late-arrival: when the canonical admin grant only becomes valid in the same + * cycle as the declaration, the load-number filter on {@code ?np} excludes the + * candidate; the late-arrival sweep ({@link #runDownstreamWithoutLoadFilter}) + * re-runs this pass without the load filter and catches it. + */ + static String aliasAdmitUpdate(IRI graph, long lastProcessed) { + return """ + PREFIX npa: <%1$s> + PREFIX gen: <%2$s> + INSERT { GRAPH <%3$s> { + ?d a npa:SpaceAliasDeclaration ; + npa:canonicalSpace ?canonical ; + npa:aliasSpace ?alias ; + npa:viaNanopub ?np . + ?alias npa:sameAsSpace ?canonical . + } } + WHERE { + # 1. Anchor: candidate alias declarations from the extraction graph. + GRAPH <%4$s> { + ?d a npa:SpaceAliasDeclaration ; + npa:canonicalSpace ?canonical ; + npa:aliasSpace ?alias ; + npa:pubkeyHash ?pkh ; + npa:viaNanopub ?np . + } + # 2. Mirror + authority gate: publisher is a validated admin of the + # canonical space. + GRAPH <%3$s> { + ?acct a npa:AccountState ; + npa:pubkey ?pkh ; + npa:agent ?publisher . + ?adminRI a gen:RoleInstantiation ; + npa:inverseProperty gen:hasAdmin ; + npa:forSpace ?canonical ; + npa:forAgent ?publisher . + } + # 3. Anti-hijack: the alias must have no admin who is not also an + # admin of the canonical space (admins(alias) ⊆ admins(canonical)). + FILTER NOT EXISTS { + GRAPH <%3$s> { + ?aliasAdmin a gen:RoleInstantiation ; + npa:inverseProperty gen:hasAdmin ; + npa:forSpace ?alias ; + npa:forAgent ?otherAgent . + } + FILTER NOT EXISTS { + GRAPH <%3$s> { + ?canonAdmin a gen:RoleInstantiation ; + npa:inverseProperty gen:hasAdmin ; + npa:forSpace ?canonical ; + npa:forAgent ?otherAgent . + } + } + } + # 4. Invalidation filter on the declaration's nanopub. + %6$s + # 5. Load-number filter on bound ?np. + GRAPH <%7$s> { + ?np npa:hasLoadNumber ?ln . + FILTER (?ln > %5$d) + } + # 6. Dedup last. + FILTER NOT EXISTS { GRAPH <%3$s> { + ?d a npa:SpaceAliasDeclaration . + } } + } + """.formatted( + NPA.NAMESPACE, + GEN.NAMESPACE, + graph, + SpacesVocab.SPACES_GRAPH, + lastProcessed, + invalidationFilter("np"), + NPA.GRAPH); + } + /** * URL-prefix sub-space fallback admit pass. For every pair of {@code SpaceRef} * aggregates where the child's {@code npa:hasIdPrefix} matches the parent's @@ -1461,6 +1620,50 @@ static String maintainedResourceInvalidationDelete(IRI graph, long lastProcessed NPA.GRAPH, NPX.INVALIDATES, lastProcessed); } + /** + * WHERE clause shared by the alias invalidation ASK precheck and the matching + * DELETE. Identifies validated {@code npa:SpaceAliasDeclaration} rows in the + * space-state graph whose {@code npa:viaNanopub} is the target of an + * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a + * load number in {@code (lastProcessed, ∞)}. + */ + static String aliasInvalidationCheckWhere(IRI graph, long lastProcessed) { + return String.format(""" + GRAPH <%1$s> { + ?d a npa:SpaceAliasDeclaration ; + npa:viaNanopub ?np . + } + GRAPH <%2$s> { + ?invNp <%3$s> ?np ; + npa:hasLoadNumber ?ln . + FILTER (?ln > %4$d) + } + """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed); + } + + /** + * DELETE template for validated {@code npa:SpaceAliasDeclaration} rows whose + * source nanopub was invalidated. Removes the per-declaration row by subject; the + * convenience {@code npa:sameAsSpace } edge is left sticky and + * cleaned by the next periodic full rebuild (same staleness policy as sub-space + * declaration invalidation — the alias feeds the authority closure, so this kind + * is structural and flips {@code npa:needsFullRebuild}). + */ + static String aliasInvalidationDelete(IRI graph, long lastProcessed) { + return String.format(""" + PREFIX npa: <%1$s> + PREFIX gen: <%2$s> + DELETE { GRAPH <%3$s> { + ?d ?p ?o . + } } + WHERE { + GRAPH <%3$s> { ?d ?p ?o . } + %4$s + } + """, NPA.NAMESPACE, GEN.NAMESPACE, graph, + aliasInvalidationCheckWhere(graph, lastProcessed)); + } + /** Wraps an ASK by joining the shared prefixes. */ private boolean wouldInvalidate(IRI graph, long lastProcessed, boolean adminPinned, String whereClause) { diff --git a/src/main/java/com/knowledgepixels/query/SpacesExtractor.java b/src/main/java/com/knowledgepixels/query/SpacesExtractor.java index 8452a23..b9dcee6 100644 --- a/src/main/java/com/knowledgepixels/query/SpacesExtractor.java +++ b/src/main/java/com/knowledgepixels/query/SpacesExtractor.java @@ -17,6 +17,7 @@ import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.model.impl.SimpleValueFactory; import org.eclipse.rdf4j.model.vocabulary.DCTERMS; +import org.eclipse.rdf4j.model.vocabulary.OWL; import org.eclipse.rdf4j.model.vocabulary.RDF; import org.nanopub.Nanopub; import org.nanopub.NanopubUtils; @@ -227,6 +228,14 @@ private static void emitSpaceEntry(Nanopub np, Context ctx, IRI spaceIri, IRI ro // object equals the Space being defined. Same shape as the standalone path. emitMaintainedResourceDeclarations(np, ctx, spaceIri, out); + // Embedded owl:sameAs triples: owl:sameAs declares that + // is an alias of the Space being defined. Emit one + // SpaceAliasDeclaration per (spaceIri, aliasIri) pair so the materializer can + // let this space's admin authority cover roles/members attached to the alias + // (issue #113). Carries provenance — the materializer gates the edge on the + // declaration's publisher being an admin of the canonical space. + emitSpaceAliasDeclarations(np, ctx, spaceIri, out); + // Per-contributor entry: signer, pubkey, created-at, link back to nanopub. out.add(vf.createStatement(defIri, RDF.TYPE, SpacesVocab.SPACE_DEFINITION, GRAPH)); out.add(vf.createStatement(defIri, SpacesVocab.FOR_SPACE_REF, refIri, GRAPH)); @@ -612,6 +621,52 @@ private static void emitMaintainedResourceDeclaration(Nanopub np, Context ctx, I addProvenance(subject, ctx, out); } + // ---------------- owl:sameAs (space aliases) ---------------- + + /** + * Scans a {@code gen:Space} nanopub's assertion for + * {@code owl:sameAs } triples (subject must equal the Space IRI + * being emitted, so the alias declaration is bound to this particular Space) and emits + * one {@code npa:SpaceAliasDeclaration} per {@code (spaceIri, aliasIri)} pair. The + * Space IRI is the canonical side; the {@code owl:sameAs} object is the alias. + * Self-aliases ({@code owl:sameAs }) are rejected. + */ + private static void emitSpaceAliasDeclarations(Nanopub np, Context ctx, IRI spaceIri, + List out) { + for (Statement st : np.getAssertion()) { + if (!st.getPredicate().equals(OWL.SAMEAS)) continue; + if (!spaceIri.equals(st.getSubject())) continue; + if (!(st.getObject() instanceof IRI aliasIri)) continue; + emitSpaceAliasDeclaration(np, ctx, spaceIri, aliasIri, out); + } + } + + /** + * Emits one {@code npa:SpaceAliasDeclaration} entry, keyed by + * {@code (artifactCode, aliasHash)} so a single nanopub can declare multiple aliases + * without subject collision. Self-aliases are silently dropped. + */ + private static void emitSpaceAliasDeclaration(Nanopub np, Context ctx, IRI canonicalIri, + IRI aliasIri, List out) { + if (canonicalIri.equals(aliasIri)) { + log.debug("Ignoring self-alias declaration on {} in {}", canonicalIri, np.getUri()); + return; + } + String aliasHash = Utils.createHash(aliasIri); + IRI subject = SpacesVocab.forSpaceAliasDeclaration(ctx.artifactCode(), aliasHash); + + // Idempotence: a single (np, canonical, alias) combination should produce one entry + // even if emitSpaceAliasDeclarations somehow sees the triple twice. + Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SPACE_ALIAS_DECLARATION, GRAPH); + if (out.contains(typeSt)) return; + + out.add(typeSt); + out.add(vf.createStatement(subject, SpacesVocab.CANONICAL_SPACE, canonicalIri, GRAPH)); + out.add(vf.createStatement(subject, SpacesVocab.ALIAS_SPACE, aliasIri, GRAPH)); + out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH)); + addProvenance(subject, ctx, out); + } + // ---------------- ID-prefix enumeration ---------------- /** diff --git a/src/main/java/com/knowledgepixels/query/vocabulary/SpacesVocab.java b/src/main/java/com/knowledgepixels/query/vocabulary/SpacesVocab.java index bbcc752..b8e087e 100644 --- a/src/main/java/com/knowledgepixels/query/vocabulary/SpacesVocab.java +++ b/src/main/java/com/knowledgepixels/query/vocabulary/SpacesVocab.java @@ -22,6 +22,7 @@ *

  • {@link #NPARD_NAMESPACE} ({@code npard:}) — {@link #forRoleDeclaration(String) role-declaration} entries (from {@code gen:SpaceMemberRole} nanopubs). *
  • {@link #NPASUB_NAMESPACE} ({@code npasub:}) — {@link #forSubSpaceDeclaration(String, String) sub-space-declaration} entries (one per {@code (child, parent)} pair). *
  • {@link #NPAMRD_NAMESPACE} ({@code npamrd:}) — {@link #forMaintainedResourceDeclaration(String, String) maintained-resource-declaration} entries (one per {@code (resource, space)} pair). + *
  • {@link #NPAALIAS_NAMESPACE} ({@code npaalias:}) — {@link #forSpaceAliasDeclaration(String, String) space-alias-declaration} entries (one per {@code owl:sameAs} pair). *
  • {@link #NPASS_NAMESPACE} ({@code npass:}) — space-state graph IRIs (used by the materializer in a later PR). * */ @@ -43,6 +44,8 @@ public final class SpacesVocab { public static final String NPASUB_NAMESPACE = "http://purl.org/nanopub/admin/subspace/"; /** Namespace for maintained-resource-declaration entries ({@code npamrd:_}). */ public static final String NPAMRD_NAMESPACE = "http://purl.org/nanopub/admin/maintainedresource/"; + /** Namespace for space-alias-declaration entries ({@code npaalias:_}). */ + public static final String NPAALIAS_NAMESPACE = "http://purl.org/nanopub/admin/spacealias/"; /** Namespace for space-state graph IRIs ({@code npass:_}). */ public static final String NPASS_NAMESPACE = "http://purl.org/nanopub/admin/spacestate/"; @@ -63,6 +66,9 @@ public final class SpacesVocab { /** RDF type for a maintained-resource-declaration extraction entry. */ public static final IRI MAINTAINED_RESOURCE_DECLARATION = vf.createIRI(NPA.NAMESPACE, "MaintainedResourceDeclaration"); + /** RDF type for a space-alias-declaration extraction entry (from {@code owl:sameAs} in a {@code gen:Space} nanopub). */ + public static final IRI SPACE_ALIAS_DECLARATION = vf.createIRI(NPA.NAMESPACE, "SpaceAliasDeclaration"); + // -------- Properties on extraction entries -------- /** Links a space-ref aggregate to its user-facing Space IRI. */ @@ -116,6 +122,19 @@ public final class SpacesVocab { /** Links a {@link #MAINTAINED_RESOURCE_DECLARATION} to the maintaining Space IRI. */ public static final IRI MAINTAINER_SPACE = vf.createIRI(NPA.NAMESPACE, "maintainerSpace"); + /** Links a {@link #SPACE_ALIAS_DECLARATION} to the canonical Space IRI (the {@code owl:sameAs} subject). */ + public static final IRI CANONICAL_SPACE = vf.createIRI(NPA.NAMESPACE, "canonicalSpace"); + + /** Links a {@link #SPACE_ALIAS_DECLARATION} to the alias Space IRI (the {@code owl:sameAs} object). */ + public static final IRI ALIAS_SPACE = vf.createIRI(NPA.NAMESPACE, "aliasSpace"); + + /** + * Validated alias edge written into the space-state graph: {@code npa:sameAsSpace }. + * Materialized by the alias-admit tier once a {@link #SPACE_ALIAS_DECLARATION} passes the + * publisher-admin and anti-hijack gates; consumed by the alias-aware admin-authority lookups. + */ + public static final IRI SAME_AS_SPACE = vf.createIRI(NPA.NAMESPACE, "sameAsSpace"); + /** * Links a {@link #SPACE_REF} aggregate to each intermediate path-prefix of its * Space IRI (down to host-only). Identity-derived; reinforced by every contributor. @@ -207,6 +226,18 @@ public static IRI forMaintainedResourceDeclaration(String artifactCode, String r return vf.createIRI(NPAMRD_NAMESPACE, artifactCode + "_" + resourceHash); } + /** + * Mints {@code npaalias:_} for a space-alias-declaration entry. + * Including the alias-IRI hash in the local name lets a single nanopub declare multiple + * aliases without subject collision. + * + * @param artifactCode trusty-URI artifact code of the originating nanopub + * @param aliasHash {@code Utils.createHash()} + */ + public static IRI forSpaceAliasDeclaration(String artifactCode, String aliasHash) { + return vf.createIRI(NPAALIAS_NAMESPACE, artifactCode + "_" + aliasHash); + } + /** * Mints {@code npass:_} for a space-state graph. * diff --git a/src/test/java/com/knowledgepixels/query/AuthorityResolverTest.java b/src/test/java/com/knowledgepixels/query/AuthorityResolverTest.java index 6b27864..b152f56 100644 --- a/src/test/java/com/knowledgepixels/query/AuthorityResolverTest.java +++ b/src/test/java/com/knowledgepixels/query/AuthorityResolverTest.java @@ -473,4 +473,95 @@ void maintainedResourceInvalidationDelete_targetsDeclarationRowsOnly() { "direct triples are not part of the DELETE — sticky until rebuild"); } + // ---------------- Space-alias admit + invalidation (issue #113) ---------------- + + @Test + void aliasAdmitUpdate_validatesAndEmitsSameAsEdge() { + String sparql = AuthorityResolver.aliasAdmitUpdate(TEST_GRAPH, 5); + assertTrue(sparql.contains("INSERT"), "INSERT clause"); + assertTrue(sparql.contains("npa:SpaceAliasDeclaration"), + "anchors on alias-declaration extraction rows"); + assertTrue(sparql.contains("?alias npa:sameAsSpace ?canonical"), + "emits the directional alias -> canonical edge"); + // Authority gate: publisher must be a validated admin of the canonical space. + assertTrue(sparql.contains("npa:inverseProperty gen:hasAdmin") + && sparql.contains("npa:forSpace ?canonical"), + "publisher-is-admin-of-canonical gate"); + assertTrue(sparql.contains("npa:AccountState"), "resolves pubkey -> publisher"); + assertTrue(sparql.contains("FILTER (?ln > 5)"), + "delta filter on the declaration nanopub"); + assertTrue(sparql.contains("FILTER NOT EXISTS"), + "anti-hijack + dedup filters present"); + } + + @Test + void aliasAdmitUpdate_antiHijackRequiresAliasAdminsSubsetOfCanonical() { + // The alias must have no admin who is not also an admin of the canonical + // space (admins(alias) ⊆ admins(canonical)); otherwise an attacker could + // publish owl:sameAs and govern the active space. + String sparql = AuthorityResolver.aliasAdmitUpdate(TEST_GRAPH, 5); + assertTrue(sparql.contains("?aliasAdmin") && sparql.contains("npa:forSpace ?alias"), + "anti-hijack inspects admins of the alias space"); + assertTrue(sparql.contains("?canonAdmin") && sparql.contains("npa:forSpace ?canonical"), + "anti-hijack compares against admins of the canonical space"); + // Nested NOT EXISTS = "no alias admin who is not a canonical admin". + assertTrue(sparql.indexOf("FILTER NOT EXISTS") != sparql.lastIndexOf("FILTER NOT EXISTS"), + "anti-hijack uses a nested FILTER NOT EXISTS"); + } + + @Test + void aliasAdmitUpdate_hasInvalidationFilterAndDedup() { + String sparql = AuthorityResolver.aliasAdmitUpdate(TEST_GRAPH, 0); + assertTrue(sparql.contains("?_inv_np"), + "invalidation filter on the declaration nanopub"); + // The declaration type appears in the INSERT, the anchor, and the dedup + // FILTER NOT EXISTS — at least three occurrences. + assertTrue(countOccurrences(sparql, "a npa:SpaceAliasDeclaration") >= 3, + "dedup on the declaration subject already in the state graph"); + } + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + for (int i = haystack.indexOf(needle); i >= 0; i = haystack.indexOf(needle, i + needle.length())) { + count++; + } + return count; + } + + @Test + void attachmentValidationUpdate_honorsSameAsAlias() { + String sparql = AuthorityResolver.attachmentValidationUpdate(TEST_GRAPH, 5); + assertTrue(sparql.contains("?space npa:sameAsSpace ?canon"), + "attachment admin gate also accepts admin of an owl:sameAs canonical"); + assertTrue(sparql.contains("npa:forSpace ?canon"), + "alias branch looks up admins on the canonical space"); + assertTrue(sparql.contains("UNION"), "direct + alias branches joined by UNION"); + } + + @Test + void nonAdminTierUpdate_adminConstraintHonorsSameAsAlias() { + String sparql = AuthorityResolver.nonAdminTierUpdate( + TEST_GRAPH, 5, + com.knowledgepixels.query.vocabulary.GEN.MEMBER_ROLE, + AuthorityResolver.PUBLISHER_IS_ADMIN); + assertTrue(sparql.contains("?space npa:sameAsSpace ?canon"), + "admin publisher constraint accepts admin of an owl:sameAs canonical"); + } + + @Test + void aliasInvalidationDelete_targetsAliasDeclarationRowsOnly() { + String sparql = AuthorityResolver.aliasInvalidationDelete(TEST_GRAPH, 5); + assertTrue(sparql.contains("DELETE"), "DELETE clause"); + assertTrue(sparql.contains("npa:SpaceAliasDeclaration"), + "scoped to SpaceAliasDeclaration rows"); + assertTrue(sparql.contains("?invNp ?np"), + "joins the raw npx:invalidates triple in npa:graph"); + assertTrue(sparql.contains("FILTER (?ln > 5)"), + "delta filter on the invalidator's load number"); + // The npa:sameAsSpace edge (subject = ?alias) is NOT removed here — sticky + // until the next periodic rebuild, same policy as sub-space declarations. + assertFalse(sparql.contains("npa:sameAsSpace"), + "the alias edge is not part of the DELETE — sticky until rebuild"); + } + } diff --git a/src/test/java/com/knowledgepixels/query/SpacesExtractorTest.java b/src/test/java/com/knowledgepixels/query/SpacesExtractorTest.java index b23b7fc..9a22102 100644 --- a/src/test/java/com/knowledgepixels/query/SpacesExtractorTest.java +++ b/src/test/java/com/knowledgepixels/query/SpacesExtractorTest.java @@ -1,5 +1,7 @@ package com.knowledgepixels.query; +import static com.knowledgepixels.query.vocabulary.SpacesVocab.ALIAS_SPACE; +import static com.knowledgepixels.query.vocabulary.SpacesVocab.CANONICAL_SPACE; import static com.knowledgepixels.query.vocabulary.SpacesVocab.CHILD_SPACE; import static com.knowledgepixels.query.vocabulary.SpacesVocab.CURRENT_LOAD_COUNTER; import static com.knowledgepixels.query.vocabulary.SpacesVocab.FOR_AGENT; @@ -20,6 +22,7 @@ import static com.knowledgepixels.query.vocabulary.SpacesVocab.ROLE_DECLARATION; import static com.knowledgepixels.query.vocabulary.SpacesVocab.ROOT_NANOPUB; import static com.knowledgepixels.query.vocabulary.SpacesVocab.SPACES_GRAPH; +import static com.knowledgepixels.query.vocabulary.SpacesVocab.SPACE_ALIAS_DECLARATION; import static com.knowledgepixels.query.vocabulary.SpacesVocab.SPACE_DEFINITION; import static com.knowledgepixels.query.vocabulary.SpacesVocab.SPACE_IRI; import static com.knowledgepixels.query.vocabulary.SpacesVocab.SPACE_REF; @@ -28,6 +31,7 @@ import static com.knowledgepixels.query.vocabulary.SpacesVocab.forRoleAssignment; import static com.knowledgepixels.query.vocabulary.SpacesVocab.forRoleDeclaration; import static com.knowledgepixels.query.vocabulary.SpacesVocab.forRoleInstantiation; +import static com.knowledgepixels.query.vocabulary.SpacesVocab.forSpaceAliasDeclaration; import static com.knowledgepixels.query.vocabulary.SpacesVocab.forSpaceDefinition; import static com.knowledgepixels.query.vocabulary.SpacesVocab.forMaintainedResourceDeclaration; import static com.knowledgepixels.query.vocabulary.SpacesVocab.forSpaceRef; @@ -52,6 +56,7 @@ import org.eclipse.rdf4j.model.Value; import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.model.impl.SimpleValueFactory; +import org.eclipse.rdf4j.model.vocabulary.OWL; import org.eclipse.rdf4j.model.vocabulary.RDF; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -468,6 +473,71 @@ void extract_subSpaceEmbeddedInGenSpace_subjectMustBeDeclaredSpaceIri() throws E assertDoesNotContain(out, rejectedSubject, RDF.TYPE, SUB_SPACE_DECLARATION); } + // ---------------- owl:sameAs (space aliases, issue #113) ---------------- + + @Test + void extract_sameAsEmbeddedInGenSpace_emitsSpaceAliasDeclaration() throws Exception { + IRI alias = vf.createIRI("https://example.org/spaces/old-alpha"); + Nanopub np = creator() + .type(GEN.SPACE) + .assertion(SPACE_IRI_1, RDF.TYPE, GEN.SPACE) + .assertion(SPACE_IRI_1, GEN.HAS_ROOT_DEFINITION, NP_URI) + .assertion(SPACE_IRI_1, GEN.HAS_ADMIN, ADMIN_AGENT_1) + .assertion(SPACE_IRI_1, OWL.SAMEAS, alias) + .finalizeNanopub(); + + List out = SpacesExtractor.extract(np, defaultContext()); + IRI subject = forSpaceAliasDeclaration(ARTIFACT_CODE, Utils.createHash(alias)); + + // Alias declaration emitted alongside the SpaceRef / SpaceDefinition, with the + // declared Space IRI as the canonical side and the owl:sameAs object as alias. + assertContains(out, subject, RDF.TYPE, SPACE_ALIAS_DECLARATION); + assertContains(out, subject, CANONICAL_SPACE, SPACE_IRI_1); + assertContains(out, subject, ALIAS_SPACE, alias); + assertContains(out, subject, VIA_NANOPUB, NP_URI); + // Provenance carried so the materializer can gate on the publisher. + assertContains(out, subject, NPX.SIGNED_BY, SIGNER_AGENT); + + // SpaceRef + SpaceDefinition still present (regression guard). + String spaceRef = ARTIFACT_CODE + "_" + Utils.createHash(SPACE_IRI_1); + assertContains(out, forSpaceRef(spaceRef), RDF.TYPE, SPACE_REF); + assertContains(out, forSpaceDefinition(ARTIFACT_CODE), RDF.TYPE, SPACE_DEFINITION); + } + + @Test + void extract_sameAsEmbeddedInGenSpace_subjectMustBeDeclaredSpaceIri() throws Exception { + // An owl:sameAs triple whose subject isn't the declared Space IRI is ignored: + // the alias must be declared by the space claiming it. + IRI unrelated = vf.createIRI("https://example.org/somewhere/else"); + IRI alias = vf.createIRI("https://example.org/spaces/old-alpha"); + Nanopub np = creator() + .type(GEN.SPACE) + .assertion(SPACE_IRI_1, RDF.TYPE, GEN.SPACE) + .assertion(SPACE_IRI_1, GEN.HAS_ROOT_DEFINITION, NP_URI) + .assertion(SPACE_IRI_1, GEN.HAS_ADMIN, ADMIN_AGENT_1) + .assertion(unrelated, OWL.SAMEAS, alias) + .finalizeNanopub(); + + List out = SpacesExtractor.extract(np, defaultContext()); + IRI rejectedSubject = forSpaceAliasDeclaration(ARTIFACT_CODE, Utils.createHash(alias)); + assertDoesNotContain(out, rejectedSubject, RDF.TYPE, SPACE_ALIAS_DECLARATION); + } + + @Test + void extract_sameAsSelfAlias_isRejected() throws Exception { + Nanopub np = creator() + .type(GEN.SPACE) + .assertion(SPACE_IRI_1, RDF.TYPE, GEN.SPACE) + .assertion(SPACE_IRI_1, GEN.HAS_ROOT_DEFINITION, NP_URI) + .assertion(SPACE_IRI_1, GEN.HAS_ADMIN, ADMIN_AGENT_1) + .assertion(SPACE_IRI_1, OWL.SAMEAS, SPACE_IRI_1) + .finalizeNanopub(); + + List out = SpacesExtractor.extract(np, defaultContext()); + IRI rejectedSubject = forSpaceAliasDeclaration(ARTIFACT_CODE, Utils.createHash(SPACE_IRI_1)); + assertDoesNotContain(out, rejectedSubject, RDF.TYPE, SPACE_ALIAS_DECLARATION); + } + // ---------------- gen:isMaintainedBy ---------------- @Test