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

knowledgepixels / nanopub-query / 26504038865

27 May 2026 09:52AM UTC coverage: 59.597% (+0.3%) from 59.27%
26504038865

push

github

web-flow
Merge pull request #115 from knowledgepixels/fix/issue-113-sameas-alias

fix(spaces): honor owl:sameAs space aliases in /repo/spaces materializer (#113)

480 of 896 branches covered (53.57%)

Branch coverage included in aggregate %.

1383 of 2230 relevant lines covered (62.02%)

9.5 hits per line

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

16.29
src/main/java/com/knowledgepixels/query/AuthorityResolver.java
1
package com.knowledgepixels.query;
2

3
import java.util.ArrayList;
4
import java.util.List;
5
import java.util.Optional;
6
import java.util.Set;
7

8
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
9
import org.eclipse.rdf4j.model.IRI;
10
import org.eclipse.rdf4j.model.Statement;
11
import org.eclipse.rdf4j.model.Value;
12
import org.eclipse.rdf4j.model.ValueFactory;
13
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
14
import org.eclipse.rdf4j.model.vocabulary.FOAF;
15
import org.eclipse.rdf4j.model.vocabulary.RDF;
16
import org.eclipse.rdf4j.query.BindingSet;
17
import org.eclipse.rdf4j.query.QueryLanguage;
18
import org.eclipse.rdf4j.query.TupleQueryResult;
19
import org.eclipse.rdf4j.repository.RepositoryConnection;
20
import org.eclipse.rdf4j.repository.RepositoryResult;
21
import org.nanopub.vocabulary.NPA;
22
import org.nanopub.vocabulary.NPX;
23
import org.slf4j.Logger;
24
import org.slf4j.LoggerFactory;
25

26
import com.knowledgepixels.query.vocabulary.GEN;
27
import com.knowledgepixels.query.vocabulary.NPAT;
28
import com.knowledgepixels.query.vocabulary.SpacesVocab;
29

30
/**
31
 * Drives the space-state materialization pipeline. Three entry points scheduled
32
 * by {@code MainVerticle}:
33
 * <ul>
34
 *   <li>{@link #tick()} — detects trust-state flips (full build) and otherwise
35
 *       advances the current space-state graph by an {@link #runIncrementalCycle
36
 *       incremental cycle} bounded by {@code (processedUpTo, currentLoadCounter]}.</li>
37
 *   <li>{@link #periodicRebuildTick()} — checks the {@code npa:needsFullRebuild}
38
 *       flag set by structural invalidations and re-runs the full build into a
39
 *       fresh graph, atomically flips the pointer, drops the old graph.</li>
40
 *   <li>{@link #cleanOrphans()} — startup cleanup of {@code npass:*} graphs the
41
 *       pointer isn't referencing.</li>
42
 * </ul>
43
 *
44
 * <p>Incremental cycle order: invalidation DELETEs (admin RI / RoleAssignment /
45
 * non-admin RI) → mirror-step delta is implicit (rebuilt only on full build) →
46
 * per-tier INSERTs (admin → alias → attachment → maintainer → member → observer) →
47
 * late-arrival sweep (re-run downstream tiers without the load-number filter
48
 * iff this cycle added any structural rows). Sets {@code npa:needsFullRebuild}
49
 * when an admin RI / RoleAssignment / RoleDeclaration was invalidated; periodic
50
 * worker turns the flag into a from-scratch rebuild.
51
 *
52
 * <p>See {@code doc/design-space-repositories.md} — this implements the "Full
53
 * build", "Incremental cycle", and "Periodic full rebuild" procedures.
54
 */
55
public final class AuthorityResolver {
56

57
    private static final Logger log = LoggerFactory.getLogger(AuthorityResolver.class);
9✔
58

59
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
60

61
    private static final String SPACES_REPO = "spaces";
62
    private static final String TRUST_REPO = "trust";
63

64
    /** NPA constants pulled in locally (trust-side). */
65
    private static final IRI NPA_HAS_CURRENT_TRUST_STATE =
9✔
66
            vf.createIRI(NPA.NAMESPACE, "hasCurrentTrustState");
6✔
67
    private static final IRI NPA_ACCOUNT_STATE = vf.createIRI(NPA.NAMESPACE, "AccountState");
15✔
68
    private static final IRI NPA_AGENT = vf.createIRI(NPA.NAMESPACE, "agent");
15✔
69
    private static final IRI NPA_PUBKEY = vf.createIRI(NPA.NAMESPACE, "pubkey");
15✔
70
    private static final IRI NPA_TRUST_STATUS = vf.createIRI(NPA.NAMESPACE, "trustStatus");
15✔
71
    private static final IRI NPA_LOADED = vf.createIRI(NPA.NAMESPACE, "loaded");
15✔
72
    private static final IRI NPA_TO_LOAD = vf.createIRI(NPA.NAMESPACE, "toLoad");
15✔
73

74
    /**
75
     * Trust-approved set: rows with one of these {@code npa:trustStatus} values
76
     * are mirrored into the space-state graph. Per
77
     * {@code doc/design-trust-state-repos.md}, these are the two "authority-
78
     * approving" statuses; {@code npa:contested}, {@code npa:skipped}, and the
79
     * transient statuses are distinct values of the same predicate and are
80
     * excluded automatically by this positive-list filter.
81
     */
82
    private static final Set<IRI> APPROVED_SET = Set.of(NPA_LOADED, NPA_TO_LOAD);
15✔
83

84
    private static AuthorityResolver instance;
85

86
    /** Returns the singleton. */
87
    public static synchronized AuthorityResolver get() {
88
        if (instance == null) {
6✔
89
            instance = new AuthorityResolver();
12✔
90
        }
91
        return instance;
6✔
92
    }
93

94
    private AuthorityResolver() {
6✔
95
    }
3✔
96

97
    // ---------------- Operational metrics snapshot ----------------
98
    //
99
    // Updated at the end of each runFullBuild / runIncrementalCycle, read by
100
    // MetricsCollector via the get*() accessors below. volatile is enough —
101
    // writers serialise via the synchronized methods, and readers (Prometheus
102
    // scrapes) only need most-recent visibility, not transactional consistency
103
    // across the snapshot. Defaults to zero values so a scrape that races a
104
    // boot before the first cycle returns 0, not NaN.
105

106
    private volatile TierSubjectTotals lastSubjectTotals = new TierSubjectTotals(0L, 0L, 0L);
24✔
107
    private volatile long lastInsertedTriplesTotal;
108
    private volatile long lastFullBuildDurationMs;
109
    private volatile long lastIncrementalCycleDurationMs;
110
    private volatile long lastProcessedUpToLag;
111

112
    public TierSubjectTotals getLastSubjectTotals() { return lastSubjectTotals; }
9✔
113
    public long getLastInsertedTriplesTotal() { return lastInsertedTriplesTotal; }
9✔
114
    public long getLastFullBuildDurationMs() { return lastFullBuildDurationMs; }
9✔
115
    public long getLastIncrementalCycleDurationMs() { return lastIncrementalCycleDurationMs; }
9✔
116
    public long getLastProcessedUpToLag() { return lastProcessedUpToLag; }
9✔
117

118
    // ---------------- Public entry points ----------------
119

120
    /**
121
     * Poll entry point. Behaviour:
122
     * <ul>
123
     *   <li>If no current space-state graph or the trust state has flipped → full build.</li>
124
     *   <li>Otherwise → {@link #runIncrementalCycle incremental cycle} on the load-number
125
     *       delta {@code (processedUpTo, currentLoadCounter]}. No-op if {@code
126
     *       processedUpTo == currentLoadCounter}.</li>
127
     * </ul>
128
     * Safe to call repeatedly on a schedule. Gated by {@link FeatureFlags#spacesEnabled()}.
129
     */
130
    public void tick() {
131
        if (!FeatureFlags.spacesEnabled()) return;
6!
132
        String trustStateHash = TrustStateRegistry.get().getCurrentHash().orElse(null);
18✔
133
        if (trustStateHash == null) {
6!
134
            log.debug("AuthorityResolver.tick: no current trust state yet — skipping");
9✔
135
            return;
3✔
136
        }
137
        IRI currentGraph = getCurrentSpaceStateGraph();
×
138
        String currentGraphName = (currentGraph == null) ? null
×
139
                : currentGraph.stringValue().substring(SpacesVocab.NPASS_NAMESPACE.length());
×
140
        if (currentGraphName == null || !currentGraphName.startsWith(trustStateHash + "_")) {
×
141
            log.info("AuthorityResolver.tick: trust-state flip detected (now {}); running full build",
×
142
                    abbrev(trustStateHash));
×
143
            runFullBuild(trustStateHash);
×
144
            return;
×
145
        }
146
        runIncrementalCycle(currentGraph);
×
147
    }
×
148

149
    /**
150
     * Periodic worker. If {@code npa:needsFullRebuild} was raised by an
151
     * incremental cycle's structural DELETE, runs a from-scratch rebuild into
152
     * a fresh space-state graph (using the current trust-state hash and load
153
     * counter) and clears the flag. No-op when the flag is not set. Safe to
154
     * call concurrently with {@link #tick()} when both are scheduled on the
155
     * same single-threaded executor.
156
     */
157
    public void periodicRebuildTick() {
158
        if (!FeatureFlags.spacesEnabled()) return;
×
159
        if (!readNeedsFullRebuild()) return;
×
160
        String trustStateHash = TrustStateRegistry.get().getCurrentHash().orElse(null);
×
161
        if (trustStateHash == null) {
×
162
            log.debug("AuthorityResolver.periodicRebuildTick: no current trust state — deferring");
×
163
            return;
×
164
        }
165
        log.info("AuthorityResolver.periodicRebuildTick: needsFullRebuild flag set; rebuilding");
×
166
        runFullBuild(trustStateHash);
×
167
        clearNeedsFullRebuild();
×
168
    }
×
169

170
    /**
171
     * Startup cleanup: drop any {@code npass:*} graph that the
172
     * {@code npa:hasCurrentSpaceState} pointer isn't pointing at. Orphans come
173
     * from crashes mid-build. Safe to call at any time; idempotent.
174
     */
175
    public void cleanOrphans() {
176
        if (!FeatureFlags.spacesEnabled()) return;
×
177
        IRI current = getCurrentSpaceStateGraph();
×
178
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
179
            int dropped = 0;
×
180
            try (RepositoryResult<org.eclipse.rdf4j.model.Resource> ctxs = conn.getContextIDs()) {
×
181
                List<IRI> toDrop = new ArrayList<>();
×
182
                while (ctxs.hasNext()) {
×
183
                    org.eclipse.rdf4j.model.Resource ctx = ctxs.next();
×
184
                    if (!(ctx instanceof IRI iri)) continue;
×
185
                    if (!iri.stringValue().startsWith(SpacesVocab.NPASS_NAMESPACE)) continue;
×
186
                    if (iri.equals(current)) continue;
×
187
                    toDrop.add(iri);
×
188
                }
×
189
                for (IRI iri : toDrop) {
×
190
                    conn.begin(IsolationLevels.SERIALIZABLE);
×
191
                    conn.clear(iri);
×
192
                    conn.commit();
×
193
                    dropped++;
×
194
                    log.info("AuthorityResolver.cleanOrphans: dropped orphan graph {}", iri);
×
195
                }
×
196
            }
197
            if (dropped == 0) {
×
198
                log.debug("AuthorityResolver.cleanOrphans: no orphan space-state graphs");
×
199
            }
200
        } catch (Exception ex) {
×
201
            log.info("AuthorityResolver.cleanOrphans: failed: {}", ex.toString());
×
202
        }
×
203
    }
×
204

205
    // ---------------- Full build ----------------
206

207
    /**
208
     * Mutex-protected full build of the space-state graph for the given trust
209
     * state. Captures {@code M = currentLoadCounter}, mirrors trust-approved
210
     * rows, (PR 2b: runs per-tier UPDATE loops from scratch), stamps
211
     * {@code processedUpTo = M}, flips the pointer, drops the previous graph.
212
     */
213
    synchronized void runFullBuild(String trustStateHash) {
214
        long startNanos = System.nanoTime();
×
215
        long loadCounter = getCurrentLoadCounter();
×
216
        IRI newGraph = SpacesVocab.forSpaceState(trustStateHash, loadCounter);
×
217
        IRI oldGraph = getCurrentSpaceStateGraph();
×
218
        if (newGraph.equals(oldGraph)) {
×
219
            log.debug("AuthorityResolver.runFullBuild: already current at {}", newGraph);
×
220
            return;
×
221
        }
222

223
        // 1. Mirror trust-approved rows into the new graph.
224
        int mirrored = mirrorTrustState(trustStateHash, newGraph);
×
225

226
        // 2. Per-tier UPDATE loops (from scratch: lastProcessed = -1 so the
227
        //    delta filter FILTER(?ln > ?lastProcessed) includes everything).
228
        TierInsertedTriples counts = runAllTierLoops(newGraph, -1);
×
229

230
        // 3. Stamp processedUpTo inside the new graph.
231
        writeProcessedUpTo(newGraph, loadCounter);
×
232

233
        // 4. Flip the current-space-state pointer.
234
        flipPointer(newGraph);
×
235

236
        // 5. Drop the old graph if one existed.
237
        if (oldGraph != null) {
×
238
            dropGraph(oldGraph);
×
239
        }
240

241
        TierSubjectTotals totals = computeTierSubjectTotals(newGraph);
×
242
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
×
243
        lastSubjectTotals = totals;
×
244
        lastInsertedTriplesTotal = (long) counts.admin + counts.alias + counts.attachment
×
245
                + counts.maintainer + counts.member + counts.observer
246
                + counts.subSpace + counts.subSpacePrefix + counts.maintainedResource;
247
        lastFullBuildDurationMs = durationMs;
×
248
        lastProcessedUpToLag = 0L;
×
249
        log.info("AuthorityResolver: full build complete — graph={} mirrored={} rows loadCounter={} "
×
250
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
251
                        + "(inserted-triples: admin={} alias={} attachment={} maintainer={} member={} observer={} "
252
                        + "subspace={} subspace-prefix={} maintained-resource={}) durationMs={}",
253
                newGraph, mirrored, loadCounter,
×
254
                totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(),
×
255
                counts.admin, counts.alias, counts.attachment, counts.maintainer, counts.member, counts.observer,
×
256
                counts.subSpace, counts.subSpacePrefix, counts.maintainedResource,
×
257
                durationMs);
×
258
    }
×
259

260
    // ---------------- Incremental cycle ----------------
261

262
    /**
263
     * Single delta cycle on the current space-state graph. Bounded by
264
     * {@code (processedUpTo, currentLoadCounter]}; no-op if the range is empty.
265
     *
266
     * <p>Order:
267
     * <ol>
268
     *   <li>Apply invalidation DELETEs (admin RI, RoleAssignment, non-admin RI)
269
     *       and the RoleDeclaration ASK. Any DELETE on a structural kind sets
270
     *       {@code npa:needsFullRebuild} to bound the staleness from sticky
271
     *       downstream entries; the periodic worker turns that into a from-scratch
272
     *       rebuild on its next pass.</li>
273
     *   <li>Run per-tier INSERTs in the same order as the full build.</li>
274
     *   <li>Late-arrival sweep: if any structural row was added, re-run downstream
275
     *       tier INSERTs with {@code lastProcessed = -1} to catch candidates whose
276
     *       enabling event landed in this same cycle. Dedup filters protect
277
     *       against double-insert.</li>
278
     *   <li>Bump {@code processedUpTo} to {@code currentLoadCounter}.</li>
279
     * </ol>
280
     */
281
    synchronized void runIncrementalCycle(IRI graph) {
282
        long startNanos = System.nanoTime();
×
283
        long currentLoadCounter = getCurrentLoadCounter();
×
284
        long lastProcessed = readProcessedUpTo(graph);
×
285
        if (lastProcessed < 0) {
×
286
            log.warn("AuthorityResolver.runIncrementalCycle: missing processedUpTo on {}; skipping",
×
287
                    graph);
288
            return;
×
289
        }
290
        lastProcessedUpToLag = currentLoadCounter - lastProcessed;
×
291
        if (currentLoadCounter <= lastProcessed) {
×
292
            log.debug("AuthorityResolver.runIncrementalCycle: caught up at load {} on {}",
×
293
                    currentLoadCounter, graph);
×
294
            return;
×
295
        }
296

297
        boolean structuralInvalidation = applyInvalidations(graph, lastProcessed);
×
298
        TierInsertedTriples counts = runAllTierLoops(graph, lastProcessed);
×
299
        boolean structuralAdds = (counts.admin > 0)
×
300
                || (counts.alias > 0)
301
                || (counts.attachment > 0)
302
                || (counts.subSpace > 0)
303
                || newRoleDeclarationsArrived(lastProcessed);
×
304
        if (structuralAdds) {
×
305
            // Late-arrival sweep: leaf tiers (attachment/maintainer/member/observer)
306
            // can promote candidates whose enabling event arrived in this same cycle.
307
            // Sub-space admit is also re-run here for Mode-B late-arrival (a new
308
            // partner declaration can validate an older primary that the regular
309
            // pass's load-number filter excluded). The URL-prefix fallback also
310
            // re-runs so newly-orphaned children pick up derived edges. Skip the
311
            // admin tier — its only enabling event is the admin grant itself,
312
            // already handled by the regular pass.
313
            TierInsertedTriples lateCounts = runDownstreamWithoutLoadFilter(graph);
×
314
            counts.alias              += lateCounts.alias;
×
315
            counts.attachment         += lateCounts.attachment;
×
316
            counts.maintainer         += lateCounts.maintainer;
×
317
            counts.member             += lateCounts.member;
×
318
            counts.observer           += lateCounts.observer;
×
319
            counts.subSpace           += lateCounts.subSpace;
×
320
            counts.subSpacePrefix     += lateCounts.subSpacePrefix;
×
321
            counts.maintainedResource += lateCounts.maintainedResource;
×
322
        }
323

324
        writeProcessedUpTo(graph, currentLoadCounter);
×
325

326
        TierSubjectTotals totals = computeTierSubjectTotals(graph);
×
327
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
×
328
        lastSubjectTotals = totals;
×
329
        lastInsertedTriplesTotal = (long) counts.admin + counts.alias + counts.attachment
×
330
                + counts.maintainer + counts.member + counts.observer
331
                + counts.subSpace + counts.subSpacePrefix + counts.maintainedResource;
332
        lastIncrementalCycleDurationMs = durationMs;
×
333
        log.info("AuthorityResolver: incremental cycle complete — graph={} delta=({}, {}] "
×
334
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
335
                        + "(inserted-triples: admin={} alias={} attachment={} maintainer={} member={} observer={} "
336
                        + "subspace={} subspace-prefix={} maintained-resource={}) "
337
                        + "structuralInvalidation={} structuralAdds={} durationMs={}",
338
                graph, lastProcessed, currentLoadCounter,
×
339
                totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(),
×
340
                counts.admin, counts.alias, counts.attachment, counts.maintainer, counts.member, counts.observer,
×
341
                counts.subSpace, counts.subSpacePrefix, counts.maintainedResource,
×
342
                structuralInvalidation, structuralAdds, durationMs);
×
343
    }
×
344

345
    /**
346
     * Runs the four invalidation-DELETE / ASK steps. Sets {@code npa:needsFullRebuild}
347
     * when admin-RI, RoleAssignment, or RoleDeclaration invalidations matched (the
348
     * three structural kinds). Leaf-tier RI deletes don't set the flag.
349
     *
350
     * @return true iff at least one structural kind was invalidated
351
     */
352
    boolean applyInvalidations(IRI graph, long lastProcessed) {
353
        boolean structural = false;
×
354
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ true,
×
355
                            adminInvalidationCheckWhere(graph, lastProcessed))) {
×
356
            executeUpdate(adminInvalidationDelete(graph, lastProcessed));
×
357
            structural = true;
×
358
        }
