• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

knowledgepixels / nanopub-query / 28225737140

26 Jun 2026 08:09AM UTC coverage: 61.856% (+0.8%) from 61.063%
28225737140

push

github

web-flow
Merge pull request #134 from knowledgepixels/feat/issue-129-role-revocation

feat(spaces): revoke & re-assign space roles (authorization-scoped latest-wins) (#129)

569 of 1026 branches covered (55.46%)

Branch coverage included in aggregate %.

1677 of 2605 relevant lines covered (64.38%)

10.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.66
src/main/java/com/knowledgepixels/query/SpacesExtractor.java
1
package com.knowledgepixels.query;
2

3
import com.knowledgepixels.query.vocabulary.BackcompatRolePredicates;
4
import com.knowledgepixels.query.vocabulary.GEN;
5
import com.knowledgepixels.query.vocabulary.SpacesVocab;
6
import net.trustyuri.TrustyUriUtils;
7
import org.eclipse.rdf4j.model.*;
8
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
9
import org.eclipse.rdf4j.model.vocabulary.DCTERMS;
10
import org.eclipse.rdf4j.model.vocabulary.OWL;
11
import org.eclipse.rdf4j.model.vocabulary.RDF;
12
import org.nanopub.Nanopub;
13
import org.nanopub.NanopubUtils;
14
import org.nanopub.vocabulary.NPA;
15
import org.nanopub.vocabulary.NPX;
16
import org.slf4j.Logger;
17
import org.slf4j.LoggerFactory;
18

19
import java.util.*;
20

21
/**
22
 * Pure-logic extractor from a loaded {@link Nanopub} to the add-only summary
23
 * triples destined for {@code npa:spacesGraph}. Implements the per-type schema
24
 * from {@code doc/design-space-repositories.md}.
25
 *
26
 * <p>Dispatch is by nanopub type — {@link NanopubUtils#getTypes(Nanopub)} returns
27
 * both {@code rdf:type} / {@code npx:hasNanopubType} declarations and, for
28
 * single-predicate-assertion nanopubs, the predicate itself. That means the
29
 * four predefined types ({@link GEN#SPACE}, {@link GEN#HAS_ROLE},
30
 * {@link GEN#SPACE_MEMBER_ROLE}, {@link GEN#ROLE_INSTANTIATION}) and all 14
31
 * {@link BackcompatRolePredicates backwards-compat predicates} can be detected
32
 * with a single type-set lookup.
33
 *
34
 * <p>Output: a list of RDF4J {@link Statement}s, all in the
35
 * {@link SpacesVocab#SPACES_GRAPH} named graph, that the caller writes into the
36
 * {@code spaces} repo. Deterministic and idempotent — the same nanopub always
37
 * produces the same statement set.
38
 */
39
public final class SpacesExtractor {
40

41
    private static final Logger logger = LoggerFactory.getLogger(SpacesExtractor.class);
9✔
42

43
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
44

45
    private static final IRI GRAPH = SpacesVocab.SPACES_GRAPH;
6✔
46

47
    /**
48
     * The set of nanopub-level type/predicate IRIs that make a nanopub "space-relevant"
49
     * — i.e., that dispatch to one of the per-shape extractors in {@link #extract}.
50
     * Shared with {@link NanopubLoader} so the spaces-load gate and the invalidation
51
     * propagation paths agree on a single definition of "space-relevant" without
52
     * needing to re-run the extractor.
53
     *
54
     * <p>Membership is checked against {@link NanopubUtils#getTypes(Nanopub)}, which
55
     * includes both {@code rdf:type} / {@code npx:hasNanopubType} declarations and,
56
     * for single-predicate-assertion nanopubs, the predicate itself — so predicate
57
     * markers like {@link GEN#HAS_ROLE} and {@link GEN#IS_MAINTAINED_BY} can appear
58
     * as types here.
59
     */
60
    public static final Set<IRI> TRIGGER_TYPES;
61

62
    static {
63
        Set<IRI> s = new LinkedHashSet<>();
12✔
64
        s.add(GEN.SPACE);
12✔
65
        s.add(GEN.HAS_ROLE);
12✔
66
        s.add(GEN.SPACE_MEMBER_ROLE);
12✔
67
        s.add(GEN.ROLE_INSTANTIATION);
12✔
68
        s.add(GEN.IS_SUB_SPACE_OF);
12✔
69
        s.add(GEN.MAINTAINED_RESOURCE);
12✔
70
        s.add(GEN.IS_MAINTAINED_BY);
12✔
71
        s.add(GEN.PRESET);
12✔
72
        s.add(GEN.PRESET_ASSIGNMENT);
12✔
73
        s.add(GEN.REVOKED_ROLE_INSTANTIATION);
12✔
74
        s.add(GEN.DETACHED_ROLE);
12✔
75
        s.addAll(BackcompatRolePredicates.ALL);
12✔
76
        TRIGGER_TYPES = Collections.unmodifiableSet(s);
9✔
77
    }
3✔
78

79
    private SpacesExtractor() {
80
    }
81

82
    /**
83
     * Returns {@code true} iff at least one of {@code types} is in
84
     * {@link #TRIGGER_TYPES} — i.e., the nanopub carries a type that dispatches
85
     * to one of the extractor branches. Callers should typically pass
86
     * {@code NanopubUtils.getTypes(np)} so that single-predicate-assertion
87
     * auto-typing is included.
88
     */
89
    public static boolean isSpaceRelevant(Set<IRI> types) {
90
        for (IRI t : types) {
30✔
91
            if (TRIGGER_TYPES.contains(t)) {
12✔
92
                return true;
6✔
93
            }
94
        }
3✔
95
        return false;
6✔
96
    }
97

98
    /**
99
     * Bundles the information a single extraction needs beyond the nanopub itself.
100
     *
101
     * @param artifactCode trusty-URI artifact code of {@code np} (used for minting
102
     *                     {@code npari:}/{@code npara:}/{@code npard:}/{@code npadef:}
103
     *                     subject IRIs).
104
     * @param signedBy     signer agent IRI from pubinfo, or {@code null} if absent.
105
     * @param pubkeyHash   hash of the signing public key, or {@code null} if absent.
106
     * @param createdAt    creation timestamp, or {@code null} if the nanopub lacks one.
107
     */
108
    public record Context(String artifactCode, IRI signedBy, String pubkeyHash, Date createdAt) {
45✔
109
    }
110

111
    /**
112
     * Runs the extractor on a loaded nanopub. Returns an empty list if the nanopub is
113
     * not space-relevant.
114
     *
115
     * @param np  the nanopub to inspect
116
     * @param ctx the extraction context
117
     * @return statements to write into {@code npa:spacesGraph}
118
     */
119
    public static List<Statement> extract(Nanopub np, Context ctx) {
120
        Set<IRI> types = NanopubUtils.getTypes(np);
9✔
121
        if (!isSpaceRelevant(types)) {
9✔
122
            return Collections.emptyList();
6✔
123
        }
124

125
        List<Statement> out = new ArrayList<>();
12✔
126

127
        boolean isSpace = types.contains(GEN.SPACE);
12✔
128
        boolean isHasRole = types.contains(GEN.HAS_ROLE);
12✔
129
        boolean isSpaceMemberRole = types.contains(GEN.SPACE_MEMBER_ROLE);
12✔
130
        boolean isRoleInstantiation = types.contains(GEN.ROLE_INSTANTIATION)
18✔
131
                                      || anyMatch(types, BackcompatRolePredicates.ALL);
18✔
132
        boolean isSubSpaceOf = types.contains(GEN.IS_SUB_SPACE_OF);
12✔
133
        // Maintained-resource nanopubs use either the resource-class marker
134
        // (gen:MaintainedResource — what Nanodash currently writes) or the
135
        // predicate marker (gen:isMaintainedBy — single-predicate-assertion
136
        // auto-typing or explicit npx:hasNanopubType). Both shapes carry the
137
        // same <r> gen:isMaintainedBy <s> triple in the assertion.
138
        boolean isMaintainedResource = types.contains(GEN.MAINTAINED_RESOURCE)
18✔
139
                                       || types.contains(GEN.IS_MAINTAINED_BY);
18✔
140
        // Presets (Nanodash issue #302). A preset-defining nanopub is typed gen:Preset;
141
        // an assignment is typed gen:PresetAssignment (plus gen:Activated/Deactivated).
142
        // Both shapes are single-subject assertions, so NanopubUtils.getTypes promotes
143
        // the assertion type even without a pubinfo npx:hasNanopubType marker.
144
        boolean isPreset = types.contains(GEN.PRESET);
12✔
145
        boolean isPresetAssignment = types.contains(GEN.PRESET_ASSIGNMENT);
12✔
146
        // Role revocation (issue #129). RevokedRoleInstantiation is a typed node carrying
147
        // gen:forSpace/forAgent/hasRole; gen:detachedRole is a single-predicate-assertion
148
        // (auto-typed like gen:hasRole). Both emit key-level negatives, never state rows.
149
        boolean isRevokedRoleInstantiation = types.contains(GEN.REVOKED_ROLE_INSTANTIATION);
12✔
150
        boolean isDetachedRole = types.contains(GEN.DETACHED_ROLE);
12✔
151

152
        if (isSpace) {
6✔
153
            extractSpace(np, ctx, out);
12✔
154
        }
155
        if (isHasRole) {
6✔
156
            extractHasRole(np, ctx, out);
12✔
157
        }
158
        if (isSpaceMemberRole) {
6✔
159
            extractSpaceMemberRole(np, ctx, out);
12✔
160
        }
161
        if (isRoleInstantiation) {
6✔
162
            extractRoleInstantiation(np, ctx, types.contains(GEN.ROLE_INSTANTIATION), out);
21✔
163
        }
164
        if (isSubSpaceOf) {
6✔
165
            extractSubSpaceOf(np, ctx, out);
12✔
166
        }
167
        if (isMaintainedResource) {
6✔
168
            extractIsMaintainedBy(np, ctx, out);
12✔
169
        }
170
        if (isPreset) {
6✔
171
            extractPreset(np, ctx, out);
12✔
172
        }
173
        if (isPresetAssignment) {
6✔
174
            extractPresetAssignment(np, ctx, out);
12✔
175
        }
176
        if (isRevokedRoleInstantiation) {
6✔
177
            extractRevokedRoleInstantiation(np, ctx, out);
12✔
178
        }
179
        if (isDetachedRole) {
6✔
180
            extractDetachedRole(np, ctx, out);
12✔
181
        }
182

183
        return out;
6✔
184
    }
185

186
    // ---------------- gen:Space ----------------
187

188
    private static void extractSpace(Nanopub np, Context ctx, List<Statement> out) {
189
        // A single gen:Space nanopub may declare multiple Space IRIs, each via its own
190
        // gen:hasRootDefinition triple. We emit one SpaceRef + SpaceDefinition per
191
        // Space IRI. A nanopub missing any hasRootDefinition is accepted as its own
192
        // root for every Space IRI it declares (transition backcompat).
193
        Set<IRI> handled = new LinkedHashSet<>();
12✔
194
        List<IRI> adminAgents = collectAdminAgents(np);
9✔
195

196
        // Rooted case: gen:hasRootDefinition explicitly declared.
197
        for (Statement st : np.getAssertion()) {
33✔
198
            if (!st.getPredicate().equals(GEN.HAS_ROOT_DEFINITION)) {
15✔
199
                continue;
3✔
200
            }
201
            if (!(st.getSubject() instanceof IRI spaceIri)) {
27!
202
                continue;
203
            }
204
            if (!(st.getObject() instanceof IRI rootUri)) {
27!
205
                continue;
206
            }
207
            String rootNanopubId = TrustyUriUtils.getArtifactCode(rootUri.stringValue());
12✔
208
            if (rootNanopubId == null || rootNanopubId.isEmpty()) {
15!
209
                logger.warn("Ignoring space {}: gen:hasRootDefinition target is not a trusty URI: {}",
×
210
                        spaceIri, rootUri);
211
                continue;
×
212
            }
213
            if (!handled.add(spaceIri)) {
12!
214
                continue;
×
215
            }
216
            emitSpaceEntry(np, ctx, spaceIri, rootUri, rootNanopubId, adminAgents, out);
24✔
217
        }
3✔
218

219
        // Rootless transition case: any Space IRI in the assertion that didn't get a
220
        // hasRootDefinition triple is treated as if it were its own root. Detect by
221
        // looking for triples that reference a Space IRI we haven't handled yet —
222
        // typically via gen:hasAdmin subjects or the rdf:type gen:Space triple on a
223
        // blank-node assertion subject. The common template publishes the Space IRI
224
        // as the subject of at least one triple in the assertion, so we scan for that.
225
        for (Statement st : np.getAssertion()) {
33✔
226
            if (!(st.getSubject() instanceof IRI spaceIri)) {
27!
227
                continue;
228
            }
229
            if (handled.contains(spaceIri)) {
12✔
230
                continue;
3✔
231
            }
232
            // Skip IRIs that clearly aren't Space IRIs (role IRIs embedded in this nanopub).
233
            if (spaceIri.stringValue().startsWith(np.getUri().stringValue())) {
21!
234
                continue;
×
235
            }
236
            // Require at least one structural signal that this is a Space IRI:
237
            // an rdf:type gen:Space, or a gen:hasAdmin triple with this as subject.
238
            if (!looksLikeSpaceIri(np, spaceIri)) {
12✔
239
                continue;
3✔
240
            }
241
            handled.add(spaceIri);
12✔
242
            String rootNanopubId = TrustyUriUtils.getArtifactCode(np.getUri().stringValue());
15✔
243
            if (rootNanopubId == null || rootNanopubId.isEmpty()) {
15!
244
                continue;
×
245
            }
246
            emitSpaceEntry(np, ctx, spaceIri, np.getUri(), rootNanopubId, adminAgents, out);
27✔
247
        }
3✔
248
    }
3✔
249

250
    private static void emitSpaceEntry(Nanopub np, Context ctx, IRI spaceIri, IRI rootUri,
251
                                       String rootNanopubId, List<IRI> adminAgents,
252
                                       List<Statement> out) {
253
        String spaceRef = rootNanopubId + "_" + Utils.createHash(spaceIri);
15✔
254
        IRI refIri = SpacesVocab.forSpaceRef(spaceRef);
9✔
255
        IRI defIri = SpacesVocab.forSpaceDefinition(ctx.artifactCode());
12✔
256

257
        // Aggregate entry: contributor-independent, reinforced on every contribution.
258
        out.add(vf.createStatement(refIri, RDF.TYPE, SpacesVocab.SPACE_REF, GRAPH));
27✔
259
        out.add(vf.createStatement(refIri, SpacesVocab.SPACE_IRI, spaceIri, GRAPH));
27✔
260
        out.add(vf.createStatement(refIri, SpacesVocab.ROOT_NANOPUB, rootUri, GRAPH));
27✔
261

262
        // Identity-derived path-prefix enumeration powering the URL-prefix sub-space
263
        // fallback in the materializer. Same triples on every contributor (RDF set
264
        // semantics dedups them).
265
        for (IRI prefix : enumerateIdPrefixes(spaceIri)) {
33✔
266
            out.add(vf.createStatement(refIri, SpacesVocab.HAS_ID_PREFIX, prefix, GRAPH));
27✔
267
        }
3✔
268

269
        // Embedded gen:isSubSpaceOf triples in this gen:Space nanopub: emit one
270
        // SubSpaceDeclaration per (spaceIri, parentIri) pair. Same shape as the
271
        // standalone path; downstream rules don't distinguish them.
272
        emitSubSpaceDeclarations(np, ctx, spaceIri, out);
15✔
273

274
        // Embedded gen:isMaintainedBy triples in this gen:Space nanopub: emit one
275
        // MaintainedResourceDeclaration per (resourceIri, spaceIri) pair where the
276
        // object equals the Space being defined. Same shape as the standalone path.
277
        emitMaintainedResourceDeclarations(np, ctx, spaceIri, out);
15✔
278

279
        // Embedded owl:sameAs triples: <spaceIri> owl:sameAs <aliasIri> declares that
280
        // <aliasIri> is an alias of the Space being defined. Emit one
281
        // SpaceAliasDeclaration per (spaceIri, aliasIri) pair so the materializer can
282
        // let this space's admin authority cover roles/members attached to the alias
283
        // (issue #113). Carries provenance — the materializer gates the edge on the
284
        // declaration's publisher being an admin of the canonical space.
285
        emitSpaceAliasDeclarations(np, ctx, spaceIri, out);
15✔
286

287
        // Per-contributor entry: signer, pubkey, created-at, link back to nanopub.
288
        out.add(vf.createStatement(defIri, RDF.TYPE, SpacesVocab.SPACE_DEFINITION, GRAPH));
27✔
289
        out.add(vf.createStatement(defIri, SpacesVocab.FOR_SPACE_REF, refIri, GRAPH));
27✔
290
        out.add(vf.createStatement(defIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
291
        addProvenance(defIri, ctx, out);
12✔
292

293
        // Trust seed: this is the root nanopub iff rootUri equals the nanopub's own URI.
294
        boolean isOwnRoot = rootUri.equals(np.getUri());
15✔
295
        if (isOwnRoot) {
6✔
296
            for (IRI adminAgent : adminAgents) {
30✔
297
                out.add(vf.createStatement(defIri, SpacesVocab.HAS_ROOT_ADMIN, adminAgent, GRAPH));
27✔
298
            }
3✔
299
        }
300

301
        // gen:RoleInstantiation entry for the admins asserted in this gen:Space nanopub,
302
        // so admins show up in the same SPARQL pattern as ordinary admin instantiations.
303
        if (!adminAgents.isEmpty()) {
9!
304
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
305
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
306
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
307
            out.add(vf.createStatement(riIri, SpacesVocab.INVERSE_PROPERTY, GEN.HAS_ADMIN, GRAPH));
27✔
308
            for (IRI adminAgent : adminAgents) {
30✔
309
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, adminAgent, GRAPH));
27✔
310
            }
3✔
311
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
312
            addProvenance(riIri, ctx, out);
12✔
313
        }
314

315
        // Inline non-hasAdmin role triples: a gen:Space nanopub may also assert
316
        // <space> <pred> <agent> (INVERSE) or <agent> <pred> <space> (REGULAR)
317
        // for any of the back-compat role predicates (has-event-facilitator,
318
        // participatedAsParticipantIn, …). Without an extraction path here those
319
        // are silently dropped because gen:Space nanopubs are not auto-typed
320
        // with back-compat predicates (only single-triple-assertion nanopubs are).
321
        // Emit one RoleInstantiation per distinct predicate found, grouping
322
        // multi-agent like the admin case. The subject is disambiguated by a
323
        // hash of the predicate IRI so multiple predicates in one nanopub don't
324
        // collide on the same npari:<artifactCode> subject as the admin RI.
325
        emitInlineRoleInstantiations(np, ctx, spaceIri, out);
15✔
326
    }
3✔
327

328
    /**
329
     * Scans the assertion of a {@code gen:Space} nanopub for inline role triples
330
     * (excluding {@code gen:hasAdmin}, which is handled separately as the trust
331
     * seed), grouping by predicate and emitting one {@link GEN#ROLE_INSTANTIATION}
332
     * per (predicate, direction) pair with multi-valued {@code npa:forAgent}.
333
     */
334
    private static void emitInlineRoleInstantiations(Nanopub np, Context ctx, IRI spaceIri,
335
                                                     List<Statement> out) {
336
        Map<IRI, BackcompatRolePredicates.Direction> directionByPred = new LinkedHashMap<>();
12✔
337
        Map<IRI, Set<IRI>> agentsByPred = new LinkedHashMap<>();
12✔
338
        for (Statement st : np.getAssertion()) {
33✔
339
            IRI predicate = st.getPredicate();
9✔
340
            if (GEN.HAS_ADMIN.equals(predicate)) {
12✔
341
                continue; // already emitted above
3✔
342
            }
343
            BackcompatRolePredicates.Direction direction = BackcompatRolePredicates.DIRECTIONS.get(predicate);
15✔
344
            if (direction == null) {
6✔
345
                continue;
3✔
346
            }
347
            if (!(st.getSubject() instanceof IRI subjIri)) {
27!
348
                continue;
349
            }
350
            if (!(st.getObject() instanceof IRI objIri)) {
27!
351
                continue;
352
            }
353
            IRI agent;
354
            if (direction == BackcompatRolePredicates.Direction.INVERSE) {
9✔
355
                if (!spaceIri.equals(subjIri)) {
12!
356
                    continue;
×
357
                }
358
                agent = objIri;
9✔
359
            } else {
360
                if (!spaceIri.equals(objIri)) {
12!
361
                    continue;
×
362
                }
363
                agent = subjIri;
6✔
364
            }
365
            directionByPred.put(predicate, direction);
15✔
366
            agentsByPred.computeIfAbsent(predicate, k -> new LinkedHashSet<>()).add(agent);
36✔
367
        }
3✔
368
        for (Map.Entry<IRI, Set<IRI>> entry : agentsByPred.entrySet()) {
33✔
369
            IRI predicate = entry.getKey();
12✔
370
            BackcompatRolePredicates.Direction direction = directionByPred.get(predicate);
15✔
371
            String predHash = Utils.createHash(predicate.stringValue());
12✔
372
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode(), predHash);
15✔
373
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
374
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
375
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
376
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
377
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
378
            out.add(vf.createStatement(riIri, directionPredicate, predicate, GRAPH));
27✔
379
            for (IRI agent : entry.getValue()) {
36✔
380
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, agent, GRAPH));
27✔
381
            }
3✔
382
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
383
            addProvenance(riIri, ctx, out);
12✔
384
        }
