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

knowledgepixels / nanopub-query / 25433039741

06 May 2026 11:40AM UTC coverage: 57.642% (-0.2%) from 57.877%
25433039741

push

github

web-flow
Merge pull request #96 from knowledgepixels/feature/93-subspace-prefix-fallback

Emit URL-prefix sub-space fallback edges in materialiser (#93, PR 3/3)

465 of 886 branches covered (52.48%)

Branch coverage included in aggregate %.

1251 of 2091 relevant lines covered (59.83%)

9.25 hits per line

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

14.14
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.slf4j.Logger;
23
import org.slf4j.LoggerFactory;
24

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

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

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

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

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

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

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

83
    private static AuthorityResolver instance;
84

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

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

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

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

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

117
    // ---------------- Public entry points ----------------
118

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

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

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

204
    // ---------------- Full build ----------------
205

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

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

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

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

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

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

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

259
    // ---------------- Incremental cycle ----------------
260

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

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

320
        writeProcessedUpTo(graph, currentLoadCounter);
×
321

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

341
    /**
342
     * Runs the four invalidation-DELETE / ASK steps. Sets {@code npa:needsFullRebuild}
343
     * when admin-RI, RoleAssignment, or RoleDeclaration invalidations matched (the
344
     * three structural kinds). Leaf-tier RI deletes don't set the flag.
345
     *
346
     * @return true iff at least one structural kind was invalidated
347
     */
348
    boolean applyInvalidations(IRI graph, long lastProcessed) {
349
        boolean structural = false;
×
350
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ true,
×
351
                            adminInvalidationCheckWhere(graph, lastProcessed))) {
×
352
            executeUpdate(adminInvalidationDelete(graph, lastProcessed));
×
353
            structural = true;
×
354
        }
355
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
356
                            roleAssignmentInvalidationCheckWhere(graph, lastProcessed))) {
×
357
            executeUpdate(roleAssignmentInvalidationDelete(graph, lastProcessed));
×
358
            structural = true;
×
359
        }
360
        // RoleDeclaration ASK only — RDs aren't materialized into the space-state
361
        // graph, so there's nothing to DELETE here. The flag still flips because
362
        // sticky downstream RIs derived from the now-invalidated RD need a
363
        // from-scratch recompute.
364
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
365
                            roleDeclarationInvalidationCheckWhere(lastProcessed))) {
×
366
            structural = true;
×
367
        }
368
        // Sub-space declarations are structural — invalidating one (Mode A) or one
369
        // of two co-declarations (Mode B) changes the validated parent/child
370
        // topology. The DELETE removes the per-declaration row; the convenience
371
        // direct triples are left sticky and cleaned on the next periodic rebuild.
372
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
373
                            subSpaceInvalidationCheckWhere(graph, lastProcessed))) {
×
374
            executeUpdate(subSpaceInvalidationDelete(graph, lastProcessed));
×
375
            structural = true;
×
376
        }
377
        // Leaf-tier RI deletes — no flag.
378
        executeUpdate(leafTierInvalidationDelete(graph, lastProcessed));
×
379
        if (structural) setNeedsFullRebuild();
×
380
        return structural;
×
381
    }
382

383
    /**
384
     * Runs the four leaf tiers (attachment/maintainer/member/observer) with
385
     * {@code lastProcessed = -1} so the load-number filter on the candidate
386
     * side admits everything. Dedup filters in the tier templates prevent
387
     * double-insert. Used by the late-arrival sweep.
388
     */