359
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
360
                            roleAssignmentInvalidationCheckWhere(graph, lastProcessed))) {
×
361
            executeUpdate(roleAssignmentInvalidationDelete(graph, lastProcessed));
×
362
            structural = true;
×
363
        }
364
        // RoleDeclaration ASK only — RDs aren't materialized into the space-state
365
        // graph, so there's nothing to DELETE here. The flag still flips because
366
        // sticky downstream RIs derived from the now-invalidated RD need a
367
        // from-scratch recompute.
368
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
369
                            roleDeclarationInvalidationCheckWhere(lastProcessed))) {
×
370
            structural = true;
×
371
        }
372
        // Sub-space declarations are structural — invalidating one (Mode A) or one
373
        // of two co-declarations (Mode B) changes the validated parent/child
374
        // topology. The DELETE removes the per-declaration row; the convenience
375
        // direct triples are left sticky and cleaned on the next periodic rebuild.
376
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
377
                            subSpaceInvalidationCheckWhere(graph, lastProcessed))) {
×
378
            executeUpdate(subSpaceInvalidationDelete(graph, lastProcessed));
×
379
            structural = true;
×
380
        }
381
        // Space-alias declarations are structural — invalidating one removes an
382
        // owl:sameAs edge that feeds the admin-authority closure (issue #113). The
383
        // DELETE removes the per-declaration row; the convenience npa:sameAsSpace edge
384
        // is left sticky and cleaned on the next periodic rebuild (same policy as
385
        // sub-space declarations).
386
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
387
                            aliasInvalidationCheckWhere(graph, lastProcessed))) {
×
388
            executeUpdate(aliasInvalidationDelete(graph, lastProcessed));
×
389
            structural = true;
×
390
        }
391
        // Leaf-tier RI deletes — no flag.
392
        executeUpdate(leafTierInvalidationDelete(graph, lastProcessed));
×
393
        // Maintained-resource declaration deletes — no flag (leaf relation, no
394
        // downstream caches to bound).
395
        executeUpdate(maintainedResourceInvalidationDelete(graph, lastProcessed));
×
396
        if (structural) setNeedsFullRebuild();
×
397
        return structural;
×
398
    }
399

400
    /**
401
     * Runs the four leaf tiers (attachment/maintainer/member/observer) with
402
     * {@code lastProcessed = -1} so the load-number filter on the candidate
403
     * side admits everything. Dedup filters in the tier templates prevent
404
     * double-insert. Used by the late-arrival sweep.
405
     */