3✔
385
    }
3✔
386

387
    /**
388
     * Heuristic: does {@code candidate} look like a Space IRI in {@code np}'s assertion,
389
     * independent of any {@code gen:hasRootDefinition} triple? We accept it if the
390
     * assertion contains {@code candidate rdf:type gen:Space} or
391
     * {@code candidate gen:hasAdmin ?x}.
392
     */
393
    private static boolean looksLikeSpaceIri(Nanopub np, IRI candidate) {
394
        for (Statement st : np.getAssertion()) {
33✔
395
            if (!candidate.equals(st.getSubject())) {
15✔
396
                continue;
3✔
397
            }
398
            if (st.getPredicate().equals(RDF.TYPE) && GEN.SPACE.equals(st.getObject())) {
30!
399
                return true;
6✔
400
            }
401
            if (st.getPredicate().equals(GEN.HAS_ADMIN)) {
15!
402
                return true;
×
403
            }
404
        }
3✔
405
        return false;
6✔
406
    }
407

408
    private static List<IRI> collectAdminAgents(Nanopub np) {
409
        Set<IRI> agents = new LinkedHashSet<>();
12✔
410
        for (Statement st : np.getAssertion()) {
33✔
411
            if (!st.getPredicate().equals(GEN.HAS_ADMIN)) {
15✔
412
                continue;
3✔
413
            }
414
            if (!(st.getObject() instanceof IRI agent)) {
27!
415
                continue;
416
            }
417
            agents.add(agent);
12✔
418
        }
3✔
419
        return new ArrayList<>(agents);
15✔
420
    }