389
    TierInsertedTriples runDownstreamWithoutLoadFilter(IRI graph) {
390
        TierInsertedTriples c = new TierInsertedTriples();
×
391
        // Sub-space late-arrival: catches Mode-B candidates whose primary
392
        // declaration is older than lastProcessed but whose partner just landed.
393
        c.subSpace = runTierLabeled("subspace(late)", graph,
×
394
                subSpaceAdmitUpdate(graph, -1));
×
395
        // URL-prefix fallback: re-run after the late-arrival sub-space admit so
396
        // any newly-validated children get their fallback edges suppressed (for
397
        // future inserts) and any newly-orphaned children pick up fallback edges.
398
        c.subSpacePrefix = runTierLabeled("subspace-prefix(late)", graph,
×
399
                subSpacePrefixFallbackUpdate(graph));
×
400
        c.attachment = runTierLabeled("attachment(late)", graph,
×
401
                attachmentValidationUpdate(graph, -1));
×
402
        c.maintainer = runTierLabeled("maintainer(late)", graph,
×
403
                nonAdminTierUpdate(graph, -1, GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
×
404
        c.member = runTierLabeled("member(admin-pub,late)", graph,
×
405
                nonAdminTierUpdate(graph, -1, GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
×
406
        c.member += runTierLabeled("member(maint-pub,late)", graph,
×
407
                nonAdminTierUpdate(graph, -1,
×
408
                        GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
409
        c.observer = runTierLabeled("observer(admin-pub,late)", graph,
×
410
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
×
411
        c.observer += runTierLabeled("observer(maint-pub,late)", graph,
×
412
                nonAdminTierUpdate(graph, -1,
×
413
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
414
        c.observer += runTierLabeled("observer(member-pub,late)", graph,
×
415
                nonAdminTierUpdate(graph, -1,
×
416
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
×
417
        c.observer += runTierLabeled("observer(self,late)", graph,
×
418
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
×
419
        return c;
×
420
    }
421

422
    /**
423
     * Cheap ASK: did any new {@code npa:RoleDeclaration} extraction land in the
424
     * load-number delta {@code (lastProcessed, ∞)}? Used by the late-arrival
425
     * trigger so an RD that arrives in the same cycle as a matching candidate
426
     * still gets validated.
427
     */
428
    boolean newRoleDeclarationsArrived(long lastProcessed) {
429
        String ask = String.format("""
×
430
                PREFIX npa: <%1$s>
431
                ASK {
432
                  GRAPH <%2$s> {
433
                    ?rd a npa:RoleDeclaration ;
434
                        npa:viaNanopub ?np .
435
                  }
436
                  GRAPH <%3$s> {
437
                    ?np npa:hasLoadNumber ?ln .
438
                    FILTER (?ln > %4$d)
439
                  }
440
                }
441
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
×
442
        return runAsk(ask);
×
443
    }
444

445
    // ---------------- Tier UPDATE loops ----------------
446

447
    /**
448
     * Per-tier inserted-triple tallies for one build or cycle. Counts the sum
449
     * of {@code (graphSize_after - graphSize_before)} across all iterations of
450
     * each tier's fixed-point INSERT loop — i.e. inserted *triples*, not
451
     * distinct subjects (a single RoleInstantiation insert writes 4–5 triples).
452
     *
453
     * <p>Used internally by the {@link #runIncrementalCycle structuralAdds}
454
     * boolean check (we only care whether any tier inserted at all).
455
     * Not what the log lines report: see {@link TierSubjectTotals} +
456
     * {@link #computeTierSubjectTotals} for the distinct-subject totals
457
     * surfaced to operators.
458
     */
459
    static final class TierInsertedTriples {
×
460
        int admin;
461
        int attachment;
462
        int maintainer;
463
        int member;
464
        int observer;
465
        int subSpace;
466
        int subSpacePrefix;
467
    }
468

469
    /**
470
     * Snapshot of distinct-subject totals in a space-state graph at a moment
471
     * in time. Independent of which tier-loop added each subject.
472
     */
473
    record TierSubjectTotals(long adminRIs, long attachmentRAs, long nonAdminRIs) {}
36✔
474

475
    /**
476
     * Runs the five tier loops in order: admin → {@code gen:hasRole} attachment
477
     * validation → maintainer → member → observer. Each loop iterates a SPARQL
478
     * INSERT to fixed point (no new triples added). Returns per-tier counts.
479
     *
480
     * @param graph         target space-state graph
481
     * @param lastProcessed load-number horizon; use {@code -1} for full build
482
     */
483
    TierInsertedTriples runAllTierLoops(IRI graph, long lastProcessed) {
484
        TierInsertedTriples c = new TierInsertedTriples();
×
485
        c.admin = runTierLabeled("admin", graph, adminTierUpdate(graph, lastProcessed));
×
486
        // Sub-space admit runs after admin closure has settled (Mode A + Mode B both
487
        // need the admin set). Independent of role tiers — order between subspace
488
        // and attachment / maintainer / member / observer doesn't matter.
489
        c.subSpace = runTierLabeled("subspace", graph, subSpaceAdmitUpdate(graph, lastProcessed));
×
490
        // URL-prefix sub-space fallback runs after the explicit-declaration admit
491
        // pass commits so the per-child suppression check sees this cycle's fresh
492
        // validations. No load filter — depends on which Spaces exist, not on
493
        // delta-arrivals; the dedup FILTER NOT EXISTS prevents re-insertion.
494
        c.subSpacePrefix = runTierLabeled("subspace-prefix", graph,
×
495
                subSpacePrefixFallbackUpdate(graph));
×
496
        c.attachment = runTierLabeled("attachment", graph,
×
497
                attachmentValidationUpdate(graph, lastProcessed));
×
498
        c.maintainer = runTierLabeled("maintainer", graph, nonAdminTierUpdate(graph, lastProcessed,
×
499
                GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
500
        // Member tier: admin OR maintainer publisher — split into two simpler updates
501
        // so the query planner doesn't struggle with the UNION.
502
        c.member = runTierLabeled("member(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
503
                GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
504
        c.member += runTierLabeled("member(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
505
                GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
506
        // Observer tier: self-evidence OR a downward grant from any higher tier.
507
        // ObserverRole is the default tier when a role definition omits an
508
        // explicit subclass (see "Role types" in design-space-repositories.md), so
509
        // most "X assigned Y this role" nanopubs land here. Restricting the tier
510
        // to PUBLISHER_IS_SELF would silently drop those grants. The four
511
        // sub-loops mirror the trust-state's downward-only chain: admin grants
512
        // anything; maintainers and members grant observer; everyone may
513
        // self-attest.
514
        c.observer = runTierLabeled("observer(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
515
                GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
516
        c.observer += runTierLabeled("observer(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
517
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
518
        c.observer += runTierLabeled("observer(member-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
519
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
×
520
        c.observer += runTierLabeled("observer(self)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
521
                GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
522
        return c;
×
523
    }
524

525
    /**
526
     * Builds a publisher constraint requiring the publisher to be a validated holder
527
     * of the given tier's role (maintainer or member) in the target space.
528
     * Owns its own AccountState resolution so ?publisher is bound through the
529
     * targeted (pkh → agent) lookup rather than enumerated.
530
     */
531
    private static String publisherIsTieredRole(IRI tierClass) {
532
        return """
×
533
                ?acct a npa:AccountState ;
534
                      npa:pubkey ?pkh ;
535
                      npa:agent  ?publisher .
536
                ?tierRI a gen:RoleInstantiation ;
537
                        npa:forSpace ?space ;
538
                        npa:forAgent ?publisher .
539
                ?rdT a npa:RoleDeclaration ;
540
                     npa:hasRoleType <%1$s> .
541
                { ?tierRI npa:regularProperty ?predT . ?rdT gen:hasRegularProperty ?predT . }
542
                UNION
543
                { ?tierRI npa:inverseProperty ?predT . ?rdT gen:hasInverseProperty ?predT . }
544
                """.formatted(tierClass);
×
545
    }
546

547
    /** Wraps {@link #runTierLoop} with tier-name context for logs/exceptions. */
548
    private int runTierLabeled(String tier, IRI graph, String sparqlUpdate) {
549
        try {
550
            return runTierLoop(graph, sparqlUpdate);
×
551
        } catch (RuntimeException ex) {
×
552
            log.error("AuthorityResolver: tier={} failed with SPARQL UPDATE:\n{}\n", tier, sparqlUpdate, ex);
×
553
            throw ex;
×
554
        }
555
    }
556

557
    /**
558
     * Runs a single tier's INSERT to fixed point. Counts rows by probing
559
     * graph size before/after each INSERT; stops when the size doesn't change.
560
     *
561
     * @return total number of triples inserted by this tier across all iterations
562
     */
563
    int runTierLoop(IRI graph, String sparqlUpdate) {
564
        int total = 0;
×
565
        long before = graphSize(graph);
×
566
        while (true) {
567
            // Note: no explicit transaction wrapping here. In tests we observed that
568
            // HTTPRepository's RDF4J-transaction protocol silently no-op'd cross-graph
569
            // SPARQL UPDATEs with UNION sub-patterns inside conn.begin()/commit(),
570
            // while the same UPDATE POSTed directly to /statements applied correctly.
571
            // A bare prepareUpdate().execute() takes the direct /statements path and
572
            // runs the UPDATE atomically per SPARQL 1.1 semantics — which is all we
573
            // need; there's nothing else to commit atomically alongside the UPDATE.
574
            try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
575
                conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
576
            }
577
            long after = graphSize(graph);
×
578
            long added = after - before;
×
579
            if (added <= 0) break;
×
580
            total += added;
×
581
            before = after;
×
582
        }
×
583
        return total;
×
584
    }
585

586
    private long graphSize(IRI graph) {
587
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
588
            return conn.size(graph);
×
589
        }
590
    }
591

592
    /**
593
     * Distinct-subject totals in the given space-state graph, broken down by
594
     * RoleInstantiation kind (admin-pinned vs not) and RoleAssignment.
595
     * Three SELECT-COUNT queries — cheap, called once per build/cycle for
596
     * the user-facing log line. Returns zeros on failure (logged) so a flaky
597
     * count read can't wedge the cycle.
598
     */
599
    TierSubjectTotals computeTierSubjectTotals(IRI graph) {
600
        long adminRIs       = countDistinctSubjects(graph, """
×
601
                ?ri a gen:RoleInstantiation ; npa:inverseProperty gen:hasAdmin .
602
                """, "ri");
603
        long attachmentRAs  = countDistinctSubjects(graph, """
×
604
                ?ra a gen:RoleAssignment .
605
                """, "ra");
606
        long nonAdminRIs    = countDistinctSubjects(graph, """
×
607
                ?ri a gen:RoleInstantiation .
608
                FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
609
                """, "ri");
610
        return new TierSubjectTotals(adminRIs, attachmentRAs, nonAdminRIs);
×
611
    }
612

613
    private long countDistinctSubjects(IRI graph, String wherePattern, String varName) {
614
        String query = String.format("""
×
615
                PREFIX npa: <%1$s>
616
                PREFIX gen: <%2$s>
617
                SELECT (COUNT(DISTINCT ?%3$s) AS ?n) WHERE {
618
                  GRAPH <%4$s> {
619
                    %5$s
620
                  }
621
                }
622
                """, NPA.NAMESPACE, GEN.NAMESPACE, varName, graph, wherePattern);
623
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
×
624
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
625
            if (!r.hasNext()) return 0;
×
626
            return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
×
627
        } catch (Exception ex) {
×
628
            log.warn("AuthorityResolver: countDistinctSubjects on {} failed: {}",
×
629
                    graph, ex.toString());
×
630
            return 0;
×
631
        }
632
    }
633

634
    // ---------------- SPARQL templates ----------------
635

636
    /**
637
     * Reusable invalidation filter on a bound nanopub-IRI variable. Pass the bare
638
     * variable name (no leading {@code ?}); e.g. {@code invalidationFilter("np")}
639
     * produces an outer-scoped {@code FILTER NOT EXISTS { GRAPH npa:spacesGraph
640
     * { ?_inv_np a npa:Invalidation ; npa:invalidates ?np . } }}.
641
     *
642
     * <p>Important: this filter must be placed OUTSIDE the surrounding
643
     * {@code GRAPH npa:spacesGraph { ... }} block, not nested inside it. When
644
     * nested, RDF4J's planner couples the FILTER NOT EXISTS evaluation into the
645
     * join order (per-row scan of {@code ?_inv a npa:Invalidation} multiplied by
646
     * the candidate set), which we measured turning a 39ms query into a 60s+
647
     * timeout on the live observer-tier data. Outside the GRAPH block, the
648
     * planner defers the filter until {@code ?np}/{@code ?rdNp} are bound and
649
     * does a targeted index lookup.
650
     *
651
     * <p>Variable names must match {@code [A-Za-z0-9_]+} per SPARQL grammar —
652
     * embedding a {@code ?} inside {@code ?_inv_?np} would yield a parse error.
653
     */
654
    private static String invalidationFilter(String bareVarName) {
655
        return "FILTER NOT EXISTS { GRAPH <" + SpacesVocab.SPACES_GRAPH + "> {"
30✔
656
                + " ?_inv_" + bareVarName
657
                + " a <" + SpacesVocab.INVALIDATION + "> ; "
658
                + "<" + SpacesVocab.INVALIDATES + "> ?" + bareVarName + " . } }";
659
    }
660

661
    /**
662
     * Admin tier: seed from {@code npadef:...hasRootAdmin} (trusted by construction)
663
     * plus closed-over admin grants; insert any {@code gen:RoleInstantiation} with
664
     * {@code npa:inverseProperty gen:hasAdmin} whose publisher (resolved via mirrored
665
     * trust-approved AccountState) is already in the admin set.
666
     */
667
    static String adminTierUpdate(IRI graph, long lastProcessed) {
668
        // Order tuned for RDF4J's evaluator:
669
        //   1. Anchor on the small (seed UNION closed-over) set to bind ?publisher
670
        //      and ?space cheaply.
671
        //   2. Resolve ?pkh from the mirrored AccountState row (?publisher bound).
672
        //   3. Probe instantiations using the now-bound (?space, ?pkh) — targeted
673
        //      lookup, not a full RoleInstantiation scan.
674
        //   4. Load-number filter on bound ?np.
675
        //   5. Dedup at the end.
676
        return """
69✔
677
                PREFIX npa:  <%1$s>
678
                PREFIX gen:  <%2$s>
679
                INSERT { GRAPH <%3$s> {
680
                  ?ri a gen:RoleInstantiation ;
681
                      npa:forSpace ?space ;
682
                      npa:inverseProperty gen:hasAdmin ;
683
                      npa:forAgent ?agent ;
684
                      npa:viaNanopub ?np .
685
                } }
686
                WHERE {
687
                  # 1. Anchor: who is already an admin of which space?
688
                  {
689
                    # Seed branch: root-admin in a non-invalidated SpaceDefinition.
690
                    GRAPH <%4$s> {
691
                      ?def a npa:SpaceDefinition ;
692
                           npa:forSpaceRef  ?spaceRef ;
693
                           npa:hasRootAdmin ?publisher ;
694
                           npa:viaNanopub   ?defNp .
695
                      ?spaceRef npa:spaceIri ?space .
696
                    }
697
                    %7$s
698
                  }
699
                  UNION
700
                  {
701
                    # Closed-over branch: an existing admin in this space-state graph.
702
                    GRAPH <%3$s> {
703
                      ?prev a gen:RoleInstantiation ;
704
                            npa:forSpace        ?space ;
705
                            npa:inverseProperty gen:hasAdmin ;
706
                            npa:forAgent        ?publisher .
707
                    }
708
                  }
709
                  # 2. Mirror: resolve ?publisher → ?pkh via the trust-approved row.
710
                  GRAPH <%3$s> {
711
                    ?acct a npa:AccountState ;
712
                          npa:agent  ?publisher ;
713
                          npa:pubkey ?pkh .
714
                  }
715
                  # 3. Targeted instantiation lookup by space + pubkey.
716
                  GRAPH <%4$s> {
717
                    ?ri a gen:RoleInstantiation ;
718
                        npa:forSpace        ?space ;
719
                        npa:inverseProperty gen:hasAdmin ;
720
                        npa:forAgent        ?agent ;
721
                        npa:pubkeyHash      ?pkh ;
722
                        npa:viaNanopub      ?np .
723
                  }
724
                  %6$s
725
                  # 4. Load-number filter on bound ?np.
726
                  GRAPH <%8$s> {
727
                    ?np npa:hasLoadNumber ?ln .
728
                    FILTER (?ln > %5$d)
729
                  }
730
                  # 5. Dedup last.
731
                  FILTER NOT EXISTS { GRAPH <%3$s> {
732
                    ?existing a gen:RoleInstantiation ;
733
                              npa:forSpace ?space ;
734
                              npa:forAgent ?agent ;
735
                              npa:inverseProperty gen:hasAdmin .
736
                  } }
737
                }
738
                """.formatted(
3✔
739
                NPA.NAMESPACE,
740
                GEN.NAMESPACE,
741
                graph,
742
                SpacesVocab.SPACES_GRAPH,
743
                lastProcessed,
15✔
744
                invalidationFilter("np"),
15✔
745
                invalidationFilter("defNp"),
18✔
746
                NPA.GRAPH);
747
    }
748

749
    /**
750
     * {@code gen:hasRole} attachment validation: an attachment is validated iff its
751
     * publisher is already a validated admin of the target space. Adds
752
     * {@code gen:RoleAssignment} rows to the space-state graph.
753
     */
754
    static String attachmentValidationUpdate(IRI graph, long lastProcessed) {
755
        return """
69✔
756
                PREFIX npa:  <%1$s>
757
                PREFIX gen:  <%2$s>
758
                INSERT { GRAPH <%3$s> {
759
                  ?ra a gen:RoleAssignment ;
760
                      npa:forSpace ?space ;
761
                      gen:hasRole  ?role ;
762
                      npa:viaNanopub ?np .
763
                } }
764
                WHERE {
765
                  GRAPH <%4$s> {
766
                    ?ra a gen:RoleAssignment ;
767
                        npa:forSpace ?space ;
768
                        gen:hasRole  ?role ;
769
                        npa:pubkeyHash ?pkh ;
770
                        npa:viaNanopub ?np .
771
                  }
772
                  GRAPH <%7$s> {
773
                    ?np npa:hasLoadNumber ?ln .
774
                    FILTER (?ln > %5$d)
775
                  }
776
                  GRAPH <%3$s> {
777
                    ?acct a npa:AccountState ;
778
                          npa:agent  ?publisher ;
779
                          npa:pubkey ?pkh .
780
                    ?adminRI a gen:RoleInstantiation ;
781
                             npa:forSpace ?space ;
782
                             npa:inverseProperty gen:hasAdmin ;
783
                             npa:forAgent ?publisher .
784
                  }
785
                  %6$s
786
                  FILTER NOT EXISTS { GRAPH <%3$s> {
787
                    ?existing a gen:RoleAssignment ;
788
                              npa:forSpace ?space ;
789
                              gen:hasRole  ?role .
790
                  } }
791
                }
792
                """.formatted(
3✔
793
                NPA.NAMESPACE,
794
                GEN.NAMESPACE,
795
                graph,
796
                SpacesVocab.SPACES_GRAPH,
797
                lastProcessed,
15✔
798
                invalidationFilter("np"),
18✔
799
                NPA.GRAPH);
800
    }
801

802
    /**
803
     * Non-admin tier publisher constraints (inserted as a SPARQL sub-pattern).
804
     * Each constraint owns the AccountState (pkh → agent) lookup so the join
805
     * variable is bound through a targeted pattern. The observer-self variant
806
     * binds {@code npa:agent ?agent} directly — no separate {@code ?publisher}
807
     * variable, no post-join equality filter — which lets the planner anchor
808
     * the AccountState lookup on the already-bound {@code ?agent} instead of
809
     * enumerating all approved publishers and filtering at the end.
810
     */
811
    static final String PUBLISHER_IS_ADMIN = """
812
            ?acct a npa:AccountState ;
813
                  npa:pubkey ?pkh ;
814
                  npa:agent  ?publisher .
815
            ?adminRI a gen:RoleInstantiation ;
816
                     npa:forSpace ?space ;
817
                     npa:inverseProperty gen:hasAdmin ;
818
                     npa:forAgent ?publisher .
819
            """;
820

821
    /** Observer self-evidence: the assignee's own pubkey signed the instantiation. */
822
    static final String PUBLISHER_IS_SELF = """
823
            ?acct a npa:AccountState ;
824
                  npa:pubkey ?pkh ;
825
                  npa:agent  ?agent .
826
            """;
827

828
    /**
829
     * Maintainer / Member / Observer tier INSERT. Same shape: find an instantiation
830
     * whose predicate matches a RoleDeclaration of the given tier attached to the
831
     * target space, and whose publisher passes the tier-specific constraint.
832
     */
833
    static String nonAdminTierUpdate(IRI graph, long lastProcessed,
834
                                     IRI tierClass, String publisherConstraint) {
835
        // Order tuned for RDF4J's evaluator (which executes BGPs roughly in order).
836
        // The crucial choice is the *anchor*: instantiation-first plans send the
837
        // planner exploring the full ~thousands of candidate RIs and only filter
838
        // by tier at the very end. Attachment-first anchors on the small set of
839
        // gen:RoleAssignment rows already validated in this space-state graph
840
        // (~hundreds, often zero) and walks outward by bound (?role, ?space).
841
        //
842
        //   1. Anchor on RoleAssignments in this space-state graph (small).
843
        //   2. Match the tier-pinned RoleDeclaration by ?role.
844
        //   3. Pair role-decl direction to instantiation direction in one UNION
845
        //      so only (reg, reg)/(inv, inv) combos are explored.
846
        //   4. Targeted instantiation lookup — (?space, ?pred) are bound.
847
        //   5. Publisher constraint (incl. AccountState resolution).
848
        //   6. Load-number filter on bound ?np.
849
        //   7. Dedup at the end.
850
        return """
69✔
851
                PREFIX npa:  <%1$s>
852
                PREFIX gen:  <%2$s>
853
                INSERT { GRAPH <%3$s> {
854
                  ?ri a gen:RoleInstantiation ;
855
                      npa:forSpace ?space ;
856
                      npa:forAgent ?agent ;
857
                      npa:viaNanopub ?np .
858
                } }
859
                WHERE {
860
                  # 1. Anchor: validated attachments in this space-state graph.
861
                  GRAPH <%3$s> {
862
                    ?ra a gen:RoleAssignment ;
863
                        gen:hasRole  ?role ;
864
                        npa:forSpace ?space .
865
                  }
866
                  # 2. Tier-pinned RoleDeclaration (?role bound from the attachment).
867
                  GRAPH <%4$s> {
868
                    ?rd a npa:RoleDeclaration ;
869
                        npa:hasRoleType <%7$s> ;
870
                        npa:role        ?role ;
871
                        npa:viaNanopub  ?rdNp .
872
                    # 3. Pair direction so only matching combos are explored.
873
                    {
874
                      ?rd gen:hasRegularProperty ?pred .
875
                      ?ri npa:regularProperty    ?pred .
876
                    }
877
                    UNION
878
                    {
879
                      ?rd gen:hasInverseProperty ?pred .
880
                      ?ri npa:inverseProperty    ?pred .
881
                    }
882
                    # 4. Targeted instantiation lookup — (?space, ?pred) bound.
883
                    ?ri a gen:RoleInstantiation ;
884
                        npa:forSpace   ?space ;
885
                        npa:forAgent   ?agent ;
886
                        npa:pubkeyHash ?pkh ;
887
                        npa:viaNanopub ?np .
888
                  }
889
                  # 5. Publisher constraint (incl. AccountState resolution).
890
                  GRAPH <%3$s> {
891
                    %9$s
892
                  }
893
                  # 6. Load-number filter on bound ?np.
894
                  GRAPH <%10$s> {
895
                    ?np npa:hasLoadNumber ?ln .
896
                    FILTER (?ln > %5$d)
897
                  }
898
                  # 7. Invalidation filters — outside the GRAPH block so the
899
                  #    planner defers them until ?rdNp/?np are bound.
900
                  %8$s
901
                  %6$s
902
                  # 8. Dedup last.
903
                  FILTER NOT EXISTS { GRAPH <%3$s> {
904
                    ?existing a gen:RoleInstantiation ;
905
                              npa:forSpace ?space ;
906
                              npa:forAgent ?agent ;
907
                              npa:viaNanopub ?np .
908
                  } }
909
                }
910
                """.formatted(
3✔
911
                NPA.NAMESPACE,
912
                GEN.NAMESPACE,
913
                graph,
914
                SpacesVocab.SPACES_GRAPH,
915
                lastProcessed,
15✔
916
                invalidationFilter("np"),
27✔
917
                tierClass,
918
                invalidationFilter("rdNp"),
30✔
919
                publisherConstraint,
920
                NPA.GRAPH);
921
    }
922

923
    /**
924
     * Sub-space admit pass. Copies validated {@code npa:SubSpaceDeclaration}
925
     * extraction rows into the space-state graph (preserving the {@code npasub:}
926
     * subject) and emits convenience {@code <child> npa:isSubSpaceOf <parent>} and
927
     * {@code <parent> npa:hasSubSpace <child>} direct triples. Two satisfaction
928
     * modes joined by UNION:
929
     * <ul>
930
     *   <li>Mode A — the declaration's publisher is a validated admin of both the
931
     *       child and the parent space.</li>
932
     *   <li>Mode B — a different non-invalidated declaration for the same
933
     *       {@code (child, parent)} pair exists, and the two publishers between
934
     *       them cover both admin sides (i.e. one of them is admin of the child,
935
     *       one of them is admin of the parent — possibly the same one twice if
936
     *       both happen to be admin of both).</li>
937
     * </ul>
938
     *
939
     * <p>Mode-B late-arrival: when only the partner declaration is new in this
940
     * cycle (the primary is older than {@code lastProcessed}), the load-number
941
     * filter on {@code ?np} excludes the candidate. The late-arrival sweep
942
     * ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass without the
943
     * load filter and catches it.
944
     */
945
    static String subSpaceAdmitUpdate(IRI graph, long lastProcessed) {
946
        return """
69✔
947
                PREFIX npa: <%1$s>
948
                PREFIX gen: <%2$s>
949
                INSERT { GRAPH <%3$s> {
950
                  ?d a npa:SubSpaceDeclaration ;
951
                     npa:childSpace  ?child ;
952
                     npa:parentSpace ?parent ;
953
                     npa:viaNanopub  ?np .
954
                  ?child  npa:isSubSpaceOf ?parent .
955
                  ?parent npa:hasSubSpace  ?child  .
956
                } }
957
                WHERE {
958
                  # 1. Anchor: candidate declarations from the extraction graph.
959
                  GRAPH <%4$s> {
960
                    ?d a npa:SubSpaceDeclaration ;
961
                       npa:childSpace  ?child ;
962
                       npa:parentSpace ?parent ;
963
                       npa:pubkeyHash  ?pkh ;
964
                       npa:viaNanopub  ?np .
965
                  }
966
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
967
                  GRAPH <%3$s> {
968
                    ?acct a npa:AccountState ;
969
                          npa:pubkey ?pkh ;
970
                          npa:agent  ?publisher .
971
                  }
972
                  # 3. Authority gate.
973
                  {
974
                    # Mode A — publisher is admin of BOTH child and parent.
975
                    FILTER EXISTS { GRAPH <%3$s> {
976
                      ?riC a gen:RoleInstantiation ;
977
                           npa:inverseProperty gen:hasAdmin ;
978
                           npa:forSpace ?child ;
979
                           npa:forAgent ?publisher .
980
                    } }
981
                    FILTER EXISTS { GRAPH <%3$s> {
982
                      ?riP a gen:RoleInstantiation ;
983
                           npa:inverseProperty gen:hasAdmin ;
984
                           npa:forSpace ?parent ;
985
                           npa:forAgent ?publisher .
986
                    } }
987
                  }
988
                  UNION
989
                  {
990
                    # Mode B — co-declaration whose publisher covers the side this
991
                    # one's publisher doesn't. Between {publisher, publisher2},
992
                    # both admin sides must be covered.
993
                    GRAPH <%4$s> {
994
                      ?d2 a npa:SubSpaceDeclaration ;
995
                          npa:childSpace  ?child ;
996
                          npa:parentSpace ?parent ;
997
                          npa:pubkeyHash  ?pkh2 ;
998
                          npa:viaNanopub  ?np2 .
999
                      FILTER (?np2 != ?np)
1000
                    }
1001
                    %8$s
1002
                    GRAPH <%3$s> {
1003
                      ?acct2 a npa:AccountState ;
1004
                             npa:pubkey ?pkh2 ;
1005
                             npa:agent  ?publisher2 .
1006
                    }
1007
                    FILTER EXISTS { GRAPH <%3$s> {
1008
                      ?riA a gen:RoleInstantiation ;
1009
                           npa:inverseProperty gen:hasAdmin ;
1010
                           npa:forSpace ?child .
1011
                      { ?riA npa:forAgent ?publisher } UNION { ?riA npa:forAgent ?publisher2 }
1012
                    } }
1013
                    FILTER EXISTS { GRAPH <%3$s> {
1014
                      ?riB a gen:RoleInstantiation ;
1015
                           npa:inverseProperty gen:hasAdmin ;
1016
                           npa:forSpace ?parent .
1017
                      { ?riB npa:forAgent ?publisher } UNION { ?riB npa:forAgent ?publisher2 }
1018
                    } }
1019
                  }
1020
                  # 4. Invalidation filter on the primary declaration's nanopub.
1021
                  %6$s
1022
                  # 5. Load-number filter on bound ?np.
1023
                  GRAPH <%7$s> {
1024
                    ?np npa:hasLoadNumber ?ln .
1025
                    FILTER (?ln > %5$d)
1026
                  }
1027
                  # 6. Dedup last.
1028
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1029
                    ?d a npa:SubSpaceDeclaration .
1030
                  } }
1031
                }
1032
                """.formatted(
3✔
1033
                NPA.NAMESPACE,
1034
                GEN.NAMESPACE,
1035
                graph,
1036
                SpacesVocab.SPACES_GRAPH,
1037
                lastProcessed,
15✔
1038
                invalidationFilter("np"),
27✔
1039
                NPA.GRAPH,
1040
                invalidationFilter("np2"));
6✔
1041
    }
1042

1043
    /**
1044
     * URL-prefix sub-space fallback admit pass. For every pair of {@code SpaceRef}
1045
     * aggregates where the child's {@code npa:hasIdPrefix} matches the parent's
1046
     * {@code npa:spaceIri}, emits convenience {@code <child> npa:isSubSpaceOf <parent>}
1047
     * and {@code <parent> npa:hasSubSpace <child>} direct triples plus a reified
1048
     * {@code npa:DerivedSubSpaceLink} tag carrying {@code npa:derivationKind
1049
     * npa:byUrlPrefix} so consumers can hide derived edges.
1050
     *
1051
     * <p>Per-child suppression: any validated {@code npa:SubSpaceDeclaration} on the
1052
     * child in {@code npass:<…>} suppresses every fallback edge for that child.
1053
     * Suppression checks the validated set (not raw extraction-graph declarations)
1054
     * so an unapproved or in-flight Mode B declaration doesn't silently hide both
1055
     * the URL-prefix fallback and the (still-invalid) explicit relation.
1056
     *
1057
     * <p>Run order: must run after {@link #subSpaceAdmitUpdate} commits in the
1058
     * same cycle so the suppression check sees this cycle's freshly-validated
1059
     * declarations.
1060
     *
1061
     * <p>No load-number filter: the fallback depends on which Spaces exist (parent
1062
     * + child {@code SpaceRef}s), not on which were just added. Always full-scan;
1063
     * the dedup {@code FILTER NOT EXISTS} on the tag IRI prevents re-insertion.
1064
     *
1065
     * <p>No invalidation handling: derived edges have no source nanopub. Two
1066
     * staleness modes: (a) child later gets first validated declaration → old
1067
     * derived edges stay sticky until the next periodic rebuild (same policy as
1068
     * admin-RI invalidation); (b) child loses last validated declaration → the
1069
     * regular fallback pass on the next cycle re-engages, adds derived edges
1070
     * incrementally, no rebuild needed.
1071
     */
1072
    static String subSpacePrefixFallbackUpdate(IRI graph) {
1073
        return """
48✔
1074
                PREFIX npa: <%1$s>
1075
                INSERT { GRAPH <%2$s> {
1076
                  ?child  npa:isSubSpaceOf ?parent .
1077
                  ?parent npa:hasSubSpace  ?child  .
1078
                  ?tagIri a npa:DerivedSubSpaceLink ;
1079
                          npa:childSpace     ?child ;
1080
                          npa:parentSpace    ?parent ;
1081
                          npa:derivationKind npa:byUrlPrefix .
1082
                } }
1083
                WHERE {
1084
                  # 1. Anchor: child SpaceRef → its path-prefixes (extracted at load
1085
                  #    time from the Space IRI; see SpacesExtractor.enumerateIdPrefixes).
1086
                  GRAPH <%3$s> {
1087
                    ?childRef  npa:spaceIri    ?child ;
1088
                               npa:hasIdPrefix ?parent .
1089
                    # 2. Parent SpaceRef must exist for the same IRI as the prefix.
1090
                    ?parentRef npa:spaceIri    ?parent .
1091
                  }
1092
                  # 3. Suppress fallback for any child that has a validated declaration
1093
                  #    in this state graph. Per-child, all-or-nothing.
1094
                  FILTER NOT EXISTS {
1095
                    GRAPH <%2$s> {
1096
                      ?d a npa:SubSpaceDeclaration ;
1097
                         npa:childSpace ?child .
1098
                    }
1099
                  }
1100
                  # 4. Mint a deterministic tag IRI per (child, parent).
1101
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/derivedlink/",
1102
                                  MD5(CONCAT(STR(?child), "|", STR(?parent))))) AS ?tagIri)
1103
                  # 5. Dedup: don't re-insert if this tag is already present.
1104
                  FILTER NOT EXISTS {
1105
                    GRAPH <%2$s> {
1106
                      ?tagIri a npa:DerivedSubSpaceLink .
1107
                    }
1108
                  }
1109
                }
1110
                """.formatted(
3✔
1111
                NPA.NAMESPACE,
1112
                graph,
1113
                SpacesVocab.SPACES_GRAPH);
1114
    }
1115

1116
    // ---------------- Invalidation templates (incremental cycle) ----------------
1117

1118
    /**
1119
     * WHERE clause shared by the admin-RI invalidation ASK precheck and the
1120
     * matching DELETE. Identifies admin-tier {@code gen:RoleInstantiation} rows
1121
     * in the space-state graph whose {@code npa:viaNanopub} equals the target
1122
     * of an {@code npa:Invalidation} that landed in {@code (lastProcessed, ∞)}.
1123
     */
1124
    static String adminInvalidationCheckWhere(IRI graph, long lastProcessed) {
1125
        return String.format("""
60✔
1126
                  GRAPH <%1$s> {
1127
                    ?ri a gen:RoleInstantiation ;
1128
                        npa:inverseProperty gen:hasAdmin ;
1129
                        npa:viaNanopub ?np .
1130
                  }
1131
                  GRAPH <%2$s> {
1132
                    ?inv a npa:Invalidation ;
1133
                         npa:invalidates ?np ;
1134
                         npa:viaNanopub  ?invNp .
1135
                  }
1136
                  GRAPH <%3$s> {
1137
                    ?invNp npa:hasLoadNumber ?ln .
1138
                    FILTER (?ln > %4$d)
1139
                  }
1140
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
1141
    }
1142

1143
    /** DELETE template for admin-tier RoleInstantiations whose source nanopub was invalidated. */
1144
    static String adminInvalidationDelete(IRI graph, long lastProcessed) {
1145
        return String.format("""
63✔
1146
                PREFIX npa: <%1$s>
1147
                PREFIX gen: <%2$s>
1148
                DELETE { GRAPH <%3$s> {
1149
                  ?ri ?p ?o .
1150
                } }
1151
                WHERE {
1152
                  GRAPH <%3$s> { ?ri ?p ?o . }
1153
                %4$s
1154
                }
1155
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1156
                adminInvalidationCheckWhere(graph, lastProcessed));
6✔
1157
    }
1158

1159
    /** WHERE clause for RoleAssignment invalidation. */
1160
    static String roleAssignmentInvalidationCheckWhere(IRI graph, long lastProcessed) {
1161
        return String.format("""
60✔
1162
                  GRAPH <%1$s> {
1163
                    ?ra a gen:RoleAssignment ;
1164
                        npa:viaNanopub ?np .
1165
                  }
1166
                  GRAPH <%2$s> {
1167
                    ?inv a npa:Invalidation ;
1168
                         npa:invalidates ?np ;
1169
                         npa:viaNanopub  ?invNp .
1170
                  }
1171
                  GRAPH <%3$s> {
1172
                    ?invNp npa:hasLoadNumber ?ln .
1173
                    FILTER (?ln > %4$d)
1174
                  }
1175
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
1176
    }
1177

1178
    /** DELETE template for RoleAssignments whose source nanopub was invalidated. */
1179
    static String roleAssignmentInvalidationDelete(IRI graph, long lastProcessed) {
1180
        return String.format("""
63✔
1181
                PREFIX npa: <%1$s>
1182
                PREFIX gen: <%2$s>
1183
                DELETE { GRAPH <%3$s> {
1184
                  ?ra ?p ?o .
1185
                } }
1186
                WHERE {
1187
                  GRAPH <%3$s> { ?ra ?p ?o . }
1188
                %4$s
1189
                }
1190
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1191
                roleAssignmentInvalidationCheckWhere(graph, lastProcessed));
6✔
1192
    }
1193

1194
    /**
1195
     * WHERE clause for RoleDeclaration invalidation. ASK-only (no DELETE):
1196
     * RoleDeclarations live in {@code npa:spacesGraph} and aren't materialized
1197
     * into the space-state graph, so there's nothing to remove from the
1198
     * space-state. The ASK still flips {@code npa:needsFullRebuild} because
1199
     * sticky downstream RIs that were derived under the now-invalidated RD
1200
     * need a from-scratch recompute.
1201
     */
1202
    static String roleDeclarationInvalidationCheckWhere(long lastProcessed) {
1203
        return String.format("""
48✔
1204
                  GRAPH <%1$s> {
1205
                    ?rd a npa:RoleDeclaration ;
1206
                        npa:viaNanopub ?np .
1207
                    ?inv a npa:Invalidation ;
1208
                         npa:invalidates ?np ;
1209
                         npa:viaNanopub  ?invNp .
1210
                  }
1211
                  GRAPH <%2$s> {
1212
                    ?invNp npa:hasLoadNumber ?ln .
1213
                    FILTER (?ln > %3$d)
1214
                  }
1215
                """, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
1216
    }
1217

1218
    /**
1219
     * DELETE template for non-admin (leaf-tier) RoleInstantiations whose source
1220
     * nanopub was invalidated. Identified as {@code gen:RoleInstantiation} rows
1221
     * lacking the admin-pinning {@code npa:inverseProperty gen:hasAdmin} triple.
1222
     * No flag is set; leaf-tier removals are recoverable on the next cycle.
1223
     */
1224
    static String leafTierInvalidationDelete(IRI graph, long lastProcessed) {
1225
        return String.format("""
84✔
1226
                PREFIX npa: <%1$s>
1227
                PREFIX gen: <%2$s>
1228
                DELETE { GRAPH <%3$s> {
1229
                  ?ri ?p ?o .
1230
                } }
1231
                WHERE {
1232
                  GRAPH <%3$s> {
1233
                    ?ri a gen:RoleInstantiation ;
1234
                        npa:viaNanopub ?np .
1235
                    FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
1236
                    ?ri ?p ?o .
1237
                  }
1238
                  GRAPH <%4$s> {
1239
                    ?inv a npa:Invalidation ;
1240
                         npa:invalidates ?np ;
1241
                         npa:viaNanopub  ?invNp .
1242
                  }
1243
                  GRAPH <%5$s> {
1244
                    ?invNp npa:hasLoadNumber ?ln .
1245
                    FILTER (?ln > %6$d)
1246
                  }
1247
                }
1248
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1249
                SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
1250
    }
1251

1252
    /**
1253
     * WHERE clause shared by the sub-space invalidation ASK precheck and the
1254
     * matching DELETE. Identifies validated {@code npa:SubSpaceDeclaration} rows
1255
     * in the space-state graph whose {@code npa:viaNanopub} equals the target of
1256
     * an {@code npa:Invalidation} that landed in {@code (lastProcessed, ∞)}.
1257
     */
1258
    static String subSpaceInvalidationCheckWhere(IRI graph, long lastProcessed) {
1259
        return String.format("""
60✔
1260
                  GRAPH <%1$s> {
1261
                    ?d a npa:SubSpaceDeclaration ;
1262
                       npa:viaNanopub ?np .
1263
                  }
1264
                  GRAPH <%2$s> {
1265
                    ?inv a npa:Invalidation ;
1266
                         npa:invalidates ?np ;
1267
                         npa:viaNanopub  ?invNp .
1268
                  }
1269
                  GRAPH <%3$s> {
1270
                    ?invNp npa:hasLoadNumber ?ln .
1271
                    FILTER (?ln > %4$d)
1272
                  }
1273
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
1274
    }
1275

1276
    /**
1277
     * DELETE template for validated {@code npa:SubSpaceDeclaration} rows whose
1278
     * source nanopub was invalidated. Removes the per-declaration row by subject;
1279
     * the convenience direct triples ({@code <child> npa:isSubSpaceOf <parent>}
1280
     * and inverse) are left sticky and cleaned by the next periodic full rebuild
1281
     * (same staleness policy as admin-RI invalidation — see {@code
1282
     * doc/design-space-repositories.md} on the structural-rebuild flag).
1283
     */
1284
    static String subSpaceInvalidationDelete(IRI graph, long lastProcessed) {
1285
        return String.format("""
63✔
1286
                PREFIX npa: <%1$s>
1287
                PREFIX gen: <%2$s>
1288
                DELETE { GRAPH <%3$s> {
1289
                  ?d ?p ?o .
1290
                } }
1291
                WHERE {
1292
                  GRAPH <%3$s> { ?d ?p ?o . }
1293
                %4$s
1294
                }
1295
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1296
                subSpaceInvalidationCheckWhere(graph, lastProcessed));
6✔
1297
    }
1298

1299
    /** Wraps an ASK by joining the shared prefixes. */
1300
    private boolean wouldInvalidate(IRI graph, long lastProcessed,
1301
                                    boolean adminPinned, String whereClause) {
1302
        // adminPinned is informational only — kept to make call sites read clearly;
1303
        // the WHERE clause already encodes the kind via its own type predicates.
1304
        String ask = String.format("""
×
1305
                PREFIX npa: <%1$s>
1306
                PREFIX gen: <%2$s>
1307
                ASK { %3$s }
1308
                """, NPA.NAMESPACE, GEN.NAMESPACE, whereClause);
1309
        return runAsk(ask);
×
1310
    }
1311

1312
    private boolean runAsk(String sparql) {
1313
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1314
            return conn.prepareBooleanQuery(QueryLanguage.SPARQL, sparql).evaluate();
×
1315
        }
1316
    }
1317

1318
    private void executeUpdate(String sparqlUpdate) {
1319
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1320
            conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
1321
        }
1322
    }
×
1323

1324
    // ---------------- Mirror step ----------------
1325

1326
    /**
1327
     * Copies trust-approved {@code npa:AccountState} rows from {@code npat:<T>}
1328
     * in the {@code trust} repo into {@code newGraph} in the {@code spaces} repo,
1329
     * inside one spaces-side serializable transaction.
1330
     *
1331
     * @return number of rows mirrored (useful for metrics / logging)
1332
     */
1333
    int mirrorTrustState(String trustStateHash, IRI newGraph) {
1334
        IRI trustStateIri = NPAT.forHash(trustStateHash);
×
1335
        int count = 0;
×
1336
        try (RepositoryConnection trustConn = TripleStore.get().getRepoConnection(TRUST_REPO);
×
1337
             RepositoryConnection spacesConn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1338
            trustConn.begin(IsolationLevels.READ_COMMITTED);
×
1339
            spacesConn.begin(IsolationLevels.SERIALIZABLE);
×
1340
            // Walk rdf:type triples in the trust state's graph; for each AccountState,
1341
            // check status and copy the approved ones verbatim (minus status-specific
1342
            // detail triples, which we don't need for validation).
1343
            try (RepositoryResult<Statement> typeRows = trustConn.getStatements(
×
1344
                    null, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri)) {
1345
                while (typeRows.hasNext()) {
×
1346
                    Statement st = typeRows.next();
×
1347
                    if (!(st.getSubject() instanceof IRI accountStateIri)) continue;
×
1348
                    Value status = trustConn.getStatements(accountStateIri, NPA_TRUST_STATUS, null, trustStateIri)
×
1349
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
1350
                    if (!(status instanceof IRI statusIri) || !APPROVED_SET.contains(statusIri)) continue;
×
1351
                    Value agent = trustConn.getStatements(accountStateIri, NPA_AGENT, null, trustStateIri)
×
1352
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
1353
                    Value pubkey = trustConn.getStatements(accountStateIri, NPA_PUBKEY, null, trustStateIri)
×
1354
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
1355
                    if (agent == null || pubkey == null) {
×
1356
                        log.warn("AuthorityResolver.mirror: account {} missing agent or pubkey; skipping",
×
1357
                                accountStateIri);
1358
                        continue;
×
1359
                    }
1360
                    spacesConn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, newGraph);
×
1361
                    spacesConn.add(accountStateIri, NPA_AGENT, agent, newGraph);
×
1362
                    spacesConn.add(accountStateIri, NPA_PUBKEY, pubkey, newGraph);
×
1363
                    spacesConn.add(accountStateIri, NPA_TRUST_STATUS, statusIri, newGraph);
×
1364
                    count++;
×
1365
                }
×
1366
            }
1367
            // Mirror canonical foaf:name triples for approved agents. The trust
1368
            // loader emits one per agent (across approved keys, MAX(ratio) wins).
1369
            // Copying them into the space-state graph means consumers reading
1370
            // ?agent foaf:name ?n inside the state graph hit local data, with no
1371
            // cross-repo SERVICE.
1372
            try (RepositoryResult<Statement> nameRows = trustConn.getStatements(
×
1373
                    null, FOAF.NAME, null, trustStateIri)) {
1374
                while (nameRows.hasNext()) {
×
1375
                    Statement st = nameRows.next();
×
1376
                    spacesConn.add(st.getSubject(), st.getPredicate(), st.getObject(), newGraph);
×
1377
                }
×
1378
            }
1379
            spacesConn.commit();
×
1380
            trustConn.commit();
×
1381
        }
1382
        return count;
×
1383
    }
1384

1385
    // ---------------- Pointer + counter helpers ----------------
1386

1387
    /**
1388
     * Reads the current {@code npa:hasCurrentSpaceState} pointer from the
1389
     * {@code npa:graph} admin graph of the {@code spaces} repo. Returns
1390
     * {@code null} if no pointer exists yet.
1391
     */
1392
    IRI getCurrentSpaceStateGraph() {
1393
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1394
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
1395
                    SpacesVocab.HAS_CURRENT_SPACE_STATE);
1396
            return (v instanceof IRI iri) ? iri : null;
×
1397
        } catch (Exception ex) {
×
1398
            log.warn("AuthorityResolver: failed to read hasCurrentSpaceState pointer: {}", ex.toString());
×
1399
            return null;
×
1400
        }
1401
    }
1402

1403
    long getCurrentLoadCounter() {
1404
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1405
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
1406
                    SpacesVocab.CURRENT_LOAD_COUNTER);
1407
            if (v == null) return 0;
×
1408
            try {
1409
                return Long.parseLong(v.stringValue());
×
1410
            } catch (NumberFormatException ex) {
×
1411
                log.warn("AuthorityResolver: non-numeric currentLoadCounter: {}", v);
×
1412
                return 0;
×
1413
            }
1414
        } catch (Exception ex) {
×
1415
            log.warn("AuthorityResolver: failed to read currentLoadCounter: {}", ex.toString());
×
1416
            return 0;
×
1417
        }
1418
    }
1419

1420
    /**
1421
     * Atomic pointer flip: a single SPARQL {@code DELETE … INSERT … WHERE}
1422
     * replaces the old pointer with the new one in one statement, so readers
1423
     * never see a zero-pointer window.
1424
     */
1425
    void flipPointer(IRI newGraph) {
1426
        String update = String.format("""
×
1427
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
1428
                INSERT { GRAPH <%s> { <%s> <%s> <%s> } }
1429
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
1430
                """,
1431
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE,
1432
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE, newGraph,
1433
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE);
1434
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1435
            conn.begin(IsolationLevels.SERIALIZABLE);
×
1436
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
1437
            conn.commit();
×
1438
        }
1439
    }
×
1440

1441
    void writeProcessedUpTo(IRI graph, long loadCounter) {
1442
        String update = String.format("""
×
1443
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
1444
                INSERT { GRAPH <%s> { <%s> <%s> "%d"^^<http://www.w3.org/2001/XMLSchema#long> } }
1445
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
1446
                """,
1447
                graph, graph, SpacesVocab.PROCESSED_UP_TO,
1448
                graph, graph, SpacesVocab.PROCESSED_UP_TO, loadCounter,
×
1449
                graph, graph, SpacesVocab.PROCESSED_UP_TO);
1450
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1451
            conn.begin(IsolationLevels.SERIALIZABLE);
×
1452
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
1453
            conn.commit();
×
1454
        }
1455
    }
×
1456

1457
    /**
1458
     * Reads {@code processedUpTo} from the given space-state graph.
1459
     * Returns {@code -1} if absent (graph not fully built yet).
1460
     */
1461
    long readProcessedUpTo(IRI graph) {
1462
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1463
            String query = String.format(
×
1464
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
1465
                    graph, graph, SpacesVocab.PROCESSED_UP_TO);
1466
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
1467
                if (!r.hasNext()) return -1;
×
1468
                BindingSet b = r.next();
×
1469
                return Long.parseLong(b.getBinding("n").getValue().stringValue());
×
1470
            }
×
1471
        } catch (Exception ex) {
×
1472
            log.warn("AuthorityResolver: failed to read processedUpTo for {}: {}", graph, ex.toString());
×
1473
            return -1;
×
1474
        }