406
    TierInsertedTriples runDownstreamWithoutLoadFilter(IRI graph) {
407
        TierInsertedTriples c = new TierInsertedTriples();
×
408
        // Alias late-arrival: catches alias declarations whose canonical admin grant
409
        // became valid only in this same cycle (the load-number filter on the
410
        // declaration's nanopub would otherwise exclude it). Runs first so the
411
        // attachment / role tiers below see this cycle's fresh npa:sameAsSpace edges.
412
        c.alias = runTierLabeled("alias(late)", graph, aliasAdmitUpdate(graph, -1));
×
413
        // Sub-space late-arrival: catches Mode-B candidates whose primary
414
        // declaration is older than lastProcessed but whose partner just landed.
415
        c.subSpace = runTierLabeled("subspace(late)", graph,
×
416
                subSpaceAdmitUpdate(graph, -1));
×
417
        // Maintained-resource late-arrival: catches declarations that landed
418
        // before the publisher's admin grant became valid in this state.
419
        c.maintainedResource = runTierLabeled("maintained-resource(late)", graph,
×
420
                maintainedResourceAdmitUpdate(graph, -1));
×
421
        // URL-prefix fallback: re-run after the late-arrival sub-space admit so
422
        // any newly-validated children get their fallback edges suppressed (for
423
        // future inserts) and any newly-orphaned children pick up fallback edges.
424
        c.subSpacePrefix = runTierLabeled("subspace-prefix(late)", graph,
×
425
                subSpacePrefixFallbackUpdate(graph));
×
426
        c.attachment = runTierLabeled("attachment(late)", graph,
×
427
                attachmentValidationUpdate(graph, -1));
×
428
        c.maintainer = runTierLabeled("maintainer(late)", graph,
×
429
                nonAdminTierUpdate(graph, -1, GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
×
430
        c.member = runTierLabeled("member(admin-pub,late)", graph,
×
431
                nonAdminTierUpdate(graph, -1, GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
×
432
        c.member += runTierLabeled("member(maint-pub,late)", graph,
×
433
                nonAdminTierUpdate(graph, -1,
×
434
                        GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
435
        c.observer = runTierLabeled("observer(admin-pub,late)", graph,
×
436
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
×
437
        c.observer += runTierLabeled("observer(maint-pub,late)", graph,
×
438
                nonAdminTierUpdate(graph, -1,
×
439
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
440
        c.observer += runTierLabeled("observer(member-pub,late)", graph,
×
441
                nonAdminTierUpdate(graph, -1,
×
442
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
×
443
        c.observer += runTierLabeled("observer(self,late)", graph,
×
444
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
×
445
        return c;
×
446
    }
447

448
    /**
449
     * Cheap ASK: did any new {@code npa:RoleDeclaration} extraction land in the
450
     * load-number delta {@code (lastProcessed, ∞)}? Used by the late-arrival
451
     * trigger so an RD that arrives in the same cycle as a matching candidate
452
     * still gets validated.
453
     */
454
    boolean newRoleDeclarationsArrived(long lastProcessed) {
455
        String ask = String.format("""
×
456
                PREFIX npa: <%1$s>
457
                ASK {
458
                  GRAPH <%2$s> {
459
                    ?rd a npa:RoleDeclaration ;
460
                        npa:viaNanopub ?np .
461
                  }
462
                  GRAPH <%3$s> {
463
                    ?np npa:hasLoadNumber ?ln .
464
                    FILTER (?ln > %4$d)
465
                  }
466
                }
467
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
×
468
        return runAsk(ask);
×
469
    }
470

471
    // ---------------- Tier UPDATE loops ----------------
472

473
    /**
474
     * Per-tier inserted-triple tallies for one build or cycle. Counts the sum
475
     * of {@code (graphSize_after - graphSize_before)} across all iterations of
476
     * each tier's fixed-point INSERT loop — i.e. inserted *triples*, not
477
     * distinct subjects (a single RoleInstantiation insert writes 4–5 triples).
478
     *
479
     * <p>Used internally by the {@link #runIncrementalCycle structuralAdds}
480
     * boolean check (we only care whether any tier inserted at all).
481
     * Not what the log lines report: see {@link TierSubjectTotals} +
482
     * {@link #computeTierSubjectTotals} for the distinct-subject totals
483
     * surfaced to operators.
484
     */
485
    static final class TierInsertedTriples {
×
486
        int admin;
487
        int alias;
488
        int attachment;
489
        int maintainer;
490
        int member;
491
        int observer;
492
        int subSpace;
493
        int subSpacePrefix;
494
        int maintainedResource;
495
    }
496

497
    /**
498
     * Snapshot of distinct-subject totals in a space-state graph at a moment
499
     * in time. Independent of which tier-loop added each subject.
500
     */
501
    record TierSubjectTotals(long adminRIs, long attachmentRAs, long nonAdminRIs) {}
36✔
502

503
    /**
504
     * Runs the five tier loops in order: admin → {@code gen:hasRole} attachment
505
     * validation → maintainer → member → observer. Each loop iterates a SPARQL
506
     * INSERT to fixed point (no new triples added). Returns per-tier counts.
507
     *
508
     * @param graph         target space-state graph
509
     * @param lastProcessed load-number horizon; use {@code -1} for full build
510
     */
511
    TierInsertedTriples runAllTierLoops(IRI graph, long lastProcessed) {
512
        TierInsertedTriples c = new TierInsertedTriples();
×
513
        c.admin = runTierLabeled("admin", graph, adminTierUpdate(graph, lastProcessed));
×
514
        // Alias admit runs after the admin closure has settled (both the authority
515
        // gate and the anti-hijack check read the admin set) and before attachment /
516
        // role tiers (their alias-aware admin lookups consume the npa:sameAsSpace edge
517
        // this pass emits). See issue #113.
518
        c.alias = runTierLabeled("alias", graph, aliasAdmitUpdate(graph, lastProcessed));
×
519
        // Sub-space admit runs after admin closure has settled (Mode A + Mode B both
520
        // need the admin set). Independent of role tiers — order between subspace
521
        // and attachment / maintainer / member / observer doesn't matter.
522
        c.subSpace = runTierLabeled("subspace", graph, subSpaceAdmitUpdate(graph, lastProcessed));
×
523
        // Maintained-resource admit also depends only on the admin closure. Single
524
        // Mode A: publisher must be admin of the maintaining space. No co-declaration
525
        // partner, no URL-prefix fallback.
526
        c.maintainedResource = runTierLabeled("maintained-resource", graph,
×
527
                maintainedResourceAdmitUpdate(graph, lastProcessed));
×
528
        // URL-prefix sub-space fallback runs after the explicit-declaration admit
529
        // pass commits so the per-child suppression check sees this cycle's fresh
530
        // validations. No load filter — depends on which Spaces exist, not on
531
        // delta-arrivals; the dedup FILTER NOT EXISTS prevents re-insertion.
532
        c.subSpacePrefix = runTierLabeled("subspace-prefix", graph,
×
533
                subSpacePrefixFallbackUpdate(graph));
×
534
        c.attachment = runTierLabeled("attachment", graph,
×
535
                attachmentValidationUpdate(graph, lastProcessed));
×
536
        c.maintainer = runTierLabeled("maintainer", graph, nonAdminTierUpdate(graph, lastProcessed,
×
537
                GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
538
        // Member tier: admin OR maintainer publisher — split into two simpler updates
539
        // so the query planner doesn't struggle with the UNION.
540
        c.member = runTierLabeled("member(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
541
                GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
542
        c.member += runTierLabeled("member(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
543
                GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
544
        // Observer tier: self-evidence OR a downward grant from any higher tier.
545
        // ObserverRole is the default tier when a role definition omits an
546
        // explicit subclass (see "Role types" in design-space-repositories.md), so
547
        // most "X assigned Y this role" nanopubs land here. Restricting the tier
548
        // to PUBLISHER_IS_SELF would silently drop those grants. The four
549
        // sub-loops mirror the trust-state's downward-only chain: admin grants
550
        // anything; maintainers and members grant observer; everyone may
551
        // self-attest.
552
        c.observer = runTierLabeled("observer(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
553
                GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
554
        c.observer += runTierLabeled("observer(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
555
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
556
        c.observer += runTierLabeled("observer(member-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
557
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
×
558
        c.observer += runTierLabeled("observer(self)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
559
                GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
560
        return c;
×
561
    }
562

563
    /**
564
     * Builds a publisher constraint requiring the publisher to be a validated holder
565
     * of the given tier's role (maintainer or member) in the target space.
566
     * Owns its own AccountState resolution so ?publisher is bound through the
567
     * targeted (pkh → agent) lookup rather than enumerated.
568
     */
569
    private static String publisherIsTieredRole(IRI tierClass) {
570
        return """
×
571
                ?acct a npa:AccountState ;
572
                      npa:pubkey ?pkh ;
573
                      npa:agent  ?publisher .
574
                # Tier-role holder in ?space directly, or in a canonical space that
575
                # ?space is an owl:sameAs alias of (issue #113).
576
                {
577
                  ?tierRI a gen:RoleInstantiation ;
578
                          npa:forSpace ?space ;
579
                          npa:forAgent ?publisher .
580
                }
581
                UNION
582
                {
583
                  ?space npa:sameAsSpace ?canon .
584
                  ?tierRI a gen:RoleInstantiation ;
585
                          npa:forSpace ?canon ;
586
                          npa:forAgent ?publisher .
587
                }
588
                ?rdT a npa:RoleDeclaration ;
589
                     npa:hasRoleType <%1$s> .
590
                { ?tierRI npa:regularProperty ?predT . ?rdT gen:hasRegularProperty ?predT . }
591
                UNION
592
                { ?tierRI npa:inverseProperty ?predT . ?rdT gen:hasInverseProperty ?predT . }
593
                """.formatted(tierClass);
×
594
    }
595

596
    /** Wraps {@link #runTierLoop} with tier-name context for logs/exceptions. */
597
    private int runTierLabeled(String tier, IRI graph, String sparqlUpdate) {
598
        try {
599
            return runTierLoop(graph, sparqlUpdate);
×
600
        } catch (RuntimeException ex) {
×
601
            log.error("AuthorityResolver: tier={} failed with SPARQL UPDATE:\n{}\n", tier, sparqlUpdate, ex);
×
602
            throw ex;
×
603
        }
604
    }
605

606
    /**
607
     * Runs a single tier's INSERT to fixed point. Counts rows by probing
608
     * graph size before/after each INSERT; stops when the size doesn't change.
609
     *
610
     * @return total number of triples inserted by this tier across all iterations
611
     */
612
    int runTierLoop(IRI graph, String sparqlUpdate) {
613
        int total = 0;
×
614
        long before = graphSize(graph);
×
615
        while (true) {
616
            // Note: no explicit transaction wrapping here. In tests we observed that
617
            // HTTPRepository's RDF4J-transaction protocol silently no-op'd cross-graph
618
            // SPARQL UPDATEs with UNION sub-patterns inside conn.begin()/commit(),
619
            // while the same UPDATE POSTed directly to /statements applied correctly.
620
            // A bare prepareUpdate().execute() takes the direct /statements path and
621
            // runs the UPDATE atomically per SPARQL 1.1 semantics — which is all we
622
            // need; there's nothing else to commit atomically alongside the UPDATE.
623
            try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
624
                conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
625
            }
626
            long after = graphSize(graph);
×
627
            long added = after - before;
×
628
            if (added <= 0) break;
×
629
            total += added;
×
630
            before = after;
×
631
        }
×
632
        return total;
×
633
    }
634

635
    private long graphSize(IRI graph) {
636
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
637
            return conn.size(graph);
×
638
        }
639
    }
640

641
    /**
642
     * Distinct-subject totals in the given space-state graph, broken down by
643
     * RoleInstantiation kind (admin-pinned vs not) and RoleAssignment.
644
     * Three SELECT-COUNT queries — cheap, called once per build/cycle for
645
     * the user-facing log line. Returns zeros on failure (logged) so a flaky
646
     * count read can't wedge the cycle.
647
     */
648
    TierSubjectTotals computeTierSubjectTotals(IRI graph) {
649
        long adminRIs       = countDistinctSubjects(graph, """
×
650
                ?ri a gen:RoleInstantiation ; npa:inverseProperty gen:hasAdmin .
651
                """, "ri");
652
        long attachmentRAs  = countDistinctSubjects(graph, """
×
653
                ?ra a gen:RoleAssignment .
654
                """, "ra");
655
        long nonAdminRIs    = countDistinctSubjects(graph, """
×
656
                ?ri a gen:RoleInstantiation .
657
                FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
658
                """, "ri");
659
        return new TierSubjectTotals(adminRIs, attachmentRAs, nonAdminRIs);
×
660
    }
661

662
    private long countDistinctSubjects(IRI graph, String wherePattern, String varName) {
663
        String query = String.format("""
×
664
                PREFIX npa: <%1$s>
665
                PREFIX gen: <%2$s>
666
                SELECT (COUNT(DISTINCT ?%3$s) AS ?n) WHERE {
667
                  GRAPH <%4$s> {
668
                    %5$s
669
                  }
670
                }
671
                """, NPA.NAMESPACE, GEN.NAMESPACE, varName, graph, wherePattern);
672
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
×
673
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
674
            if (!r.hasNext()) return 0;
×
675
            return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
×
676
        } catch (Exception ex) {
×
677
            log.warn("AuthorityResolver: countDistinctSubjects on {} failed: {}",
×
678
                    graph, ex.toString());
×
679
            return 0;
×
680
        }
681
    }
682

683
    // ---------------- SPARQL templates ----------------
684

685
    /**
686
     * Reusable invalidation filter on a bound nanopub-IRI variable. Pass the bare
687
     * variable name (no leading {@code ?}); e.g. {@code invalidationFilter("np")}
688
     * produces an outer-scoped {@code FILTER NOT EXISTS { GRAPH npa:graph
689
     * { ?_inv_np npx:invalidates ?np . } }}.
690
     *
691
     * <p>Joins on the raw {@code npx:invalidates} triple in {@code npa:graph},
692
     * which {@link com.knowledgepixels.query.NanopubLoader} writes into the
693
     * spaces repo from two complementary directions, making the filter symmetric
694
     * in load order:
695
     * <ul>
696
     *   <li>At the invalidator's own load: the loader's space-repo trigger fires
697
     *       whenever the nanopub has either its own space-relevant extractions
698
     *       OR an {@code npx:invalidates}/{@code npx:retracts}/{@code npx:supersedes}
699
     *       triple, so a pure-retraction nanopub still lands its raw triple plus
700
     *       {@code npa:hasLoadNumber} stamp in {@code npa:graph}.</li>
701
     *   <li>At the invalidated target's load (when the invalidator landed
702
     *       earlier): {@code NanopubLoader.getInvalidatingStatements} reads the
703
     *       triple back from the meta repo and mirrors it into the target's own
704
     *       write to the spaces repo.</li>
705
     * </ul>
706
     *
707
     * <p>The earlier shape joined on a structured {@code npa:Invalidation} entry
708
     * in {@code npa:spacesGraph} that was only emitted on the invalidator's side
709
     * AND only when the invalidated target's meta had already loaded, leaving a
710
     * window where a superseding nanopub loaded before its target produced no
711
     * entry and the stale row was never filtered out (see also the matching
712
     * change in the tier-specific {@code *InvalidationCheckWhere}/{@code
713
     * *InvalidationDelete} templates below).
714
     *
715
     * <p>Important: this filter must be placed OUTSIDE the surrounding
716
     * {@code GRAPH npa:spacesGraph { ... }} block, not nested inside it. When
717
     * nested, RDF4J's planner couples the FILTER NOT EXISTS evaluation into the
718
     * join order (per-row scan multiplied by the candidate set), which we
719
     * measured turning a 39ms query into a 60s+ timeout on the live observer-tier
720
     * data. Outside the GRAPH block, the planner defers the filter until
721
     * {@code ?np}/{@code ?rdNp} are bound and does a targeted index lookup.
722
     *
723
     * <p>Variable names must match {@code [A-Za-z0-9_]+} per SPARQL grammar —
724
     * embedding a {@code ?} inside {@code ?_inv_?np} would yield a parse error.
725
     */
726
    private static String invalidationFilter(String bareVarName) {
727
        return "FILTER NOT EXISTS { GRAPH <" + NPA.GRAPH + "> {"
24✔
728
                + " ?_inv_" + bareVarName
729
                + " <" + NPX.INVALIDATES + "> ?" + bareVarName + " . } }";
730
    }
731

732
    /**
733
     * Admin tier: seed from {@code npadef:...hasRootAdmin} (trusted by construction)
734
     * plus closed-over admin grants; insert any {@code gen:RoleInstantiation} with
735
     * {@code npa:inverseProperty gen:hasAdmin} whose publisher (resolved via mirrored
736
     * trust-approved AccountState) is already in the admin set.
737
     *
738
     * <p>The seed is gated by {@link #spaceRefAliveFilter} (not the per-nanopub
739
     * {@code invalidationFilter("defNp")}): the {@code hasRootAdmin} seed is anchored
740
     * to the root NPID, which is the immutable space-ref identity, so superseding the
741
     * root <em>nanopub</em> with a continuation revision must not strip the seed —
742
     * only retracting every definition of the ref removes it. See issue #110.
743
     */
744
    static String adminTierUpdate(IRI graph, long lastProcessed) {
745
        // Order tuned for RDF4J's evaluator:
746
        //   1. Anchor on the small (seed UNION closed-over) set to bind ?publisher
747
        //      and ?space cheaply.
748
        //   2. Resolve ?pkh from the mirrored AccountState row (?publisher bound).
749
        //   3. Probe instantiations using the now-bound (?space, ?pkh) — targeted
750
        //      lookup, not a full RoleInstantiation scan.
751
        //   4. Load-number filter on bound ?np.
752
        //   5. Dedup at the end.
753
        return """
69✔
754
                PREFIX npa:  <%1$s>
755
                PREFIX gen:  <%2$s>
756
                INSERT { GRAPH <%3$s> {
757
                  ?ri a gen:RoleInstantiation ;
758
                      npa:forSpace ?space ;
759
                      npa:inverseProperty gen:hasAdmin ;
760
                      npa:forAgent ?agent ;
761
                      npa:viaNanopub ?np .
762
                } }
763
                WHERE {
764
                  # 1. Anchor: who is already an admin of which space?
765
                  {
766
                    # Seed branch: root-admin of a space ref that is still alive
767
                    # (has at least one non-invalidated definition). NOT filtered on
768
                    # ?def's own invalidation — superseding the root nanopub with a
769
                    # continuation revision must keep the seed; only a fully-retracted
770
                    # ref drops it (issue #110).
771
                    GRAPH <%4$s> {
772
                      ?def a npa:SpaceDefinition ;
773
                           npa:forSpaceRef  ?spaceRef ;
774
                           npa:hasRootAdmin ?publisher .
775
                      ?spaceRef npa:spaceIri ?space .
776
                    }
777
                    %7$s
778
                  }
779
                  UNION
780
                  {
781
                    # Closed-over branch: an existing admin in this space-state graph.
782
                    GRAPH <%3$s> {
783
                      ?prev a gen:RoleInstantiation ;
784
                            npa:forSpace        ?space ;
785
                            npa:inverseProperty gen:hasAdmin ;
786
                            npa:forAgent        ?publisher .
787
                    }
788
                  }
789
                  # 2. Mirror: resolve ?publisher → ?pkh via the trust-approved row.
790
                  GRAPH <%3$s> {
791
                    ?acct a npa:AccountState ;
792
                          npa:agent  ?publisher ;
793
                          npa:pubkey ?pkh .
794
                  }
795
                  # 3. Targeted instantiation lookup by space + pubkey.
796
                  GRAPH <%4$s> {
797
                    ?ri a gen:RoleInstantiation ;
798
                        npa:forSpace        ?space ;
799
                        npa:inverseProperty gen:hasAdmin ;
800
                        npa:forAgent        ?agent ;
801
                        npa:pubkeyHash      ?pkh ;
802
                        npa:viaNanopub      ?np .
803
                  }
804
                  %6$s
805
                  # 4. Load-number filter on bound ?np.
806
                  GRAPH <%8$s> {
807
                    ?np npa:hasLoadNumber ?ln .
808
                    FILTER (?ln > %5$d)
809
                  }
810
                  # 5. Dedup last.
811
                  FILTER NOT EXISTS { GRAPH <%3$s> {
812
                    ?existing a gen:RoleInstantiation ;
813
                              npa:forSpace ?space ;
814
                              npa:forAgent ?agent ;
815
                              npa:inverseProperty gen:hasAdmin .
816
                  } }
817
                }
818
                """.formatted(
3✔
819
                NPA.NAMESPACE,
820
                GEN.NAMESPACE,
821
                graph,
822
                SpacesVocab.SPACES_GRAPH,
823
                lastProcessed,
15✔
824
                invalidationFilter("np"),
12✔
825
                spaceRefAliveFilter(),
18✔
826
                NPA.GRAPH);
827
    }
828

829
    /**
830
     * Seed-survival filter for the admin tier (issue #110). The {@code hasRootAdmin}
831
     * seed is anchored to the root NPID, which is the immutable space-ref identity, so
832
     * it must survive supersession of the root <em>nanopub</em> by a continuation
833
     * revision (a later definition re-roots to the same ref via
834
     * {@code gen:hasRootDefinition} and so carries no {@code hasRootAdmin} of its own).
835
     * The previous {@code invalidationFilter("defNp")} dropped the seed the moment the
836
     * root revision was superseded, leaving the whole admin closure — and everything
837
     * cascading from it — unmaterialized for any space whose definition had ever been
838
     * updated.
839
     *
840
     * <p>Expressed positively: the seed survives iff the space ref still has at least
841
     * one non-invalidated {@link SpacesVocab#SPACE_DEFINITION}. A fully-retracted ref
842
     * (every definition invalidated) has no live definition, so the {@code FILTER
843
     * EXISTS} fails and the seed correctly disappears. Anchored on the already-bound
844
     * {@code ?spaceRef}, so it's a targeted lookup over that ref's (few) definitions.
845
     */
846
    private static String spaceRefAliveFilter() {
847
        return """
33✔
848
                FILTER EXISTS {
849
                  GRAPH <%1$s> {
850
                    ?liveDef a npa:SpaceDefinition ;
851
                             npa:forSpaceRef ?spaceRef ;
852
                             npa:viaNanopub  ?liveNp .
853
                  }
854
                  %2$s
855
                }
856
                """.formatted(SpacesVocab.SPACES_GRAPH, invalidationFilter("liveNp"));
9✔
857
    }
858

859
    /**
860
     * {@code gen:hasRole} attachment validation: an attachment is validated iff its
861
     * publisher is already a validated admin of the target space. Adds
862
     * {@code gen:RoleAssignment} rows to the space-state graph.
863
     */
864
    static String attachmentValidationUpdate(IRI graph, long lastProcessed) {
865
        return """
69✔
866
                PREFIX npa:  <%1$s>
867
                PREFIX gen:  <%2$s>
868
                INSERT { GRAPH <%3$s> {
869
                  ?ra a gen:RoleAssignment ;
870
                      npa:forSpace ?space ;
871
                      gen:hasRole  ?role ;
872
                      npa:viaNanopub ?np .
873
                } }
874
                WHERE {
875
                  GRAPH <%4$s> {
876
                    ?ra a gen:RoleAssignment ;
877
                        npa:forSpace ?space ;
878
                        gen:hasRole  ?role ;
879
                        npa:pubkeyHash ?pkh ;
880
                        npa:viaNanopub ?np .
881
                  }
882
                  GRAPH <%7$s> {
883
                    ?np npa:hasLoadNumber ?ln .
884
                    FILTER (?ln > %5$d)
885
                  }
886
                  GRAPH <%3$s> {
887
                    ?acct a npa:AccountState ;
888
                          npa:agent  ?publisher ;
889
                          npa:pubkey ?pkh .
890
                    # Admin of ?space directly, or admin of a canonical space that
891
                    # ?space is an owl:sameAs alias of (issue #113).
892
                    {
893
                      ?adminRI a gen:RoleInstantiation ;
894
                               npa:forSpace ?space ;
895
                               npa:inverseProperty gen:hasAdmin ;
896
                               npa:forAgent ?publisher .
897
                    }
898
                    UNION
899
                    {
900
                      ?space npa:sameAsSpace ?canon .
901
                      ?adminRI a gen:RoleInstantiation ;
902
                               npa:forSpace ?canon ;
903
                               npa:inverseProperty gen:hasAdmin ;
904
                               npa:forAgent ?publisher .
905
                    }
906
                  }
907
                  %6$s
908
                  FILTER NOT EXISTS { GRAPH <%3$s> {
909
                    ?existing a gen:RoleAssignment ;
910
                              npa:forSpace ?space ;
911
                              gen:hasRole  ?role .
912
                  } }
913
                }
914
                """.formatted(
3✔
915
                NPA.NAMESPACE,
916
                GEN.NAMESPACE,
917
                graph,
918
                SpacesVocab.SPACES_GRAPH,
919
                lastProcessed,
15✔
920
                invalidationFilter("np"),
18✔
921
                NPA.GRAPH);
922
    }
923

924
    /**
925
     * Non-admin tier publisher constraints (inserted as a SPARQL sub-pattern).
926
     * Each constraint owns the AccountState (pkh → agent) lookup so the join
927
     * variable is bound through a targeted pattern. The observer-self variant
928
     * binds {@code npa:agent ?agent} directly — no separate {@code ?publisher}
929
     * variable, no post-join equality filter — which lets the planner anchor
930
     * the AccountState lookup on the already-bound {@code ?agent} instead of
931
     * enumerating all approved publishers and filtering at the end.
932
     */
933
    static final String PUBLISHER_IS_ADMIN = """
934
            ?acct a npa:AccountState ;
935
                  npa:pubkey ?pkh ;
936
                  npa:agent  ?publisher .
937
            # Admin of ?space directly, or admin of a canonical space that ?space is
938
            # an owl:sameAs alias of (issue #113).
939
            {
940
              ?adminRI a gen:RoleInstantiation ;
941
                       npa:forSpace ?space ;
942
                       npa:inverseProperty gen:hasAdmin ;
943
                       npa:forAgent ?publisher .
944
            }
945
            UNION
946
            {
947
              ?space npa:sameAsSpace ?canon .
948
              ?adminRI a gen:RoleInstantiation ;
949
                       npa:forSpace ?canon ;
950
                       npa:inverseProperty gen:hasAdmin ;
951
                       npa:forAgent ?publisher .
952
            }
953
            """;
954

955
    /** Observer self-evidence: the assignee's own pubkey signed the instantiation. */
956
    static final String PUBLISHER_IS_SELF = """
957
            ?acct a npa:AccountState ;
958
                  npa:pubkey ?pkh ;
959
                  npa:agent  ?agent .
960
            """;
961

962
    /**
963
     * Maintainer / Member / Observer tier INSERT. Same shape: find an instantiation
964
     * whose predicate matches a RoleDeclaration of the given tier attached to the
965
     * target space, and whose publisher passes the tier-specific constraint.
966
     */
967
    static String nonAdminTierUpdate(IRI graph, long lastProcessed,
968
                                     IRI tierClass, String publisherConstraint) {
969
        // Order tuned for RDF4J's evaluator (which executes BGPs roughly in order).
970
        // The crucial choice is the *anchor*: instantiation-first plans send the
971
        // planner exploring the full ~thousands of candidate RIs and only filter
972
        // by tier at the very end. Attachment-first anchors on the small set of
973
        // gen:RoleAssignment rows already validated in this space-state graph
974
        // (~hundreds, often zero) and walks outward by bound (?role, ?space).
975
        //
976
        //   1. Anchor on RoleAssignments in this space-state graph (small).
977
        //   2. Match the tier-pinned RoleDeclaration by ?role.
978
        //   3. Pair role-decl direction to instantiation direction in one UNION
979
        //      so only (reg, reg)/(inv, inv) combos are explored.
980
        //   4. Targeted instantiation lookup — (?space, ?pred) are bound.
981
        //   5. Publisher constraint (incl. AccountState resolution).
982
        //   6. Load-number filter on bound ?np.
983
        //   7. Dedup at the end.
984
        return """
69✔
985
                PREFIX npa:  <%1$s>
986
                PREFIX gen:  <%2$s>
987
                INSERT { GRAPH <%3$s> {
988
                  ?ri a gen:RoleInstantiation ;
989
                      npa:forSpace ?space ;
990
                      npa:forAgent ?agent ;
991
                      npa:viaNanopub ?np .
992
                } }
993
                WHERE {
994
                  # 1. Anchor: validated attachments in this space-state graph.
995
                  GRAPH <%3$s> {
996
                    ?ra a gen:RoleAssignment ;
997
                        gen:hasRole  ?role ;
998
                        npa:forSpace ?space .
999
                  }
1000
                  # 2. Tier-pinned RoleDeclaration (?role bound from the attachment).
1001
                  GRAPH <%4$s> {
1002
                    ?rd a npa:RoleDeclaration ;
1003
                        npa:hasRoleType <%7$s> ;
1004
                        npa:role        ?role ;
1005
                        npa:viaNanopub  ?rdNp .
1006
                    # 3. Pair direction so only matching combos are explored.
1007
                    {
1008
                      ?rd gen:hasRegularProperty ?pred .
1009
                      ?ri npa:regularProperty    ?pred .
1010
                    }
1011
                    UNION
1012
                    {
1013
                      ?rd gen:hasInverseProperty ?pred .
1014
                      ?ri npa:inverseProperty    ?pred .
1015
                    }
1016
                    # 4. Targeted instantiation lookup — (?space, ?pred) bound.
1017
                    ?ri a gen:RoleInstantiation ;
1018
                        npa:forSpace   ?space ;
1019
                        npa:forAgent   ?agent ;
1020
                        npa:pubkeyHash ?pkh ;
1021
                        npa:viaNanopub ?np .
1022
                  }
1023
                  # 5. Publisher constraint (incl. AccountState resolution).
1024
                  GRAPH <%3$s> {
1025
                    %9$s
1026
                  }
1027
                  # 6. Load-number filter on bound ?np.
1028
                  GRAPH <%10$s> {
1029
                    ?np npa:hasLoadNumber ?ln .
1030
                    FILTER (?ln > %5$d)
1031
                  }
1032
                  # 7. Invalidation filters — outside the GRAPH block so the
1033
                  #    planner defers them until ?rdNp/?np are bound.
1034
                  %8$s
1035
                  %6$s
1036
                  # 8. Dedup last.
1037
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1038
                    ?existing a gen:RoleInstantiation ;
1039
                              npa:forSpace ?space ;
1040
                              npa:forAgent ?agent ;
1041
                              npa:viaNanopub ?np .
1042
                  } }
1043
                }
1044
                """.formatted(
3✔
1045
                NPA.NAMESPACE,
1046
                GEN.NAMESPACE,
1047
                graph,
1048
                SpacesVocab.SPACES_GRAPH,
1049
                lastProcessed,
15✔
1050
                invalidationFilter("np"),
27✔
1051
                tierClass,
1052
                invalidationFilter("rdNp"),
30✔
1053
                publisherConstraint,
1054
                NPA.GRAPH);
1055
    }
1056

1057
    /**
1058
     * Sub-space admit pass. Copies validated {@code npa:SubSpaceDeclaration}
1059
     * extraction rows into the space-state graph (preserving the {@code npasub:}
1060
     * subject) and emits convenience {@code <child> npa:isSubSpaceOf <parent>} and
1061
     * {@code <parent> npa:hasSubSpace <child>} direct triples. Two satisfaction
1062
     * modes joined by UNION:
1063
     * <ul>
1064
     *   <li>Mode A — the declaration's publisher is a validated admin of both the
1065
     *       child and the parent space.</li>
1066
     *   <li>Mode B — a different non-invalidated declaration for the same
1067
     *       {@code (child, parent)} pair exists, and the two publishers between
1068
     *       them cover both admin sides (i.e. one of them is admin of the child,
1069
     *       one of them is admin of the parent — possibly the same one twice if
1070
     *       both happen to be admin of both).</li>
1071
     * </ul>
1072
     *
1073
     * <p>Mode-B late-arrival: when only the partner declaration is new in this
1074
     * cycle (the primary is older than {@code lastProcessed}), the load-number
1075
     * filter on {@code ?np} excludes the candidate. The late-arrival sweep
1076
     * ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass without the
1077
     * load filter and catches it.
1078
     */
1079
    static String subSpaceAdmitUpdate(IRI graph, long lastProcessed) {
1080
        return """
69✔
1081
                PREFIX npa: <%1$s>
1082
                PREFIX gen: <%2$s>
1083
                INSERT { GRAPH <%3$s> {
1084
                  ?d a npa:SubSpaceDeclaration ;
1085
                     npa:childSpace  ?child ;
1086
                     npa:parentSpace ?parent ;
1087
                     npa:viaNanopub  ?np .
1088
                  ?child  npa:isSubSpaceOf ?parent .
1089
                  ?parent npa:hasSubSpace  ?child  .
1090
                } }
1091
                WHERE {
1092
                  # 1. Anchor: candidate declarations from the extraction graph.
1093
                  GRAPH <%4$s> {
1094
                    ?d a npa:SubSpaceDeclaration ;
1095
                       npa:childSpace  ?child ;
1096
                       npa:parentSpace ?parent ;
1097
                       npa:pubkeyHash  ?pkh ;
1098
                       npa:viaNanopub  ?np .
1099
                  }
1100
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
1101
                  GRAPH <%3$s> {
1102
                    ?acct a npa:AccountState ;
1103
                          npa:pubkey ?pkh ;
1104
                          npa:agent  ?publisher .
1105
                  }
1106
                  # 3. Authority gate.
1107
                  {
1108
                    # Mode A — publisher is admin of BOTH child and parent.
1109
                    FILTER EXISTS { GRAPH <%3$s> {
1110
                      ?riC a gen:RoleInstantiation ;
1111
                           npa:inverseProperty gen:hasAdmin ;
1112
                           npa:forSpace ?child ;
1113
                           npa:forAgent ?publisher .
1114
                    } }
1115
                    FILTER EXISTS { GRAPH <%3$s> {
1116
                      ?riP a gen:RoleInstantiation ;
1117
                           npa:inverseProperty gen:hasAdmin ;
1118
                           npa:forSpace ?parent ;
1119
                           npa:forAgent ?publisher .
1120
                    } }
1121
                  }
1122
                  UNION
1123
                  {
1124
                    # Mode B — co-declaration whose publisher covers the side this
1125
                    # one's publisher doesn't. Between {publisher, publisher2},
1126
                    # both admin sides must be covered.
1127
                    GRAPH <%4$s> {
1128
                      ?d2 a npa:SubSpaceDeclaration ;
1129
                          npa:childSpace  ?child ;
1130
                          npa:parentSpace ?parent ;
1131
                          npa:pubkeyHash  ?pkh2 ;
1132
                          npa:viaNanopub  ?np2 .
1133
                      FILTER (?np2 != ?np)
1134
                    }
1135
                    %8$s
1136
                    GRAPH <%3$s> {
1137
                      ?acct2 a npa:AccountState ;
1138
                             npa:pubkey ?pkh2 ;
1139
                             npa:agent  ?publisher2 .
1140
                    }
1141
                    FILTER EXISTS { GRAPH <%3$s> {
1142
                      ?riA a gen:RoleInstantiation ;
1143
                           npa:inverseProperty gen:hasAdmin ;
1144
                           npa:forSpace ?child .
1145
                      { ?riA npa:forAgent ?publisher } UNION { ?riA npa:forAgent ?publisher2 }
1146
                    } }
1147
                    FILTER EXISTS { GRAPH <%3$s> {
1148
                      ?riB a gen:RoleInstantiation ;
1149
                           npa:inverseProperty gen:hasAdmin ;
1150
                           npa:forSpace ?parent .
1151
                      { ?riB npa:forAgent ?publisher } UNION { ?riB npa:forAgent ?publisher2 }
1152
                    } }
1153
                  }
1154
                  # 4. Invalidation filter on the primary declaration's nanopub.
1155
                  %6$s
1156
                  # 5. Load-number filter on bound ?np.
1157
                  GRAPH <%7$s> {
1158
                    ?np npa:hasLoadNumber ?ln .
1159
                    FILTER (?ln > %5$d)
1160
                  }
1161
                  # 6. Dedup last.
1162
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1163
                    ?d a npa:SubSpaceDeclaration .
1164
                  } }
1165
                }
1166
                """.formatted(
3✔
1167
                NPA.NAMESPACE,
1168
                GEN.NAMESPACE,
1169
                graph,
1170
                SpacesVocab.SPACES_GRAPH,
1171
                lastProcessed,
15✔
1172
                invalidationFilter("np"),
27✔
1173
                NPA.GRAPH,
1174
                invalidationFilter("np2"));
6✔
1175
    }
1176

1177
    /**
1178
     * Maintained-resource admit pass. Copies validated
1179
     * {@code npa:MaintainedResourceDeclaration} extraction rows into the space-state
1180
     * graph (preserving the {@code npamrd:} subject) and emits convenience
1181
     * {@code <r> npa:isMaintainedBy <s>} and {@code <s> npa:hasMaintainedResource <r>}
1182
     * direct triples. Single satisfaction mode:
1183
     * <ul>
1184
     *   <li>Mode A — the declaration's publisher is a validated admin of the
1185
     *       maintaining space.</li>
1186
     * </ul>
1187
     *
1188
     * <p>No Mode B because only one space is involved; the two-sides-must-be-covered
1189
     * concern that drives sub-space Mode B doesn't apply. Late-arrival is still
1190
     * possible (declaration lands before the publisher's admin grant becomes valid):
1191
     * the load-number filter on {@code ?np} excludes the candidate, and the
1192
     * late-arrival sweep ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass
1193
     * without the load filter and catches it.
1194
     */
1195
    static String maintainedResourceAdmitUpdate(IRI graph, long lastProcessed) {
1196
        return """
69✔
1197
                PREFIX npa: <%1$s>
1198
                PREFIX gen: <%2$s>
1199
                INSERT { GRAPH <%3$s> {
1200
                  ?d a npa:MaintainedResourceDeclaration ;
1201
                     npa:resourceIri     ?r ;
1202
                     npa:maintainerSpace ?s ;
1203
                     npa:viaNanopub      ?np .
1204
                  ?r npa:isMaintainedBy        ?s .
1205
                  ?s npa:hasMaintainedResource ?r .
1206
                } }
1207
                WHERE {
1208
                  # 1. Anchor: candidate declarations from the extraction graph.
1209
                  GRAPH <%4$s> {
1210
                    ?d a npa:MaintainedResourceDeclaration ;
1211
                       npa:resourceIri     ?r ;
1212
                       npa:maintainerSpace ?s ;
1213
                       npa:pubkeyHash      ?pkh ;
1214
                       npa:viaNanopub      ?np .
1215
                  }
1216
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
1217
                  GRAPH <%3$s> {
1218
                    ?acct a npa:AccountState ;
1219
                          npa:pubkey ?pkh ;
1220
                          npa:agent  ?publisher .
1221
                    # 3. Authority gate (Mode A only): publisher is admin of the
1222
                    #    maintaining space.
1223
                    ?riA a gen:RoleInstantiation ;
1224
                         npa:inverseProperty gen:hasAdmin ;
1225
                         npa:forSpace ?s ;
1226
                         npa:forAgent ?publisher .
1227
                  }
1228
                  # 4. Invalidation filter on the declaration's nanopub.
1229
                  %6$s
1230
                  # 5. Load-number filter on bound ?np.
1231
                  GRAPH <%7$s> {
1232
                    ?np npa:hasLoadNumber ?ln .
1233
                    FILTER (?ln > %5$d)
1234
                  }
1235
                  # 6. Dedup last.
1236
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1237
                    ?d a npa:MaintainedResourceDeclaration .
1238
                  } }
1239
                }
1240
                """.formatted(
3✔
1241
                NPA.NAMESPACE,
1242
                GEN.NAMESPACE,
1243
                graph,
1244
                SpacesVocab.SPACES_GRAPH,
1245
                lastProcessed,
15✔
1246
                invalidationFilter("np"),
18✔
1247
                NPA.GRAPH);
1248
    }
1249

1250
    /**
1251
     * Space-alias admit pass (issue #113). Copies validated
1252
     * {@code npa:SpaceAliasDeclaration} extraction rows into the space-state graph
1253
     * (preserving the {@code npaalias:} subject) and emits the directional
1254
     * {@code <alias> npa:sameAsSpace <canonical>} edge consumed by the alias-aware
1255
     * admin-authority lookups in {@link #attachmentValidationUpdate},
1256
     * {@link #PUBLISHER_IS_ADMIN}, and {@link #publisherIsTieredRole}.
1257
     *
1258
     * <p>Two gates, both read against the (already-settled) admin closure in the
1259
     * space-state graph:
1260
     * <ul>
1261
     *   <li><b>Authority</b> — the declaration's publisher (resolved via the mirrored
1262
     *       trust-approved {@code AccountState}) is a validated admin of the
1263
     *       <em>canonical</em> space. The alias is declared inside the canonical
1264
     *       space's own {@code gen:Space} nanopub, so this is the same evidence rule
1265
     *       as a {@code gen:hasRole} attachment.</li>
1266
     *   <li><b>Anti-hijack</b> — the alias must not be an independently-governed live
1267
     *       space: it must have no admin who is not also an admin of the canonical
1268
     *       space ({@code admins(alias) ⊆ admins(canonical)}). The common rename case
1269
     *       (the alias's own definition was superseded, so it has no live admin
1270
     *       closure) passes trivially; an attacker publishing
1271
     *       {@code <evil> owl:sameAs <activeSpace>} is rejected because the active
1272
     *       space has admins not in evil's set.</li>
1273
     * </ul>
1274
     *
1275
     * <p>Late-arrival: when the canonical admin grant only becomes valid in the same
1276
     * cycle as the declaration, the load-number filter on {@code ?np} excludes the
1277
     * candidate; the late-arrival sweep ({@link #runDownstreamWithoutLoadFilter})
1278
     * re-runs this pass without the load filter and catches it.
1279
     */
1280
    static String aliasAdmitUpdate(IRI graph, long lastProcessed) {
1281
        return """
69✔
1282
                PREFIX npa: <%1$s>
1283
                PREFIX gen: <%2$s>
1284
                INSERT { GRAPH <%3$s> {
1285
                  ?d a npa:SpaceAliasDeclaration ;
1286
                     npa:canonicalSpace ?canonical ;
1287
                     npa:aliasSpace     ?alias ;
1288
                     npa:viaNanopub     ?np .
1289
                  ?alias npa:sameAsSpace ?canonical .
1290
                } }
1291
                WHERE {
1292
                  # 1. Anchor: candidate alias declarations from the extraction graph.
1293
                  GRAPH <%4$s> {
1294
                    ?d a npa:SpaceAliasDeclaration ;
1295
                       npa:canonicalSpace ?canonical ;
1296
                       npa:aliasSpace     ?alias ;
1297
                       npa:pubkeyHash     ?pkh ;
1298
                       npa:viaNanopub     ?np .
1299
                  }
1300
                  # 2. Mirror + authority gate: publisher is a validated admin of the
1301
                  #    canonical space.
1302
                  GRAPH <%3$s> {
1303
                    ?acct a npa:AccountState ;
1304
                          npa:pubkey ?pkh ;
1305
                          npa:agent  ?publisher .
1306
                    ?adminRI a gen:RoleInstantiation ;
1307
                             npa:inverseProperty gen:hasAdmin ;
1308
                             npa:forSpace ?canonical ;
1309
                             npa:forAgent ?publisher .
1310
                  }
1311
                  # 3. Anti-hijack: the alias must have no admin who is not also an
1312
                  #    admin of the canonical space (admins(alias) ⊆ admins(canonical)).
1313
                  FILTER NOT EXISTS {
1314
                    GRAPH <%3$s> {
1315
                      ?aliasAdmin a gen:RoleInstantiation ;
1316
                                  npa:inverseProperty gen:hasAdmin ;
1317
                                  npa:forSpace ?alias ;
1318
                                  npa:forAgent ?otherAgent .
1319
                    }
1320
                    FILTER NOT EXISTS {
1321
                      GRAPH <%3$s> {
1322
                        ?canonAdmin a gen:RoleInstantiation ;
1323
                                    npa:inverseProperty gen:hasAdmin ;
1324
                                    npa:forSpace ?canonical ;
1325
                                    npa:forAgent ?otherAgent .
1326
                      }
1327
                    }
1328
                  }
1329
                  # 4. Invalidation filter on the declaration's nanopub.
1330
                  %6$s
1331
                  # 5. Load-number filter on bound ?np.
1332
                  GRAPH <%7$s> {
1333
                    ?np npa:hasLoadNumber ?ln .
1334
                    FILTER (?ln > %5$d)
1335
                  }
1336
                  # 6. Dedup last.
1337
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1338
                    ?d a npa:SpaceAliasDeclaration .
1339
                  } }
1340
                }
1341
                """.formatted(
3✔
1342
                NPA.NAMESPACE,
1343
                GEN.NAMESPACE,
1344
                graph,
1345
                SpacesVocab.SPACES_GRAPH,
1346
                lastProcessed,
15✔
1347
                invalidationFilter("np"),
18✔
1348
                NPA.GRAPH);
1349
    }
1350

1351
    /**
1352
     * URL-prefix sub-space fallback admit pass. For every pair of {@code SpaceRef}
1353
     * aggregates where the child's {@code npa:hasIdPrefix} matches the parent's
1354
     * {@code npa:spaceIri}, emits convenience {@code <child> npa:isSubSpaceOf <parent>}
1355
     * and {@code <parent> npa:hasSubSpace <child>} direct triples plus a reified
1356
     * {@code npa:DerivedSubSpaceLink} tag carrying {@code npa:derivationKind
1357
     * npa:byUrlPrefix} so consumers can hide derived edges.
1358
     *
1359
     * <p>Per-child suppression: any validated {@code npa:SubSpaceDeclaration} on the
1360
     * child in {@code npass:<…>} suppresses every fallback edge for that child.
1361
     * Suppression checks the validated set (not raw extraction-graph declarations)
1362
     * so an unapproved or in-flight Mode B declaration doesn't silently hide both
1363
     * the URL-prefix fallback and the (still-invalid) explicit relation.
1364
     *
1365
     * <p>Run order: must run after {@link #subSpaceAdmitUpdate} commits in the
1366
     * same cycle so the suppression check sees this cycle's freshly-validated
1367
     * declarations.
1368
     *
1369
     * <p>No load-number filter: the fallback depends on which Spaces exist (parent
1370
     * + child {@code SpaceRef}s), not on which were just added. Always full-scan;
1371
     * the dedup {@code FILTER NOT EXISTS} on the tag IRI prevents re-insertion.
1372
     *
1373
     * <p>No invalidation handling: derived edges have no source nanopub. Two
1374
     * staleness modes: (a) child later gets first validated declaration → old
1375
     * derived edges stay sticky until the next periodic rebuild (same policy as
1376
     * admin-RI invalidation); (b) child loses last validated declaration → the
1377
     * regular fallback pass on the next cycle re-engages, adds derived edges
1378
     * incrementally, no rebuild needed.
1379
     */
1380
    static String subSpacePrefixFallbackUpdate(IRI graph) {
1381
        return """
48✔
1382
                PREFIX npa: <%1$s>
1383
                INSERT { GRAPH <%2$s> {
1384
                  ?child  npa:isSubSpaceOf ?parent .
1385
                  ?parent npa:hasSubSpace  ?child  .
1386
                  ?tagIri a npa:DerivedSubSpaceLink ;
1387
                          npa:childSpace     ?child ;
1388
                          npa:parentSpace    ?parent ;
1389
                          npa:derivationKind npa:byUrlPrefix .
1390
                } }
1391
                WHERE {
1392
                  # 1. Anchor: child SpaceRef → its path-prefixes (extracted at load
1393
                  #    time from the Space IRI; see SpacesExtractor.enumerateIdPrefixes).
1394
                  GRAPH <%3$s> {
1395
                    ?childRef  npa:spaceIri    ?child ;
1396
                               npa:hasIdPrefix ?parent .
1397
                    # 2. Parent SpaceRef must exist for the same IRI as the prefix.
1398
                    ?parentRef npa:spaceIri    ?parent .
1399
                  }
1400
                  # 3. Suppress fallback for any child that has a validated declaration
1401
                  #    in this state graph. Per-child, all-or-nothing.
1402
                  FILTER NOT EXISTS {
1403
                    GRAPH <%2$s> {
1404
                      ?d a npa:SubSpaceDeclaration ;
1405
                         npa:childSpace ?child .
1406
                    }
1407
                  }
1408
                  # 4. Mint a deterministic tag IRI per (child, parent).
1409
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/derivedlink/",
1410
                                  MD5(CONCAT(STR(?child), "|", STR(?parent))))) AS ?tagIri)
1411
                  # 5. Dedup: don't re-insert if this tag is already present.
1412
                  FILTER NOT EXISTS {
1413
                    GRAPH <%2$s> {
1414
                      ?tagIri a npa:DerivedSubSpaceLink .
1415
                    }
1416
                  }
1417
                }
1418
                """.formatted(
3✔
1419
                NPA.NAMESPACE,
1420
                graph,
1421
                SpacesVocab.SPACES_GRAPH);
1422
    }
1423

1424
    // ---------------- Invalidation templates (incremental cycle) ----------------
1425

1426
    /**
1427
     * WHERE clause shared by the admin-RI invalidation ASK precheck and the
1428
     * matching DELETE. Identifies admin-tier {@code gen:RoleInstantiation} rows
1429
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
1430
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
1431
     * has a load number in {@code (lastProcessed, ∞)}.
1432
     */
1433
    static String adminInvalidationCheckWhere(IRI graph, long lastProcessed) {
1434
        return String.format("""
60✔
1435
                  GRAPH <%1$s> {
1436
                    ?ri a gen:RoleInstantiation ;
1437
                        npa:inverseProperty gen:hasAdmin ;
1438
                        npa:viaNanopub ?np .
1439
                  }
1440
                  GRAPH <%2$s> {
1441
                    ?invNp <%3$s> ?np ;
1442
                           npa:hasLoadNumber ?ln .
1443
                    FILTER (?ln > %4$d)
1444
                  }
1445
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1446
    }
1447

1448
    /** DELETE template for admin-tier RoleInstantiations whose source nanopub was invalidated. */
1449
    static String adminInvalidationDelete(IRI graph, long lastProcessed) {
1450
        return String.format("""
63✔
1451
                PREFIX npa: <%1$s>
1452
                PREFIX gen: <%2$s>
1453
                DELETE { GRAPH <%3$s> {
1454
                  ?ri ?p ?o .
1455
                } }
1456
                WHERE {
1457
                  GRAPH <%3$s> { ?ri ?p ?o . }
1458
                %4$s
1459
                }
1460
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1461
                adminInvalidationCheckWhere(graph, lastProcessed));
6✔
1462
    }
1463

1464
    /** WHERE clause for RoleAssignment invalidation. */
1465
    static String roleAssignmentInvalidationCheckWhere(IRI graph, long lastProcessed) {
1466
        return String.format("""
60✔
1467
                  GRAPH <%1$s> {
1468
                    ?ra a gen:RoleAssignment ;
1469
                        npa:viaNanopub ?np .
1470
                  }
1471
                  GRAPH <%2$s> {
1472
                    ?invNp <%3$s> ?np ;
1473
                           npa:hasLoadNumber ?ln .
1474
                    FILTER (?ln > %4$d)
1475
                  }
1476
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1477
    }
1478

1479
    /** DELETE template for RoleAssignments whose source nanopub was invalidated. */
1480
    static String roleAssignmentInvalidationDelete(IRI graph, long lastProcessed) {
1481
        return String.format("""
63✔
1482
                PREFIX npa: <%1$s>
1483
                PREFIX gen: <%2$s>
1484
                DELETE { GRAPH <%3$s> {
1485
                  ?ra ?p ?o .
1486
                } }
1487
                WHERE {
1488
                  GRAPH <%3$s> { ?ra ?p ?o . }
1489
                %4$s
1490
                }
1491
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1492
                roleAssignmentInvalidationCheckWhere(graph, lastProcessed));
6✔
1493
    }
1494

1495
    /**
1496
     * WHERE clause for RoleDeclaration invalidation. ASK-only (no DELETE):
1497
     * RoleDeclarations live in {@code npa:spacesGraph} and aren't materialized
1498
     * into the space-state graph, so there's nothing to remove from the
1499
     * space-state. The ASK still flips {@code npa:needsFullRebuild} because
1500
     * sticky downstream RIs that were derived under the now-invalidated RD
1501
     * need a from-scratch recompute.
1502
     */
1503
    static String roleDeclarationInvalidationCheckWhere(long lastProcessed) {
1504
        return String.format("""
60✔
1505
                  GRAPH <%1$s> {
1506
                    ?rd a npa:RoleDeclaration ;
1507
                        npa:viaNanopub ?np .
1508
                  }
1509
                  GRAPH <%2$s> {
1510
                    ?invNp <%3$s> ?np ;
1511
                           npa:hasLoadNumber ?ln .
1512
                    FILTER (?ln > %4$d)
1513
                  }
1514
                """, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1515
    }
1516

1517
    /**
1518
     * DELETE template for non-admin (leaf-tier) RoleInstantiations whose source
1519
     * nanopub was invalidated. Identified as {@code gen:RoleInstantiation} rows
1520
     * lacking the admin-pinning {@code npa:inverseProperty gen:hasAdmin} triple.
1521
     * No flag is set; leaf-tier removals are recoverable on the next cycle.
1522
     */
1523
    static String leafTierInvalidationDelete(IRI graph, long lastProcessed) {
1524
        return String.format("""
84✔
1525
                PREFIX npa: <%1$s>
1526
                PREFIX gen: <%2$s>
1527
                DELETE { GRAPH <%3$s> {
1528
                  ?ri ?p ?o .
1529
                } }
1530
                WHERE {
1531
                  GRAPH <%3$s> {
1532
                    ?ri a gen:RoleInstantiation ;
1533
                        npa:viaNanopub ?np .
1534
                    FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
1535
                    ?ri ?p ?o .
1536
                  }
1537
                  GRAPH <%4$s> {
1538
                    ?invNp <%5$s> ?np ;
1539
                           npa:hasLoadNumber ?ln .
1540
                    FILTER (?ln > %6$d)
1541
                  }
1542
                }
1543
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1544
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1545
    }
1546

1547
    /**
1548
     * WHERE clause shared by the sub-space invalidation ASK precheck and the
1549
     * matching DELETE. Identifies validated {@code npa:SubSpaceDeclaration} rows
1550
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
1551
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
1552
     * has a load number in {@code (lastProcessed, ∞)}.
1553
     */
1554
    static String subSpaceInvalidationCheckWhere(IRI graph, long lastProcessed) {
1555
        return String.format("""
60✔
1556
                  GRAPH <%1$s> {
1557
                    ?d a npa:SubSpaceDeclaration ;
1558
                       npa:viaNanopub ?np .
1559
                  }
1560
                  GRAPH <%2$s> {
1561
                    ?invNp <%3$s> ?np ;
1562
                           npa:hasLoadNumber ?ln .
1563
                    FILTER (?ln > %4$d)
1564
                  }
1565
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1566
    }
1567

1568
    /**
1569
     * DELETE template for validated {@code npa:SubSpaceDeclaration} rows whose
1570
     * source nanopub was invalidated. Removes the per-declaration row by subject;
1571
     * the convenience direct triples ({@code <child> npa:isSubSpaceOf <parent>}
1572
     * and inverse) are left sticky and cleaned by the next periodic full rebuild
1573
     * (same staleness policy as admin-RI invalidation — see {@code
1574
     * doc/design-space-repositories.md} on the structural-rebuild flag).
1575
     */
1576
    static String subSpaceInvalidationDelete(IRI graph, long lastProcessed) {
1577
        return String.format("""
63✔
1578
                PREFIX npa: <%1$s>
1579
                PREFIX gen: <%2$s>
1580
                DELETE { GRAPH <%3$s> {
1581
                  ?d ?p ?o .
1582
                } }
1583
                WHERE {
1584
                  GRAPH <%3$s> { ?d ?p ?o . }
1585
                %4$s
1586
                }
1587
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1588
                subSpaceInvalidationCheckWhere(graph, lastProcessed));
6✔
1589
    }
1590

1591
    /**
1592
     * DELETE template for validated {@code npa:MaintainedResourceDeclaration} rows
1593
     * whose source nanopub was invalidated. Removes the per-declaration row by
1594
     * subject; the convenience direct triples ({@code <r> npa:isMaintainedBy <s>}
1595
     * and inverse) are left sticky and cleaned by the next periodic full rebuild
1596
     * (same staleness policy as sub-space declaration invalidation, but without
1597
     * the structural-rebuild flag — maintained-resource is a leaf relation, no
1598
     * downstream consumers depend on its closure).
1599
     */
1600
    static String maintainedResourceInvalidationDelete(IRI graph, long lastProcessed) {
1601
        return String.format("""
84✔
1602
                PREFIX npa: <%1$s>
1603
                PREFIX gen: <%2$s>
1604
                DELETE { GRAPH <%3$s> {
1605
                  ?d ?p ?o .
1606
                } }
1607
                WHERE {
1608
                  GRAPH <%3$s> {
1609
                    ?d a npa:MaintainedResourceDeclaration ;
1610
                       npa:viaNanopub ?np .
1611
                    ?d ?p ?o .
1612
                  }
1613
                  GRAPH <%4$s> {
1614
                    ?invNp <%5$s> ?np ;
1615
                           npa:hasLoadNumber ?ln .
1616
                    FILTER (?ln > %6$d)
1617
                  }
1618
                }
1619
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1620
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1621
    }
1622

1623
    /**
1624
     * WHERE clause shared by the alias invalidation ASK precheck and the matching
1625
     * DELETE. Identifies validated {@code npa:SpaceAliasDeclaration} rows in the
1626
     * space-state graph whose {@code npa:viaNanopub} is the target of an
1627
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
1628
     * load number in {@code (lastProcessed, ∞)}.
1629
     */
1630
    static String aliasInvalidationCheckWhere(IRI graph, long lastProcessed) {
1631
        return String.format("""
60✔
1632
                  GRAPH <%1$s> {
1633
                    ?d a npa:SpaceAliasDeclaration ;
1634
                       npa:viaNanopub ?np .
1635
                  }
1636
                  GRAPH <%2$s> {
1637
                    ?invNp <%3$s> ?np ;
1638
                           npa:hasLoadNumber ?ln .
1639
                    FILTER (?ln > %4$d)
1640
                  }
1641
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1642
    }
1643

1644
    /**
1645
     * DELETE template for validated {@code npa:SpaceAliasDeclaration} rows whose
1646
     * source nanopub was invalidated. Removes the per-declaration row by subject; the
1647
     * convenience {@code <alias> npa:sameAsSpace <canonical>} edge is left sticky and
1648
     * cleaned by the next periodic full rebuild (same staleness policy as sub-space
1649
     * declaration invalidation — the alias feeds the authority closure, so this kind
1650
     * is structural and flips {@code npa:needsFullRebuild}).
1651
     */
1652
    static String aliasInvalidationDelete(IRI graph, long lastProcessed) {
1653
        return String.format("""
63✔
1654
                PREFIX npa: <%1$s>
1655
                PREFIX gen: <%2$s>
1656
                DELETE { GRAPH <%3$s> {
1657
                  ?d ?p ?o .
1658
                } }
1659
                WHERE {
1660
                  GRAPH <%3$s> { ?d ?p ?o . }
1661
                %4$s
1662
                }
1663
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1664
                aliasInvalidationCheckWhere(graph, lastProcessed));
6✔
1665
    }
1666

1667
    /** Wraps an ASK by joining the shared prefixes. */
1668
    private boolean wouldInvalidate(IRI graph, long lastProcessed,
1669
                                    boolean adminPinned, String whereClause) {
1670
        // adminPinned is informational only — kept to make call sites read clearly;
1671
        // the WHERE clause already encodes the kind via its own type predicates.
1672
        String ask = String.format("""
×
1673
                PREFIX npa: <%1$s>
1674
                PREFIX gen: <%2$s>
1675
                ASK { %3$s }
1676
                """, NPA.NAMESPACE, GEN.NAMESPACE, whereClause);
1677
        return runAsk(ask);
×
1678
    }
1679

1680
    private boolean runAsk(String sparql) {
1681
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1682
            return conn.prepareBooleanQuery(QueryLanguage.SPARQL, sparql).evaluate();
×
1683
        }
1684
    }
1685

1686
    private void executeUpdate(String sparqlUpdate) {
1687
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1688
            conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
1689
        }
1690
    }
×
1691

1692
    // ---------------- Mirror step ----------------
1693

1694
    /**
1695
     * Copies trust-approved {@code npa:AccountState} rows from {@code npat:<T>}
1696
     * in the {@code trust} repo into {@code newGraph} in the {@code spaces} repo,
1697
     * inside one spaces-side serializable transaction.
1698
     *
1699
     * @return number of rows mirrored (useful for metrics / logging)
1700
     */
1701
    int mirrorTrustState(String trustStateHash, IRI newGraph) {
1702
        IRI trustStateIri = NPAT.forHash(trustStateHash);
×
1703
        int count = 0;
×
1704
        try (RepositoryConnection trustConn = TripleStore.get().getRepoConnection(TRUST_REPO);
×
1705
             RepositoryConnection spacesConn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1706
            trustConn.begin(IsolationLevels.READ_COMMITTED);
×
1707
            spacesConn.begin(IsolationLevels.SERIALIZABLE);
×
1708
            // Walk rdf:type triples in the trust state's graph; for each AccountState,
1709
            // check status and copy the approved ones verbatim (minus status-specific
1710
            // detail triples, which we don't need for validation).
1711
            try (RepositoryResult<Statement> typeRows = trustConn.getStatements(
×
1712
                    null, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri)) {
1713
                while (typeRows.hasNext()) {
×
1714
                    Statement st = typeRows.next();
×
1715
                    if (!(st.getSubject() instanceof IRI accountStateIri)) continue;
×
1716
                    Value status = trustConn.getStatements(accountStateIri, NPA_TRUST_STATUS, null, trustStateIri)
×
1717
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
1718
                    if (!(status instanceof IRI statusIri) || !APPROVED_SET.contains(statusIri)) continue;
×
1719
                    Value agent = trustConn.getStatements(accountStateIri, NPA_AGENT, null, trustStateIri)
×
1720
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
1721
                    Value pubkey = trustConn.getStatements(accountStateIri, NPA_PUBKEY, null, trustStateIri)
×
1722
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
1723
                    if (agent == null || pubkey == null) {
×
1724
                        log.warn("AuthorityResolver.mirror: account {} missing agent or pubkey; skipping",
×
1725
                                accountStateIri);
1726
                        continue;
×
1727
                    }
1728
                    spacesConn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, newGraph);
×
1729
                    spacesConn.add(accountStateIri, NPA_AGENT, agent, newGraph);
×
1730
                    spacesConn.add(accountStateIri, NPA_PUBKEY, pubkey, newGraph);
×
1731
                    spacesConn.add(accountStateIri, NPA_TRUST_STATUS, statusIri, newGraph);
×
1732
                    count++;
×
1733
                }
×
1734
            }
1735
            // Mirror canonical foaf:name triples for approved agents. The trust
1736
            // loader emits one per agent (across approved keys, MAX(ratio) wins).
1737
            // Copying them into the space-state graph means consumers reading
1738
            // ?agent foaf:name ?n inside the state graph hit local data, with no
1739
            // cross-repo SERVICE.
1740
            try (RepositoryResult<Statement> nameRows = trustConn.getStatements(
×
1741
                    null, FOAF.NAME, null, trustStateIri)) {
1742
                while (nameRows.hasNext()) {
×
1743
                    Statement st = nameRows.next();
×
1744
                    spacesConn.add(st.getSubject(), st.getPredicate(), st.getObject(), newGraph);
×
1745
                }
×
1746
            }
1747
            spacesConn.commit();
×
1748
            trustConn.commit();
×
1749
        }
1750
        return count;
×
1751
    }
1752

1753
    // ---------------- Pointer + counter helpers ----------------
1754

1755
    /**
1756
     * Reads the current {@code npa:hasCurrentSpaceState} pointer from the
1757
     * {@code npa:graph} admin graph of the {@code spaces} repo. Returns
1758
     * {@code null} if no pointer exists yet.
1759
     */
1760
    IRI getCurrentSpaceStateGraph() {
1761
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1762
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
1763
                    SpacesVocab.HAS_CURRENT_SPACE_STATE);
1764
            return (v instanceof IRI iri) ? iri : null;
×
1765
        } catch (Exception ex) {
×
1766
            log.warn("AuthorityResolver: failed to read hasCurrentSpaceState pointer: {}", ex.toString());
×
1767
            return null;
×
1768
        }
1769
    }
1770

1771
    long getCurrentLoadCounter() {
1772
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1773
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
1774
                    SpacesVocab.CURRENT_LOAD_COUNTER);
1775
            if (v == null) return 0;
×
1776
            try {
1777
                return Long.parseLong(v.stringValue());
×
1778
            } catch (NumberFormatException ex) {
×
1779
                log.warn("AuthorityResolver: non-numeric currentLoadCounter: {}", v);
×
1780
                return 0;
×
1781
            }
1782
        } catch (Exception ex) {
×
1783
            log.warn("AuthorityResolver: failed to read currentLoadCounter: {}", ex.toString());
×
1784
            return 0;
×
1785
        }
1786
    }
1787

1788
    /**
1789
     * Atomic pointer flip: a single SPARQL {@code DELETE … INSERT … WHERE}
1790
     * replaces the old pointer with the new one in one statement, so readers
1791
     * never see a zero-pointer window.
1792
     */
1793
    void flipPointer(IRI newGraph) {
1794
        String update = String.format("""
×
1795
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
1796
                INSERT { GRAPH <%s> { <%s> <%s> <%s> } }
1797
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
1798
                """,
1799
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE,
1800
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE, newGraph,
1801
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE);
1802
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1803
            conn.begin(IsolationLevels.SERIALIZABLE);
×
1804
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
1805
            conn.commit();
×
1806
        }
1807
    }
×
1808

1809
    void writeProcessedUpTo(IRI graph, long loadCounter) {
1810
        String update = String.format("""
×
1811
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
1812
                INSERT { GRAPH <%s> { <%s> <%s> "%d"^^<http://www.w3.org/2001/XMLSchema#long> } }
1813
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
1814
                """,
1815
                graph, graph, SpacesVocab.PROCESSED_UP_TO,
1816
                graph, graph, SpacesVocab.PROCESSED_UP_TO, loadCounter,
×
1817
                graph, graph, SpacesVocab.PROCESSED_UP_TO);
1818
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1819
            conn.begin(IsolationLevels.SERIALIZABLE);
×
1820
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
1821
            conn.commit();
×
1822
        }
1823
    }
×
1824

1825
    /**
1826
     * Reads {@code processedUpTo} from the given space-state graph.
1827
     * Returns {@code -1} if absent (graph not fully built yet).
1828
     */
1829
    long readProcessedUpTo(IRI graph) {
1830
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1831
            String query = String.format(
×
1832
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
1833
                    graph, graph, SpacesVocab.PROCESSED_UP_TO);
1834
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
1835
                if (!r.hasNext()) return -1;
×
1836
                BindingSet b = r.next();
×
1837
                return Long.parseLong(b.getBinding("n").getValue().stringValue());
×
1838
            }
×
1839
        } catch (Exception ex) {
×
1840
            log.warn("AuthorityResolver: failed to read processedUpTo for {}: {}", graph, ex.toString());
×
1841
            return -1;
×
1842
        }
1843
    }
1844

1845
    /**
1846
     * Reads the {@code npa:needsFullRebuild} flag (boolean literal) from
1847
     * {@code npa:graph} in the {@code spaces} repo. Defaults to {@code false}
1848
     * when the triple is absent.
1849
     */
1850
    boolean readNeedsFullRebuild() {
1851
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1852
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
1853
                    SpacesVocab.NEEDS_FULL_REBUILD);
1854
            return v != null && Boolean.parseBoolean(v.stringValue());
×
1855
        } catch (Exception ex) {
×
1856
            log.warn("AuthorityResolver: failed to read needsFullRebuild: {}", ex.toString());
×
1857
            return false;
×
1858
        }
1859
    }
1860

1861
    void setNeedsFullRebuild() {
1862
        writeNeedsFullRebuild(true);
×
1863
    }
×
1864

1865
    void clearNeedsFullRebuild() {
1866
        writeNeedsFullRebuild(false);
×
1867
    }
×
1868

1869
    private void writeNeedsFullRebuild(boolean value) {
1870
        String update = String.format("""
×
1871
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
1872
                INSERT { GRAPH <%s> { <%s> <%s> "%s"^^<http://www.w3.org/2001/XMLSchema#boolean> } }
1873
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
1874
                """,
1875
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD,
1876
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD, value,
×
1877
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD);
1878
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1879
            conn.begin(IsolationLevels.SERIALIZABLE);
×
1880
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
1881
            conn.commit();
×
1882
        }
1883
    }
×
1884

1885
    void dropGraph(IRI graph) {
1886
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1887
            conn.begin(IsolationLevels.SERIALIZABLE);
×
1888
            conn.clear(graph);
×
1889
            conn.commit();
×
1890
            log.info("AuthorityResolver: dropped old space-state graph {}", graph);
×
1891
        }
1892
    }
×
1893

1894
    // ---------------- Trust-repo pointer lookup (used by TrustStateRegistry's bootstrap) ----------------
1895

1896
    /**
1897
     * Queries the {@code trust} repo directly for the current trust-state hash.
1898
     * Prefer {@link TrustStateRegistry#getCurrentHash()} in normal operation —
1899
     * this helper exists for tests and diagnostics.
1900
     *
1901
     * @return the current trust-state hash, or empty if none is set
1902
     */
1903
    Optional<String> readTrustRepoCurrentHash() {
1904
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
1905
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
1906
                    NPA_HAS_CURRENT_TRUST_STATE);
1907
            if (!(v instanceof IRI iri)) return Optional.empty();
×
1908
            String s = iri.stringValue();
×
1909
            if (!s.startsWith(NPAT.NAMESPACE)) return Optional.empty();
×
1910
            return Optional.of(s.substring(NPAT.NAMESPACE.length()));
×
1911
        } catch (Exception ex) {
×
1912
            log.warn("AuthorityResolver: failed to read trust-repo current pointer: {}", ex.toString());
×
1913
            return Optional.empty();
×
1914
        }
1915
    }
1916

1917
    private static String abbrev(String hash) {
1918
        return hash.length() > 12 ? hash.substring(0, 12) + "…" : hash;
×
1919
    }
1920

1921
}
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