421

422
    // ---------------- gen:hasRole (role attachment) ----------------
423

424
    private static void extractHasRole(Nanopub np, Context ctx, List<Statement> out) {
425
        // A gen:hasRole nanopub asserts <space> gen:hasRole <role>.
426
        for (Statement st : np.getAssertion()) {
33!
427
            if (!st.getPredicate().equals(GEN.HAS_ROLE)) {
15!
428
                continue;
×
429
            }
430
            if (!(st.getSubject() instanceof IRI spaceIri)) {
27!
431
                continue;
432
            }
433
            if (!(st.getObject() instanceof IRI roleIri)) {
27!
434
                continue;
435
            }
436
            IRI subject = SpacesVocab.forRoleAssignment(ctx.artifactCode());
12✔
437
            out.add(vf.createStatement(subject, RDF.TYPE, GEN.ROLE_ASSIGNMENT, GRAPH));
27✔
438
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
439
            out.add(vf.createStatement(subject, GEN.HAS_ROLE, roleIri, GRAPH));
27✔
440
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
441
            addProvenance(subject, ctx, out);
12✔
442
            // One attachment per nanopub — the subject IRI is derived from the nanopub
443
            // artifact code so multiple hasRole triples in the same nanopub would collide.
444
            // If that case shows up in practice, we'll refine the subject-minting scheme.
445
            return;
3✔
446
        }
447
    }