1475
    }
1476

1477
    /**
1478
     * Reads the {@code npa:needsFullRebuild} flag (boolean literal) from
1479
     * {@code npa:graph} in the {@code spaces} repo. Defaults to {@code false}
1480
     * when the triple is absent.
1481
     */
1482
    boolean readNeedsFullRebuild() {
1483
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1484
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
1485
                    SpacesVocab.NEEDS_FULL_REBUILD);
1486
            return v != null && Boolean.parseBoolean(v.stringValue());
×
1487
        } catch (Exception ex) {
×
1488
            log.warn("AuthorityResolver: failed to read needsFullRebuild: {}", ex.toString());
×
1489
            return false;
×
1490
        }
1491
    }
1492

1493
    void setNeedsFullRebuild() {
1494
        writeNeedsFullRebuild(true);
×
1495
    }
×
1496

1497
    void clearNeedsFullRebuild() {
1498
        writeNeedsFullRebuild(false);
×
1499
    }
×
1500

1501
    private void writeNeedsFullRebuild(boolean value) {
1502
        String update = String.format("""
×
1503
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
1504
                INSERT { GRAPH <%s> { <%s> <%s> "%s"^^<http://www.w3.org/2001/XMLSchema#boolean> } }
1505
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
1506
                """,
1507
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD,
1508
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD, value,
×
1509
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD);
1510
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1511
            conn.begin(IsolationLevels.SERIALIZABLE);
×
1512
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
1513
            conn.commit();
×
1514
        }