×
448

449
    // ---------------- gen:SpaceMemberRole (role declaration) ----------------
450

451
    private static void extractSpaceMemberRole(Nanopub np, Context ctx, List<Statement> out) {
452
        // The role IRI is embedded in this nanopub, so look for an assertion statement
453
        // of the shape <roleIri> rdf:type gen:SpaceMemberRole where <roleIri> starts
454
        // with the nanopub IRI (valid embedded mint).
455
        IRI roleIri = null;
6✔
456
        for (Statement st : np.getAssertion()) {
33✔
457
            if (!st.getPredicate().equals(RDF.TYPE)) {
15!
458
                continue;
×
459
            }
460
            if (!GEN.SPACE_MEMBER_ROLE.equals(st.getObject())) {
15✔
461
                continue;
3✔
462
            }
463
            if (!(st.getSubject() instanceof IRI candidate)) {
27!
464
                continue;
465
            }
466
            if (!candidate.stringValue().startsWith(np.getUri().stringValue())) {
21✔
467
                continue;
3✔
468
            }
469
            roleIri = candidate;
6✔
470
            break;
3✔
471
        }
472
        if (roleIri == null) {
6✔
473
            return;
3✔
474
        }
475

476
        IRI roleType = findRoleTier(np, roleIri);
12✔
477
        List<IRI> regulars = collectRolePredicate(np, roleIri, GEN.HAS_REGULAR_PROPERTY);
15✔
478
        List<IRI> inverses = collectRolePredicate(np, roleIri, GEN.HAS_INVERSE_PROPERTY);
15✔
479

480
        IRI subject = SpacesVocab.forRoleDeclaration(ctx.artifactCode());
12✔
481
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_DECLARATION, GRAPH));
27✔
482
        out.add(vf.createStatement(subject, SpacesVocab.ROLE, roleIri, GRAPH));
27✔
483
        out.add(vf.createStatement(subject, SpacesVocab.HAS_ROLE_TYPE, roleType, GRAPH));
27✔
484
        for (IRI reg : regulars) {
30✔
485
            out.add(vf.createStatement(subject, GEN.HAS_REGULAR_PROPERTY, reg, GRAPH));
27✔
486
        }
3✔
487
        for (IRI inv : inverses) {
30✔
488
            out.add(vf.createStatement(subject, GEN.HAS_INVERSE_PROPERTY, inv, GRAPH));
27✔
489
        }
3✔
490
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
491
        if (ctx.createdAt() != null) {
9!
492
            out.add(vf.createStatement(subject, DCTERMS.CREATED, vf.createLiteral(ctx.createdAt()), GRAPH));
36✔
493
        }
494
    }
3✔
495

496
    /**
497
     * Looks for a tier rdf:type ({@code gen:MaintainerRole} / {@code gen:MemberRole} /
498
     * {@code gen:ObserverRole}) on the role IRI in the assertion; defaults to
499
     * {@code gen:ObserverRole} if none is declared.
500
     */
501
    private static IRI findRoleTier(Nanopub np, IRI roleIri) {
502
        for (Statement st : np.getAssertion()) {
33✔
503
            if (!roleIri.equals(st.getSubject())) {
15!
504
                continue;
×
505
            }
506
            if (!st.getPredicate().equals(RDF.TYPE)) {
15!
507
                continue;
×
508
            }
509
            if (!(st.getObject() instanceof IRI type)) {
27!
510
                continue;
511
            }
512
            if (GEN.MAINTAINER_ROLE.equals(type) || GEN.MEMBER_ROLE.equals(type)
30!
513
                || GEN.OBSERVER_ROLE.equals(type)) {
6!
514
                return type;
6✔
515
            }
516
        }
3✔
517
        return GEN.OBSERVER_ROLE;
6✔
518
    }
519

520
    private static List<IRI> collectRolePredicate(Nanopub np, IRI roleIri, IRI predicate) {
521
        List<IRI> out = new ArrayList<>();
12✔
522
        for (Statement st : np.getAssertion()) {
33✔
523
            if (!roleIri.equals(st.getSubject())) {
15!
524
                continue;
×
525
            }
526
            if (!predicate.equals(st.getPredicate())) {
15✔
527
                continue;
3✔
528
            }
529
            if (!(st.getObject() instanceof IRI obj)) {
27!
530
                continue;
531
            }
532
            out.add(obj);
12✔
533
        }
3✔
534
        return out;
6✔
535
    }
536

537
    // ---------------- gen:RoleInstantiation (and backcompat) ----------------
538

539
    private static void extractRoleInstantiation(Nanopub np, Context ctx,
540
                                                 boolean explicitRoleInstantiation, List<Statement> out) {
541
        // Find the assignment triple. Directionality (matches the publisher convention
542
        // used by gen:hasRegularProperty / gen:hasInverseProperty in role-definition
543
        // nanopubs):
544
        //   REGULAR: <agent> <predicate> <space>  → npa:regularProperty.
545
        //   INVERSE: <space> <predicate> <agent>  → npa:inverseProperty.
546
        // gen:hasAdmin is hardcoded INVERSE (space-centric: <space> hasAdmin <agent>).
547
        // The 14 backwards-compat predicates are classified in
548
        // {@link BackcompatRolePredicates#DIRECTIONS}.
549
        for (Statement st : np.getAssertion()) {
33✔
550
            IRI predicate = st.getPredicate();
9✔
551
            BackcompatRolePredicates.Direction direction = directionFor(predicate);
9✔
552
            if (direction == null) {
6✔
553
                continue;
3✔
554
            }
555
            if (!(st.getSubject() instanceof IRI subjIri)) {
27!
556
                continue;
557
            }
558
            if (!(st.getObject() instanceof IRI objIri)) {
27!
559
                continue;
560
            }
561

562
            IRI spaceSide;
563
            IRI agentSide;
564
            if (direction == BackcompatRolePredicates.Direction.REGULAR) {
9✔
565
                agentSide = subjIri;
6✔
566
                spaceSide = objIri;
9✔
567
            } else {
568
                spaceSide = subjIri;
6✔
569
                agentSide = objIri;
6✔
570
            }
571

572
            // Deduplicate against the (possibly already emitted) admin instantiation
573
            // from the gen:Space path — a single nanopub can be typed gen:Space AND
574
            // have a gen:hasAdmin triple that the backcompat list also catches. The
575
            // subject IRI is the same (derived from artifact code) and the payload
576
            // would conflict if re-emitted. Skip if we already have a RoleInstantiation
577
            // entry on this subject.
578
            IRI subject = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
579
            Statement typeSt = vf.createStatement(subject, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH);
21✔
580
            if (out.contains(typeSt)) {
12!
581
                return;
×
582
            }
583

584
            out.add(typeSt);
12✔
585
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceSide, GRAPH));
27✔
586
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
587
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
588
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
589
            out.add(vf.createStatement(subject, directionPredicate, predicate, GRAPH));
27✔
590
            out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agentSide, GRAPH));
27✔
591
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
592
            addProvenance(subject, ctx, out);
12✔
593
            return;
3✔
594
        }
595

596
        // No predicate with a known direction. For a nanopub explicitly typed
597
        // gen:RoleInstantiation, the assertion still binds an agent to a space via a
598
        // custom role predicate declared in a gen:SpaceMemberRole nanopub (e.g.
599
        // gen:hasMaintainer). We can't classify its direction here — that lives in the
600
        // role declaration, a different nanopub the extractor can't see — so emit a
601
        // neutral binding carrying the raw (subject, predicate, object). The materializer
602
        // resolves direction + tier by joining the predicate against the role declaration
603
        // attached to the space (see AuthorityResolver#nonAdminTierUpdate). Gated on the
604
        // explicit type so we don't mint inert entries for incidental IRI-valued triples
605
        // in nanopubs that only matched via a backcompat predicate.
606
        if (!explicitRoleInstantiation) {
6!
607
            return;
×
608
        }
609
        for (Statement st : np.getAssertion()) {
33✔
610
            IRI predicate = st.getPredicate();
9✔
611
            if (predicate.equals(RDF.TYPE) || directionFor(predicate) != null) {
21!
612
                continue;
×
613
            }
614
            if (!(st.getSubject() instanceof IRI subjIri)) {
27!
615
                continue;
616
            }
617
            if (!(st.getObject() instanceof IRI objIri)) {
27!
618
                continue;
619
            }
620
            // Discriminate the subject by predicate so multiple custom-predicate triples
621
            // in one nanopub don't collide on the artifact-code-derived subject.
622
            IRI subject = SpacesVocab.forRoleInstantiation(
9✔
623
                    ctx.artifactCode(), Utils.createHash(predicate.stringValue()));
12✔
624
            out.add(vf.createStatement(subject, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
625
            out.add(vf.createStatement(subject, SpacesVocab.ROLE_PREDICATE, predicate, GRAPH));
27✔
626
            out.add(vf.createStatement(subject, SpacesVocab.BINDING_SUBJECT, subjIri, GRAPH));
27✔
627
            out.add(vf.createStatement(subject, SpacesVocab.BINDING_OBJECT, objIri, GRAPH));
27✔
628
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
629
            addProvenance(subject, ctx, out);
12✔
630
        }
3✔
631
    }
3✔
632

633
    private static BackcompatRolePredicates.Direction directionFor(IRI predicate) {
634
        if (GEN.HAS_ADMIN.equals(predicate)) {
12!
635
            return BackcompatRolePredicates.Direction.INVERSE;
×
636
        }
637
        return BackcompatRolePredicates.DIRECTIONS.get(predicate);
15✔
638
    }
639

640
    // ---------------- gen:isSubSpaceOf (standalone path) ----------------
641

642
    /**
643
     * Standalone {@code gen:isSubSpaceOf} nanopub: every
644
     * {@code <childIri> gen:isSubSpaceOf <parentIri>} triple in the assertion emits one
645
     * {@code npa:SubSpaceDeclaration}. Multi-triple assertions are allowed; one entry
646
     * per pair. Self-loops ({@code <X> gen:isSubSpaceOf <X>}) are rejected.
647
     */
648
    private static void extractSubSpaceOf(Nanopub np, Context ctx, List<Statement> out) {
649
        for (Statement st : np.getAssertion()) {
33✔
650
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) {
15!
651
                continue;
×
652
            }
653
            if (!(st.getSubject() instanceof IRI childIri)) {
27!
654
                continue;
655
            }
656
            if (!(st.getObject() instanceof IRI parentIri)) {
27!
657
                continue;
658
            }
659
            emitSubSpaceDeclaration(np, ctx, childIri, parentIri, out);
18✔
660
        }
3✔
661
    }
3✔
662

663
    /**
664
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
665
     * {@code <spaceIri> gen:isSubSpaceOf <parentIri>} triples (subject must equal the
666
     * Space IRI we're emitting an entry for, so the subspace declaration is bound to
667
     * this particular Space). Self-loops are rejected.
668
     */
669
    private static void emitSubSpaceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
670
                                                 List<Statement> out) {
671
        for (Statement st : np.getAssertion()) {
33✔
672
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) {
15✔
673
                continue;
3✔
674
            }
675
            if (!spaceIri.equals(st.getSubject())) {
15✔
676
                continue;
3✔
677
            }
678
            if (!(st.getObject() instanceof IRI parentIri)) {
27!
679
                continue;
680
            }
681
            emitSubSpaceDeclaration(np, ctx, spaceIri, parentIri, out);
18✔
682
        }
3✔
683
    }
3✔
684

685
    /**
686
     * Emits one {@code npa:SubSpaceDeclaration} entry, keyed by
687
     * {@code (artifactCode, parentHash)} so a single nanopub can declare multiple
688
     * parents without subject collision. Self-loops are silently dropped.
689
     */
690
    private static void emitSubSpaceDeclaration(Nanopub np, Context ctx, IRI childIri,
691
                                                IRI parentIri, List<Statement> out) {
692
        if (childIri.equals(parentIri)) {
12✔
693
            logger.debug("Ignoring self-loop sub-space declaration on {} in {}", childIri, np.getUri());
18✔
694
            return;
3✔
695
        }
696
        String parentHash = Utils.createHash(parentIri);
9✔
697
        IRI subject = SpacesVocab.forSubSpaceDeclaration(ctx.artifactCode(), parentHash);
15✔
698

699
        // Idempotence: the embedded and standalone paths can both fire on the same
700
        // (np, child, parent) combination if a gen:Space nanopub somehow ends up typed
701
        // gen:isSubSpaceOf as well. Skip if we've already emitted the type triple for
702
        // this subject.
703
        Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SUB_SPACE_DECLARATION, GRAPH);
21✔
704
        if (out.contains(typeSt)) {
12!
705
            return;
×
706
        }
707

708
        out.add(typeSt);
12✔
709
        out.add(vf.createStatement(subject, SpacesVocab.CHILD_SPACE, childIri, GRAPH));
27✔
710
        out.add(vf.createStatement(subject, SpacesVocab.PARENT_SPACE, parentIri, GRAPH));
27✔
711
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
712
        addProvenance(subject, ctx, out);
12✔
713
    }
3✔
714

715
    // ---------------- gen:isMaintainedBy ----------------
716

717
    /**
718
     * Standalone {@code gen:isMaintainedBy} nanopub: every
719
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triple in the assertion emits
720
     * one {@code npa:MaintainedResourceDeclaration}. Multi-triple assertions are
721
     * allowed; one entry per pair. Self-loops ({@code <X> gen:isMaintainedBy <X>}) are
722
     * rejected.
723
     */
724
    private static void extractIsMaintainedBy(Nanopub np, Context ctx, List<Statement> out) {
725
        for (Statement st : np.getAssertion()) {
33✔
726
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) {
15✔
727
                continue;
3✔
728
            }
729
            if (!(st.getSubject() instanceof IRI resourceIri)) {
27!
730
                continue;
731
            }
732
            if (!(st.getObject() instanceof IRI spaceIri)) {
27!
733
                continue;
734
            }
735
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
736
        }
3✔
737
    }
3✔
738

739
    /**
740
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
741
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triples (object must equal
742
     * the Space IRI we're emitting an entry for, so the maintained-resource
743
     * declaration is bound to this particular Space). Self-loops are rejected.
744
     */
745
    private static void emitMaintainedResourceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
746
                                                           List<Statement> out) {
747
        for (Statement st : np.getAssertion()) {
33✔
748
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) {
15✔
749
                continue;
3✔
750
            }
751
            if (!spaceIri.equals(st.getObject())) {
15✔
752
                continue;
3✔
753
            }
754
            if (!(st.getSubject() instanceof IRI resourceIri)) {
27!
755
                continue;
756
            }
757
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
758
        }