1515
    }
×
1516

1517
    void dropGraph(IRI graph) {
1518
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1519
            conn.begin(IsolationLevels.SERIALIZABLE);
×
1520
            conn.clear(graph);
×
1521
            conn.commit();
×
1522
            log.info("AuthorityResolver: dropped old space-state graph {}", graph);
×
1523
        }
1524
    }
×
1525

1526
    // ---------------- Trust-repo pointer lookup (used by TrustStateRegistry's bootstrap) ----------------
1527

1528
    /**
1529
     * Queries the {@code trust} repo directly for the current trust-state hash.
1530
     * Prefer {@link TrustStateRegistry#getCurrentHash()} in normal operation —
1531
     * this helper exists for tests and diagnostics.
1532
     *
1533
     * @return the current trust-state hash, or empty if none is set
1534
     */
1535
    Optional<String> readTrustRepoCurrentHash() {
1536
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
1537
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
1538
                    NPA_HAS_CURRENT_TRUST_STATE);
1539
            if (!(v instanceof IRI iri)) return Optional.empty();
×
1540
            String s = iri.stringValue();
×
1541
            if (!s.startsWith(NPAT.NAMESPACE)) return Optional.empty();
×
1542
            return Optional.of(s.substring(NPAT.NAMESPACE.length()));
×
1543
        } catch (Exception ex) {
×
1544
            log.warn("AuthorityResolver: failed to read trust-repo current pointer: {}", ex.toString());
×
1545
            return Optional.empty();
×
1546
        }
1547
    }
1548

1549
    private static String abbrev(String hash) {
1550
        return hash.length() > 12 ? hash.substring(0, 12) + "…" : hash;
×
1551
    }
1552

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