3✔
759
    }
3✔
760

761
    /**
762
     * Emits one {@code npa:MaintainedResourceDeclaration} entry, keyed by
763
     * {@code (artifactCode, resourceHash)} so a single nanopub can declare multiple
764
     * maintained resources without subject collision. Self-loops are silently dropped.
765
     */
766
    private static void emitMaintainedResourceDeclaration(Nanopub np, Context ctx, IRI resourceIri,
767
                                                          IRI spaceIri, List<Statement> out) {
768
        if (resourceIri.equals(spaceIri)) {
12✔
769
            logger.debug("Ignoring self-loop maintained-resource declaration on {} in {}",
15✔
770
                    resourceIri, np.getUri());
3✔
771
            return;
3✔
772
        }
773
        String resourceHash = Utils.createHash(resourceIri);
9✔
774
        IRI subject = SpacesVocab.forMaintainedResourceDeclaration(ctx.artifactCode(), resourceHash);
15✔
775

776
        // Idempotence: the embedded (gen:Space) and standalone (gen:isMaintainedBy)
777
        // paths can both fire on the same (np, resource, space) combination if a
778
        // gen:Space nanopub somehow ends up typed gen:isMaintainedBy as well. Skip if
779
        // we've already emitted the type triple for this subject.
780
        Statement typeSt = vf.createStatement(subject, RDF.TYPE,
21✔
781
                SpacesVocab.MAINTAINED_RESOURCE_DECLARATION, GRAPH);
782
        if (out.contains(typeSt)) {
12!
783
            return;
×
784
        }
785

786
        out.add(typeSt);
12✔
787
        out.add(vf.createStatement(subject, SpacesVocab.RESOURCE_IRI, resourceIri, GRAPH));
27✔
788
        out.add(vf.createStatement(subject, SpacesVocab.MAINTAINER_SPACE, spaceIri, GRAPH));
27✔
789
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
790
        addProvenance(subject, ctx, out);
12✔
791
    }
3✔
792

793
    // ---------------- gen:Preset (preset declaration) ----------------
794

795
    /**
796
     * A {@code gen:Preset} nanopub bundles default views and roles. We extract only the
797
     * role half (views stay read-time in Nanodash; see
798
     * {@code doc/design-preset-role-materialization.md}): one
799
     * {@code npa:PresetDeclaration} carrying every {@code gen:hasRole} as
800
     * {@code npa:presetRole}, the {@code gen:appliesToInstancesOf} target(s), and the
801
     * preset's identity as {@code npa:ofPreset}.
802
     *
803
     * <p>Join robustness: the assignment's {@code gen:isAssignmentOfPreset} may name the
804
     * versioned preset node or the version-independent {@code dct:isVersionOf} kind
805
     * (Nanodash treats the kind as the canonical reference). We emit {@code npa:ofPreset}
806
     * for <em>both</em> so an assignment naming either joins to this declaration.
807
     */
808
    private static void extractPreset(Nanopub np, Context ctx, List<Statement> out) {
809
        // The preset IRI is embedded in this nanopub: <preset> rdf:type gen:Preset where
810
        // <preset> starts with the nanopub IRI (valid embedded mint), mirroring the
811
        // gen:SpaceMemberRole role-declaration rule.
812
        IRI presetIri = null;
6✔
813
        for (Statement st : np.getAssertion()) {
33✔
814
            if (!st.getPredicate().equals(RDF.TYPE)) {
15✔
815
                continue;
3✔
816
            }
817
            if (!GEN.PRESET.equals(st.getObject())) {
15!
818
                continue;
×
819
            }
820
            if (!(st.getSubject() instanceof IRI candidate)) {
27!
821
                continue;
822
            }
823
            if (!candidate.stringValue().startsWith(np.getUri().stringValue())) {
21✔
824
                continue;
3✔
825
            }
826
            presetIri = candidate;
6✔
827
            break;
3✔
828
        }
829
        if (presetIri == null) {
6✔
830
            return;
3✔
831
        }
832

833
        List<IRI> roles = collectObjects(np, presetIri, GEN.HAS_ROLE);
15✔
834
        List<IRI> appliesTo = collectObjects(np, presetIri, GEN.APPLIES_TO_INSTANCES_OF);
15✔
835
        List<IRI> kinds = collectObjects(np, presetIri, DCTERMS.IS_VERSION_OF);
15✔
836
        // Canonical kind: the dct:isVersionOf target, or the node IRI as fallback when the
837
        // preset declares no kind — same rule as Nanodash ViewDisplay.getViewKindIri().
838
        IRI presetKind = kinds.isEmpty() ? presetIri : kinds.get(0);
30✔
839

840
        IRI subject = SpacesVocab.forPresetDeclaration(ctx.artifactCode());
12✔
841
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.PRESET_DECLARATION, GRAPH));
27✔
842
        // Canonical version-independent grouping key (latest-declaration-per-kind resolution).
843
        out.add(vf.createStatement(subject, SpacesVocab.PRESET_KIND, presetKind, GRAPH));
27✔
844
        // Lookup keys: the preset's own node IRI plus its version-independent kind, so an
845
        // assignment naming either is mapped to this declaration's canonical kind.
846
        out.add(vf.createStatement(subject, SpacesVocab.OF_PRESET, presetIri, GRAPH));
27✔
847
        for (IRI kind : kinds) {
30✔
848
            out.add(vf.createStatement(subject, SpacesVocab.OF_PRESET, kind, GRAPH));
27✔
849
        }
3✔
850
        for (IRI role : roles) {
30✔
851
            out.add(vf.createStatement(subject, SpacesVocab.PRESET_ROLE, role, GRAPH));
27✔
852
        }
3✔
853
        for (IRI type : appliesTo) {
30✔
854
            out.add(vf.createStatement(subject, SpacesVocab.APPLIES_TO_INSTANCES_OF, type, GRAPH));
27✔
855
        }
3✔
856
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
857
        addProvenance(subject, ctx, out);
12✔
858
    }
3✔
859

860
    // ---------------- gen:PresetAssignment ----------------
861

862
    /**
863
     * A {@code gen:PresetAssignment} nanopub assigns a preset to a resource. Emits one
864
     * {@code npa:PresetAssignment} row recording the {@code (preset, resource)} pair and
865
     * its activation state. Activation is <em>active-by-default</em>: active unless the
866
     * assignment node is explicitly typed {@code gen:DeactivatedPresetAssignment} —
867
     * matching Nanodash's {@code PresetAssignment.isActive()}.
868
     *
869
     * <p>The {@code dct:created} timestamp emitted by {@link #addProvenance} is the
870
     * latest-wins key the validator uses to resolve same-pair assignments; it must be
871
     * present for the materialization to converge.
872
     */
873
    private static void extractPresetAssignment(Nanopub np, Context ctx, List<Statement> out) {
874
        // The assignment node is the subject of the gen:isAssignmentOfPreset triple.
875
        IRI assignmentNode = null;
6✔
876
        IRI presetIri = null;
6✔
877
        for (Statement st : np.getAssertion()) {
33!
878
            if (!st.getPredicate().equals(GEN.IS_ASSIGNMENT_OF_PRESET)) {
15✔
879
                continue;
3✔
880
            }
881
            if (!(st.getSubject() instanceof IRI subj)) {
27!
882
                continue;
883
            }
884
            if (!(st.getObject() instanceof IRI preset)) {
27!
885
                continue;
886
            }
887
            assignmentNode = subj;
6✔
888
            presetIri = preset;
6✔
889
            break;
3✔
890
        }
891
        if (assignmentNode == null) {
6!
892
            return;
×
893
        }
894

895
        IRI resource = null;
6✔
896
        for (Statement st : np.getAssertion()) {
33✔
897
            if (!assignmentNode.equals(st.getSubject())) {
15!
898
                continue;
×
899
            }
900
            if (!st.getPredicate().equals(GEN.IS_ASSIGNMENT_FOR)) {
15✔
901
                continue;
3✔
902
            }
903
            if (st.getObject() instanceof IRI res) {
27!
904
                resource = res;
6✔
905
                break;
3✔
906
            }
907
        }
×
908
        if (resource == null) {
6✔
909
            logger.warn("Ignoring preset assignment in {}: no gen:isAssignmentFor resource", np.getUri());
15✔
910
            return;
3✔
911
        }
912

913
        // Active-by-default: deactivated only if explicitly typed.
914
        boolean deactivated = false;
6✔
915
        for (Statement st : np.getAssertion()) {
33✔
916
            if (assignmentNode.equals(st.getSubject())
18!
917
                && st.getPredicate().equals(RDF.TYPE)
18✔
918
                && GEN.DEACTIVATED_PRESET_ASSIGNMENT.equals(st.getObject())) {
9✔
919
                deactivated = true;
6✔
920
                break;
3✔
921
            }
922
        }
3✔
923

924
        IRI subject = SpacesVocab.forPresetAssignment(ctx.artifactCode());
12✔
925
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.PRESET_ASSIGNMENT, GRAPH));
27✔
926
        out.add(vf.createStatement(subject, SpacesVocab.OF_PRESET, presetIri, GRAPH));
27✔
927
        out.add(vf.createStatement(subject, SpacesVocab.FOR_RESOURCE, resource, GRAPH));
27✔
928
        out.add(vf.createStatement(subject, SpacesVocab.IS_ACTIVATED, vf.createLiteral(!deactivated), GRAPH));
45✔
929
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
930
        addProvenance(subject, ctx, out);
12✔
931
    }
3✔
932

933
    // ---------------- gen:RevokedRoleInstantiation (role revocation, issue #129) ----------------
934

935
    /**
936
     * A {@code gen:RevokedRoleInstantiation} nanopub asserts that an agent no longer holds a
937
     * role in a space (a key-level negative on {@code (space, agent, role)}). The assertion
938
     * shape is a typed node:
939
     * <pre>{@code :r a gen:RevokedRoleInstantiation ; gen:forSpace <s> ; gen:forAgent <a> ; gen:hasRole <role> .}</pre>
940
     * For an <em>admin</em> revocation the role is {@code gen:AdminRole} (the admin tier carries
941
     * no per-role IRI), so the same shape covers every tier. Emits one {@code npa:RoleRevocation}
942
     * row per revoked node; the {@code dct:created} from {@link #addProvenance} is the latest-wins
943
     * key the materializer uses to decide whether this negative shadows the standing assertion.
944
     * This negative never materializes as a state row — it is consumed as a suppression filter /
945
     * displacement DELETE in the tier where the key is bound.
946
     */
947
    private static void extractRevokedRoleInstantiation(Nanopub np, Context ctx, List<Statement> out) {
948
        // One pass over the assertion: index IRI-valued (subject -> predicate -> object) and
949
        // collect the RevokedRoleInstantiation-typed nodes. Avoids the quadratic re-scan a
950
        // per-node singleIriObject lookup would incur on a multi-revocation nanopub.
951
        Map<IRI, Map<IRI, IRI>> bySubject = new LinkedHashMap<>();
12✔
952
        List<IRI> revokedNodes = new ArrayList<>();
12✔
953
        for (Statement st : np.getAssertion()) {
33✔
954
            if (!(st.getSubject() instanceof IRI s) || !(st.getObject() instanceof IRI o)) {
54!
955
                continue;
956
            }
957
            bySubject.computeIfAbsent(s, k -> new LinkedHashMap<>()).putIfAbsent(st.getPredicate(), o);
42✔
958
            if (st.getPredicate().equals(RDF.TYPE) && GEN.REVOKED_ROLE_INSTANTIATION.equals(o)) {
27!
959
                revokedNodes.add(s);
12✔
960
            }
961
        }
3✔
962
        for (IRI node : revokedNodes) {
30✔
963
            Map<IRI, IRI> props = bySubject.getOrDefault(node, Map.of());
18✔
964
            IRI space = props.get(GEN.FOR_SPACE);
15✔
965
            IRI agent = props.get(GEN.FOR_AGENT);
15✔
966
            IRI role = props.get(GEN.HAS_ROLE);
15✔
967
            if (space == null || agent == null || role == null) {
18!
968
                logger.warn("Ignoring role revocation node {} in {}: missing forSpace/forAgent/hasRole",
15✔
969
                        node, np.getUri());
3✔
970
                continue;
3✔
971
            }
972
            // Separator that cannot appear in an IRI (newline) so distinct (agent, role) pairs
973
            // in the same nanopub never collide onto one nparev: subject.
974
            String discriminator = Utils.createHash(agent.stringValue() + "\n" + role.stringValue());
21✔
975
            IRI subject = SpacesVocab.forRoleRevocation(ctx.artifactCode(), discriminator);
15✔
976
            out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_REVOCATION, GRAPH));
27✔
977
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, space, GRAPH));
27✔
978
            out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agent, GRAPH));
27✔
979
            out.add(vf.createStatement(subject, SpacesVocab.REVOKED_ROLE, role, GRAPH));
27✔
980
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
981
            addProvenance(subject, ctx, out);
12✔
982
        }
3✔
983
    }
3✔
984

985
    // ---------------- gen:detachedRole (role detachment, issue #129) ----------------
986

987
    /**
988
     * A {@code gen:detachedRole} nanopub asserts {@code <space> gen:detachedRole <role>} — the
989
     * stative antonym of {@code gen:hasRole}, a key-level negative on {@code (space, role)}.
990
     * Emits one {@code npa:RoleDetachment} row per triple; latest-wins (by {@code dct:created})
991
     * against direct <em>and</em> preset-derived attachments removes the role's availability in
992
     * the space, cascading to the instantiations anchored on it. Never materializes as a state row.
993
     */
994
    private static void extractDetachedRole(Nanopub np, Context ctx, List<Statement> out) {
995
        for (Statement st : np.getAssertion()) {
33✔
996
            if (!st.getPredicate().equals(GEN.DETACHED_ROLE)
15!
997
                || !(st.getSubject() instanceof IRI spaceIri)
27!
998
                || !(st.getObject() instanceof IRI roleIri)) {
27!
999
                continue;
1000
            }
1001
            String roleHash = Utils.createHash(roleIri.stringValue());
12✔
1002
            IRI subject = SpacesVocab.forRoleDetachment(ctx.artifactCode(), roleHash);
15✔
1003
            out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_DETACHMENT, GRAPH));
27✔
1004
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
1005
            out.add(vf.createStatement(subject, SpacesVocab.REVOKED_ROLE, roleIri, GRAPH));
27✔
1006
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
1007
            addProvenance(subject, ctx, out);
12✔
1008
        }
3✔
1009
    }
3✔
1010

1011
    /** Collects all IRI objects of {@code subject predicate ?o} triples in the assertion. */
1012
    private static List<IRI> collectObjects(Nanopub np, IRI subject, IRI predicate) {
1013
        List<IRI> out = new ArrayList<>();
12✔
1014
        for (Statement st : np.getAssertion()) {
33✔
1015
            if (!subject.equals(st.getSubject())) {
15!
1016
                continue;
×
1017
            }
1018
            if (!predicate.equals(st.getPredicate())) {
15✔
1019
                continue;
3✔
1020
            }
1021
            if (st.getObject() instanceof IRI obj) {
27!
1022
                out.add(obj);
12✔
1023
            }
1024
        }
3✔
1025
        return out;
6✔
1026
    }
1027

1028
    // ---------------- owl:sameAs (space aliases) ----------------
1029

1030
    /**
1031
     * Scans a {@code gen:Space} nanopub's assertion for
1032
     * {@code <spaceIri> owl:sameAs <aliasIri>} triples (subject must equal the Space IRI
1033
     * being emitted, so the alias declaration is bound to this particular Space) and emits
1034
     * one {@code npa:SpaceAliasDeclaration} per {@code (spaceIri, aliasIri)} pair. The
1035
     * Space IRI is the canonical side; the {@code owl:sameAs} object is the alias.
1036
     * Self-aliases ({@code <X> owl:sameAs <X>}) are rejected.
1037
     */
1038
    private static void emitSpaceAliasDeclarations(Nanopub np, Context ctx, IRI spaceIri,
1039
                                                   List<Statement> out) {
1040
        for (Statement st : np.getAssertion()) {
33✔
1041
            if (!st.getPredicate().equals(OWL.SAMEAS)) {
15✔
1042
                continue;
3✔
1043
            }
1044
            if (!spaceIri.equals(st.getSubject())) {
15✔
1045
                continue;
3✔
1046
            }
1047
            if (!(st.getObject() instanceof IRI aliasIri)) {
27!
1048
                continue;
1049
            }
1050
            emitSpaceAliasDeclaration(np, ctx, spaceIri, aliasIri, out);
18✔
1051
        }
3✔
1052
    }
3✔
1053

1054
    /**
1055
     * Emits one {@code npa:SpaceAliasDeclaration} entry, keyed by
1056
     * {@code (artifactCode, aliasHash)} so a single nanopub can declare multiple aliases
1057
     * without subject collision. Self-aliases are silently dropped.
1058
     */
1059
    private static void emitSpaceAliasDeclaration(Nanopub np, Context ctx, IRI canonicalIri,
1060
                                                  IRI aliasIri, List<Statement> out) {
1061
        if (canonicalIri.equals(aliasIri)) {
12✔
1062
            logger.debug("Ignoring self-alias declaration on {} in {}", canonicalIri, np.getUri());
18✔
1063
            return;
3✔
1064
        }
1065
        String aliasHash = Utils.createHash(aliasIri);
9✔
1066
        IRI subject = SpacesVocab.forSpaceAliasDeclaration(ctx.artifactCode(), aliasHash);
15✔
1067

1068
        // Idempotence: a single (np, canonical, alias) combination should produce one entry
1069
        // even if emitSpaceAliasDeclarations somehow sees the triple twice.
1070
        Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SPACE_ALIAS_DECLARATION, GRAPH);
21✔
1071
        if (out.contains(typeSt)) {
12!
1072
            return;
×
1073
        }
1074

1075
        out.add(typeSt);
12✔
1076
        out.add(vf.createStatement(subject, SpacesVocab.CANONICAL_SPACE, canonicalIri, GRAPH));
27✔
1077
        out.add(vf.createStatement(subject, SpacesVocab.ALIAS_SPACE, aliasIri, GRAPH));
27✔
1078
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
1079
        addProvenance(subject, ctx, out);
12✔
1080
    }
3✔
1081

1082
    // ---------------- ID-prefix enumeration ----------------
1083

1084
    /**
1085
     * Returns the immediate URL-path parent of a Space IRI, after normalisation,
1086
     * for the URL-prefix sub-space fallback. Strips query / fragment / trailing
1087
     * slash, then drops the last path segment after the {@code ://} scheme
1088
     * separator. Returns at most one IRI; empty for inputs without a scheme
1089
     * separator or without any path beyond the host.
1090
     *
1091
     * <p>Direct-parent-only semantics matches Nanodash's existing
1092
     * {@code SpaceRepository.findSubspaces(...)} URL-regex behaviour. Multi-level
1093
     * containment queries should use SPARQL property paths
1094
     * ({@code <ancestor> npa:hasSubSpace+ ?descendant}) which walk the chain
1095
     * transitively, so deeper descendants remain reachable as long as the
1096
     * intermediate Spaces exist.
1097
     *
1098
     * <p>Examples:
1099
     * <pre>
1100
     *   https://example.org/a/b/c/space  →  [https://example.org/a/b/c]
1101
     *   https://example.org/space        →  [https://example.org]   (single segment → host)
1102
     *   https://example.org/x/           →  [https://example.org]   (trailing slash stripped)
1103
     *   https://example.org/a/space?q=1  →  [https://example.org/a] (query stripped)
1104
     *   https://example.org              →  []                       (no path to strip)
1105
     * </pre>
1106
     */
1107
    static List<IRI> enumerateIdPrefixes(IRI spaceIri) {
1108
        String s = spaceIri.stringValue();
9✔
1109
        int hash = s.indexOf('#');
12✔
1110
        if (hash >= 0) {
6✔
1111
            s = s.substring(0, hash);
15✔
1112
        }
1113
        int qmark = s.indexOf('?');
12✔
1114
        if (qmark >= 0) {
6✔
1115
            s = s.substring(0, qmark);
15✔
1116
        }
1117
        while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
39✔
1118

1119
        int schemeEnd = s.indexOf("://");
12✔
1120
        if (schemeEnd < 0) {
6✔
1121
            return Collections.emptyList();
6✔
1122
        }
1123
        int hostStart = schemeEnd + 3;
12✔
1124
        int hostEnd = s.indexOf('/', hostStart);
15✔
1125
        if (hostEnd < 0) {
6✔
1126
            return Collections.emptyList();   // host-only, nothing to strip
6✔
1127
        }
1128

1129
        // Drop the last path segment. If that strips us back to the host (single-
1130
        // segment path), return the host-only IRI as the immediate parent.
1131
        int lastSlash = s.lastIndexOf('/');
12✔
1132
        String parent = (lastSlash <= hostEnd) ? s.substring(0, hostEnd) : s.substring(0, lastSlash);
39✔
1133
        return List.of(vf.createIRI(parent));
15✔
1134
    }
1135

1136
    // ---------------- shared helpers ----------------
1137

1138
    private static void addProvenance(Resource subject, Context ctx, List<Statement> out) {
1139
        if (ctx.signedBy() != null) {
9!
1140
            out.add(vf.createStatement(subject, NPX.SIGNED_BY, ctx.signedBy(), GRAPH));
30✔
1141
        }
1142
        if (ctx.pubkeyHash() != null) {
9!
1143
            out.add(vf.createStatement(subject, SpacesVocab.PUBKEY_HASH,
27✔
1144
                    vf.createLiteral(ctx.pubkeyHash()), GRAPH));
9✔
1145
        }
1146
        if (ctx.createdAt() != null) {
9!
1147
            Literal ts = vf.createLiteral(ctx.createdAt());
15✔
1148
            out.add(vf.createStatement(subject, DCTERMS.CREATED, ts, GRAPH));
27✔
1149
        }
1150
    }
3✔
1151

1152
    private static boolean anyMatch(Set<IRI> types, Set<IRI> candidates) {
1153
        for (IRI c : candidates) {
30✔
1154
            if (types.contains(c)) {
12✔
1155
                return true;
6✔
1156
            }
1157
        }
3✔
1158
        return false;
6✔
1159
    }
1160

1161
    // ---------------- load-number stamping ----------------
1162

1163
    /**
1164
     * Stamps {@code <thisNP> npa:hasLoadNumber <N>} on the given nanopub. Intended to
1165
     * be called by the loader once per nanopub, in the same transaction as the
1166
     * extraction writes. Also bumps {@code npa:thisRepo npa:currentLoadCounter <N>}
1167
     * in the admin graph so the materializer's delta cycles know the horizon.
1168
     *
1169
     * @param npId       nanopub IRI
1170
     * @param loadNumber the load counter value
1171
     * @return two statements: load-number stamp + current-load-counter value
1172
     */
1173
    public static List<Statement> loadCounterStatements(IRI npId, long loadNumber) {
1174
        List<Statement> out = new ArrayList<>(2);
15✔
1175
        Literal lit = vf.createLiteral(loadNumber);
12✔
1176
        out.add(vf.createStatement(npId, NPA.HAS_LOAD_NUMBER, lit, NPA.GRAPH));
27✔
1177
        out.add(vf.createStatement(NPA.THIS_REPO, SpacesVocab.CURRENT_LOAD_COUNTER, lit, NPA.GRAPH));
27✔
1178
        return out;
6✔
1179
    }
1180

1181
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc