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

knowledgepixels / nanopub-query / 27544702113

15 Jun 2026 11:59AM UTC coverage: 61.209% (+1.6%) from 59.58%
27544702113

Pull #121

github

web-flow
Merge 72cdd420f into 7fbb1d638
Pull Request #121: feat(spaces): materialize preset-bundled roles as validated attachments

531 of 964 branches covered (55.08%)

Branch coverage included in aggregate %.

1544 of 2426 relevant lines covered (63.64%)

9.43 hits per line

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

17.24
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 logger = 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
            logger.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
            logger.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
            logger.debug("AuthorityResolver.periodicRebuildTick: no current trust state — deferring");
×
163
            return;
×
164
        }
165
        logger.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
                    logger.info("AuthorityResolver.cleanOrphans: dropped orphan graph {}", iri);
×
195
                }
×
196
            }
197
            if (dropped == 0) {
×
198
                logger.debug("AuthorityResolver.cleanOrphans: no orphan space-state graphs");
×
199
            }
200
        } catch (Exception ex) {
×
201
            logger.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
            logger.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.presetAttachment
×
245
                + counts.attachment + counts.maintainer + counts.member + counts.observer
246
                + counts.subSpace + counts.subSpacePrefix + counts.maintainedResource;
247
        lastFullBuildDurationMs = durationMs;
×
248
        lastProcessedUpToLag = 0L;
×
249
        logger.info("AuthorityResolver: full build complete — graph={} mirrored={} rows loadCounter={} "
×
250
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
251
                        + "(inserted-triples: admin={} alias={} preset-attachment={} 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.presetAttachment, 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
            logger.warn("AuthorityResolver.runIncrementalCycle: missing processedUpTo on {}; skipping",
×
287
                    graph);
288
            return;
×
289
        }
290
        lastProcessedUpToLag = currentLoadCounter - lastProcessed;
×
291
        if (currentLoadCounter <= lastProcessed) {
×
292
            logger.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.presetAttachment > 0)
302
                || (counts.attachment > 0)
303
                || (counts.subSpace > 0)
304
                || newRoleDeclarationsArrived(lastProcessed)
×
305
                || newPresetAssignmentsArrived(lastProcessed);
×
306
        if (structuralAdds) {
×
307
            // Late-arrival sweep: leaf tiers (attachment/maintainer/member/observer)
308
            // can promote candidates whose enabling event arrived in this same cycle.
309
            // Sub-space admit is also re-run here for Mode-B late-arrival (a new
310
            // partner declaration can validate an older primary that the regular
311
            // pass's load-number filter excluded). The URL-prefix fallback also
312
            // re-runs so newly-orphaned children pick up derived edges. Skip the
313
            // admin tier — its only enabling event is the admin grant itself,
314
            // already handled by the regular pass.
315
            TierInsertedTriples lateCounts = runDownstreamWithoutLoadFilter(graph);
×
316
            counts.alias              += lateCounts.alias;
×
317
            counts.presetAttachment   += lateCounts.presetAttachment;
×
318
            counts.attachment         += lateCounts.attachment;
×
319
            counts.maintainer         += lateCounts.maintainer;
×
320
            counts.member             += lateCounts.member;
×
321
            counts.observer           += lateCounts.observer;
×
322
            counts.subSpace           += lateCounts.subSpace;
×
323
            counts.subSpacePrefix     += lateCounts.subSpacePrefix;
×
324
            counts.maintainedResource += lateCounts.maintainedResource;
×
325
        }
326

327
        writeProcessedUpTo(graph, currentLoadCounter);
×
328

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

348
    /**
349
     * Runs the four invalidation-DELETE / ASK steps. Sets {@code npa:needsFullRebuild}
350
     * when admin-RI, RoleAssignment, or RoleDeclaration invalidations matched (the
351
     * three structural kinds). Leaf-tier RI deletes don't set the flag.
352
     *
353
     * @return true iff at least one structural kind was invalidated
354
     */
355
    boolean applyInvalidations(IRI graph, long lastProcessed) {
356
        boolean structural = false;
×
357
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ true,
×
358
                            adminInvalidationCheckWhere(graph, lastProcessed))) {
×
359
            executeUpdate(adminInvalidationDelete(graph, lastProcessed));
×
360
            structural = true;
×
361
        }
362
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
363
                            roleAssignmentInvalidationCheckWhere(graph, lastProcessed))) {
×
364
            executeUpdate(roleAssignmentInvalidationDelete(graph, lastProcessed));
×
365
            structural = true;
×
366
        }
367
        // RoleDeclaration ASK only — RDs aren't materialized into the space-state
368
        // graph, so there's nothing to DELETE here. The flag still flips because
369
        // sticky downstream RIs derived from the now-invalidated RD need a
370
        // from-scratch recompute.
371
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
372
                            roleDeclarationInvalidationCheckWhere(lastProcessed))) {
×
373
            structural = true;
×
374
        }
375
        // Sub-space declarations are structural — invalidating one (Mode A) or one
376
        // of two co-declarations (Mode B) changes the validated parent/child
377
        // topology. The DELETE removes the per-declaration row; the convenience
378
        // direct triples are left sticky and cleaned on the next periodic rebuild.
379
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
380
                            subSpaceInvalidationCheckWhere(graph, lastProcessed))) {
×
381
            executeUpdate(subSpaceInvalidationDelete(graph, lastProcessed));
×
382
            structural = true;
×
383
        }
384
        // Space-alias declarations are structural — invalidating one removes an
385
        // owl:sameAs edge that feeds the admin-authority closure (issue #113). The
386
        // DELETE removes the per-declaration row; the convenience npa:sameAsSpace edge
387
        // is left sticky and cleaned on the next periodic rebuild (same policy as
388
        // sub-space declarations).
389
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
390
                            aliasInvalidationCheckWhere(graph, lastProcessed))) {
×
391
            executeUpdate(aliasInvalidationDelete(graph, lastProcessed));
×
392
            structural = true;
×
393
        }
394
        // Preset-derived RoleAssignment removal (issue #302). NOT npx:invalidates: a newer
395
        // admin-authored same-(preset,resource) assignment supersedes by dct:created (a
396
        // gen:DeactivatedPresetAssignment, or any newer assignment that is no longer active).
397
        // Structural — sticky downstream non-admin RIs derived through a removed attachment
398
        // are bounded by the periodic full rebuild. The DELETE is scoped by
399
        // npa:derivedFromPreset so directly-published gen:hasRole attachments are never
400
        // touched; the §4.3 re-INSERT re-materializes only currently-active pairs in the same
401
        // cycle. See doc/design-preset-role-materialization.md §4.4.
402
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
403
                            presetDeactivationCheckWhere(graph, lastProcessed))) {
×
404
            executeUpdate(presetDeactivationDelete(graph, lastProcessed));
×
405
            structural = true;
×
406
        }
407
        // Leaf-tier RI deletes — no flag.
408
        executeUpdate(leafTierInvalidationDelete(graph, lastProcessed));
×
409
        // Maintained-resource declaration deletes — no flag (leaf relation, no
410
        // downstream caches to bound).
411
        executeUpdate(maintainedResourceInvalidationDelete(graph, lastProcessed));
×
412
        if (structural) setNeedsFullRebuild();
×
413
        return structural;
×
414
    }
415

416
    /**
417
     * Runs the four leaf tiers (attachment/maintainer/member/observer) with
418
     * {@code lastProcessed = -1} so the load-number filter on the candidate
419
     * side admits everything. Dedup filters in the tier templates prevent
420
     * double-insert. Used by the late-arrival sweep.
421
     */
422
    TierInsertedTriples runDownstreamWithoutLoadFilter(IRI graph) {
423
        TierInsertedTriples c = new TierInsertedTriples();
×
424
        // Alias late-arrival: catches alias declarations whose canonical admin grant
425
        // became valid only in this same cycle (the load-number filter on the
426
        // declaration's nanopub would otherwise exclude it). Runs first so the
427
        // attachment / role tiers below see this cycle's fresh npa:sameAsSpace edges.
428
        c.alias = runTierLabeled("alias(late)", graph, aliasAdmitUpdate(graph, -1));
×
429
        // Sub-space late-arrival: catches Mode-B candidates whose primary
430
        // declaration is older than lastProcessed but whose partner just landed.
431
        c.subSpace = runTierLabeled("subspace(late)", graph,
×
432
                subSpaceAdmitUpdate(graph, -1));
×
433
        // Maintained-resource late-arrival: catches declarations that landed
434
        // before the publisher's admin grant became valid in this state.
435
        c.maintainedResource = runTierLabeled("maintained-resource(late)", graph,
×
436
                maintainedResourceAdmitUpdate(graph, -1));
×
437
        // URL-prefix fallback: re-run after the late-arrival sub-space admit so
438
        // any newly-validated children get their fallback edges suppressed (for
439
        // future inserts) and any newly-orphaned children pick up fallback edges.
440
        c.subSpacePrefix = runTierLabeled("subspace-prefix(late)", graph,
×
441
                subSpacePrefixFallbackUpdate(graph));
×
442
        // Preset-attachment late-arrival: catches assignments whose preset declaration or
443
        // admin grant only became valid in this same cycle. Runs before attachment(late)
444
        // so the non-admin late tiers below see this cycle's fresh preset-derived RAs.
445
        c.presetAttachment = runTierLabeled("preset-attachment(late)", graph,
×
446
                presetAttachmentValidationUpdate(graph, -1));
×
447
        c.attachment = runTierLabeled("attachment(late)", graph,
×
448
                attachmentValidationUpdate(graph, -1));
×
449
        c.maintainer = runTierLabeled("maintainer(late)", graph,
×
450
                nonAdminTierUpdate(graph, -1, GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
×
451
        c.member = runTierLabeled("member(admin-pub,late)", graph,
×
452
                nonAdminTierUpdate(graph, -1, GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
×
453
        c.member += runTierLabeled("member(maint-pub,late)", graph,
×
454
                nonAdminTierUpdate(graph, -1,
×
455
                        GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
456
        c.observer = runTierLabeled("observer(admin-pub,late)", graph,
×
457
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
×
458
        c.observer += runTierLabeled("observer(maint-pub,late)", graph,
×
459
                nonAdminTierUpdate(graph, -1,
×
460
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
461
        c.observer += runTierLabeled("observer(member-pub,late)", graph,
×
462
                nonAdminTierUpdate(graph, -1,
×
463
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
×
464
        c.observer += runTierLabeled("observer(self,late)", graph,
×
465
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
×
466
        return c;
×
467
    }
468

469
    /**
470
     * Cheap ASK: did any new {@code npa:RoleDeclaration} extraction land in the
471
     * load-number delta {@code (lastProcessed, ∞)}? Used by the late-arrival
472
     * trigger so an RD that arrives in the same cycle as a matching candidate
473
     * still gets validated.
474
     */
475
    boolean newRoleDeclarationsArrived(long lastProcessed) {
476
        String ask = String.format("""
×
477
                PREFIX npa: <%1$s>
478
                ASK {
479
                  GRAPH <%2$s> {
480
                    ?rd a npa:RoleDeclaration ;
481
                        npa:viaNanopub ?np .
482
                  }
483
                  GRAPH <%3$s> {
484
                    ?np npa:hasLoadNumber ?ln .
485
                    FILTER (?ln > %4$d)
486
                  }
487
                }
488
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
×
489
        return runAsk(ask);
×
490
    }
491

492
    /**
493
     * Cheap ASK: did any new {@code npa:PresetAssignment} or {@code npa:PresetDeclaration}
494
     * extraction land in the load-number delta {@code (lastProcessed, ∞)}? Drives the
495
     * late-arrival re-run so a preset assignment that arrives in the same cycle as its
496
     * declaration (or admin grant) still materializes, and so an arriving newer assignment
497
     * triggers the deactivation/latest-wins re-evaluation.
498
     */
499
    boolean newPresetAssignmentsArrived(long lastProcessed) {
500
        String ask = String.format("""
×
501
                PREFIX npa: <%1$s>
502
                ASK {
503
                  GRAPH <%2$s> {
504
                    ?x a ?t ;
505
                       npa:viaNanopub ?np .
506
                    FILTER (?t = npa:PresetAssignment || ?t = npa:PresetDeclaration)
507
                  }
508
                  GRAPH <%3$s> {
509
                    ?np npa:hasLoadNumber ?ln .
510
                    FILTER (?ln > %4$d)
511
                  }
512
                }
513
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
×
514
        return runAsk(ask);
×
515
    }
516

517
    // ---------------- Tier UPDATE loops ----------------
518

519
    /**
520
     * Per-tier inserted-triple tallies for one build or cycle. Counts the sum
521
     * of {@code (graphSize_after - graphSize_before)} across all iterations of
522
     * each tier's fixed-point INSERT loop — i.e. inserted *triples*, not
523
     * distinct subjects (a single RoleInstantiation insert writes 4–5 triples).
524
     *
525
     * <p>Used internally by the {@link #runIncrementalCycle structuralAdds}
526
     * boolean check (we only care whether any tier inserted at all).
527
     * Not what the log lines report: see {@link TierSubjectTotals} +
528
     * {@link #computeTierSubjectTotals} for the distinct-subject totals
529
     * surfaced to operators.
530
     */
531
    static final class TierInsertedTriples {
×
532
        int admin;
533
        int alias;
534
        int presetAttachment;
535
        int attachment;
536
        int maintainer;
537
        int member;
538
        int observer;
539
        int subSpace;
540
        int subSpacePrefix;
541
        int maintainedResource;
542
    }
543

544
    /**
545
     * Snapshot of distinct-subject totals in a space-state graph at a moment
546
     * in time. Independent of which tier-loop added each subject.
547
     */
548
    record TierSubjectTotals(long adminRIs, long attachmentRAs, long nonAdminRIs) {}
36✔
549

550
    /**
551
     * Runs the five tier loops in order: admin → {@code gen:hasRole} attachment
552
     * validation → maintainer → member → observer. Each loop iterates a SPARQL
553
     * INSERT to fixed point (no new triples added). Returns per-tier counts.
554
     *
555
     * @param graph         target space-state graph
556
     * @param lastProcessed load-number horizon; use {@code -1} for full build
557
     */
558
    TierInsertedTriples runAllTierLoops(IRI graph, long lastProcessed) {
559
        TierInsertedTriples c = new TierInsertedTriples();
×
560
        c.admin = runTierLabeled("admin", graph, adminTierUpdate(graph, lastProcessed));
×
561
        // Alias admit runs after the admin closure has settled (both the authority
562
        // gate and the anti-hijack check read the admin set) and before attachment /
563
        // role tiers (their alias-aware admin lookups consume the npa:sameAsSpace edge
564
        // this pass emits). See issue #113.
565
        c.alias = runTierLabeled("alias", graph, aliasAdmitUpdate(graph, lastProcessed));
×
566
        // Sub-space admit runs after admin closure has settled (Mode A + Mode B both
567
        // need the admin set). Independent of role tiers — order between subspace
568
        // and attachment / maintainer / member / observer doesn't matter.
569
        c.subSpace = runTierLabeled("subspace", graph, subSpaceAdmitUpdate(graph, lastProcessed));
×
570
        // Maintained-resource admit also depends only on the admin closure. Single
571
        // Mode A: publisher must be admin of the maintaining space. No co-declaration
572
        // partner, no URL-prefix fallback.
573
        c.maintainedResource = runTierLabeled("maintained-resource", graph,
×
574
                maintainedResourceAdmitUpdate(graph, lastProcessed));
×
575
        // URL-prefix sub-space fallback runs after the explicit-declaration admit
576
        // pass commits so the per-child suppression check sees this cycle's fresh
577
        // validations. No load filter — depends on which Spaces exist, not on
578
        // delta-arrivals; the dedup FILTER NOT EXISTS prevents re-insertion.
579
        c.subSpacePrefix = runTierLabeled("subspace-prefix", graph,
×
580
                subSpacePrefixFallbackUpdate(graph));
×
581
        // Preset-attachment runs immediately before the regular attachment tier so the
582
        // gen:RoleAssignment rows it materializes (from active, admin-authored preset
583
        // assignments) are picked up by the downstream non-admin tiers in the same pass,
584
        // exactly like directly-published attachments. See
585
        // doc/design-preset-role-materialization.md.
586
        c.presetAttachment = runTierLabeled("preset-attachment", graph,
×
587
                presetAttachmentValidationUpdate(graph, lastProcessed));
×
588
        c.attachment = runTierLabeled("attachment", graph,
×
589
                attachmentValidationUpdate(graph, lastProcessed));
×
590
        c.maintainer = runTierLabeled("maintainer", graph, nonAdminTierUpdate(graph, lastProcessed,
×
591
                GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
592
        // Member tier: admin OR maintainer publisher — split into two simpler updates
593
        // so the query planner doesn't struggle with the UNION.
594
        c.member = runTierLabeled("member(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
595
                GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
596
        c.member += runTierLabeled("member(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
597
                GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
598
        // Observer tier: self-evidence OR a downward grant from any higher tier.
599
        // ObserverRole is the default tier when a role definition omits an
600
        // explicit subclass (see "Role types" in design-space-repositories.md), so
601
        // most "X assigned Y this role" nanopubs land here. Restricting the tier
602
        // to PUBLISHER_IS_SELF would silently drop those grants. The four
603
        // sub-loops mirror the trust-state's downward-only chain: admin grants
604
        // anything; maintainers and members grant observer; everyone may
605
        // self-attest.
606
        c.observer = runTierLabeled("observer(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
607
                GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
608
        c.observer += runTierLabeled("observer(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
609
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
610
        c.observer += runTierLabeled("observer(member-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
611
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
×
612
        c.observer += runTierLabeled("observer(self)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
613
                GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
614
        return c;
×
615
    }
616

617
    /**
618
     * Builds a publisher constraint requiring the publisher to be a validated holder
619
     * of the given tier's role (maintainer or member) in the target space.
620
     * Owns its own AccountState resolution so ?publisher is bound through the
621
     * targeted (pkh → agent) lookup rather than enumerated.
622
     */
623
    private static String publisherIsTieredRole(IRI tierClass) {
624
        // Re-keyed on the assignment's ref (alias → canonical already resolved by the
625
        // attachment tier). Relies on materialized non-admin RIs carrying their role
626
        // property (npa:regularProperty / npa:inverseProperty) — supplied by the
627
        // enrichment in nonAdminTierUpdate; without it this constraint matched nothing.
628
        return """
×
629
                ?acct a npa:AccountState ;
630
                      npa:pubkey ?pkh ;
631
                      npa:agent  ?publisher .
632
                ?tierRI a gen:RoleInstantiation ;
633
                        npa:forSpaceRef ?spaceRef ;
634
                        npa:forAgent ?publisher .
635
                ?rdT a npa:RoleDeclaration ;
636
                     npa:hasRoleType <%1$s> .
637
                { ?tierRI npa:regularProperty ?predT . ?rdT gen:hasRegularProperty ?predT . }
638
                UNION
639
                { ?tierRI npa:inverseProperty ?predT . ?rdT gen:hasInverseProperty ?predT . }
640
                """.formatted(tierClass);
×
641
    }
642

643
    /** Wraps {@link #runTierLoop} with tier-name context for logs/exceptions. */
644
    private int runTierLabeled(String tier, IRI graph, String sparqlUpdate) {
645
        try {
646
            return runTierLoop(graph, sparqlUpdate);
×
647
        } catch (RuntimeException ex) {
×
648
            logger.error("AuthorityResolver: tier={} failed with SPARQL UPDATE:\n{}\n", tier, sparqlUpdate, ex);
×
649
            throw ex;
×
650
        }
651
    }
652

653
    /**
654
     * Runs a single tier's INSERT to fixed point. Counts rows by probing
655
     * graph size before/after each INSERT; stops when the size doesn't change.
656
     *
657
     * @return total number of triples inserted by this tier across all iterations
658
     */
659
    int runTierLoop(IRI graph, String sparqlUpdate) {
660
        int total = 0;
×
661
        long before = graphSize(graph);
×
662
        while (true) {
663
            // Note: no explicit transaction wrapping here. In tests we observed that
664
            // HTTPRepository's RDF4J-transaction protocol silently no-op'd cross-graph
665
            // SPARQL UPDATEs with UNION sub-patterns inside conn.begin()/commit(),
666
            // while the same UPDATE POSTed directly to /statements applied correctly.
667
            // A bare prepareUpdate().execute() takes the direct /statements path and
668
            // runs the UPDATE atomically per SPARQL 1.1 semantics — which is all we
669
            // need; there's nothing else to commit atomically alongside the UPDATE.
670
            try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
671
                conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
672
            }
673
            long after = graphSize(graph);
×
674
            long added = after - before;
×
675
            if (added <= 0) break;
×
676
            total += added;
×
677
            before = after;
×
678
        }
×
679
        return total;
×
680
    }
681

682
    private long graphSize(IRI graph) {
683
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
684
            return conn.size(graph);
×
685
        }
686
    }
687

688
    /**
689
     * Distinct-subject totals in the given space-state graph, broken down by
690
     * RoleInstantiation kind (admin-pinned vs not) and RoleAssignment.
691
     * Three SELECT-COUNT queries — cheap, called once per build/cycle for
692
     * the user-facing log line. Returns zeros on failure (logged) so a flaky
693
     * count read can't wedge the cycle.
694
     */
695
    TierSubjectTotals computeTierSubjectTotals(IRI graph) {
696
        long adminRIs       = countDistinctSubjects(graph, """
×
697
                ?ri a gen:RoleInstantiation ; npa:inverseProperty gen:hasAdmin .
698
                """, "ri");
699
        long attachmentRAs  = countDistinctSubjects(graph, """
×
700
                ?ra a gen:RoleAssignment .
701
                """, "ra");
702
        long nonAdminRIs    = countDistinctSubjects(graph, """
×
703
                ?ri a gen:RoleInstantiation .
704
                FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
705
                """, "ri");
706
        return new TierSubjectTotals(adminRIs, attachmentRAs, nonAdminRIs);
×
707
    }
708

709
    private long countDistinctSubjects(IRI graph, String wherePattern, String varName) {
710
        String query = String.format("""
×
711
                PREFIX npa: <%1$s>
712
                PREFIX gen: <%2$s>
713
                SELECT (COUNT(DISTINCT ?%3$s) AS ?n) WHERE {
714
                  GRAPH <%4$s> {
715
                    %5$s
716
                  }
717
                }
718
                """, NPA.NAMESPACE, GEN.NAMESPACE, varName, graph, wherePattern);
719
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
×
720
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
721
            if (!r.hasNext()) return 0;
×
722
            return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
×
723
        } catch (Exception ex) {
×
724
            logger.warn("AuthorityResolver: countDistinctSubjects on {} failed: {}",
×
725
                    graph, ex.toString());
×
726
            return 0;
×
727
        }
728
    }
729

730
    // ---------------- SPARQL templates ----------------
731

732
    /**
733
     * Reusable invalidation filter on a bound nanopub-IRI variable. Pass the bare
734
     * variable name (no leading {@code ?}); e.g. {@code invalidationFilter("np")}
735
     * produces an outer-scoped {@code FILTER NOT EXISTS { GRAPH npa:graph
736
     * { ?_inv_np npx:invalidates ?np . } }}.
737
     *
738
     * <p>Joins on the raw {@code npx:invalidates} triple in {@code npa:graph},
739
     * which {@link com.knowledgepixels.query.NanopubLoader} writes into the
740
     * spaces repo from two complementary directions, making the filter symmetric
741
     * in load order:
742
     * <ul>
743
     *   <li>At the invalidator's own load: the loader's space-repo trigger fires
744
     *       whenever the nanopub has either its own space-relevant extractions
745
     *       OR an {@code npx:invalidates}/{@code npx:retracts}/{@code npx:supersedes}
746
     *       triple, so a pure-retraction nanopub still lands its raw triple plus
747
     *       {@code npa:hasLoadNumber} stamp in {@code npa:graph}.</li>
748
     *   <li>At the invalidated target's load (when the invalidator landed
749
     *       earlier): {@code NanopubLoader.getInvalidatingStatements} reads the
750
     *       triple back from the meta repo and mirrors it into the target's own
751
     *       write to the spaces repo.</li>
752
     * </ul>
753
     *
754
     * <p>The earlier shape joined on a structured {@code npa:Invalidation} entry
755
     * in {@code npa:spacesGraph} that was only emitted on the invalidator's side
756
     * AND only when the invalidated target's meta had already loaded, leaving a
757
     * window where a superseding nanopub loaded before its target produced no
758
     * entry and the stale row was never filtered out (see also the matching
759
     * change in the tier-specific {@code *InvalidationCheckWhere}/{@code
760
     * *InvalidationDelete} templates below).
761
     *
762
     * <p>Important: this filter must be placed OUTSIDE the surrounding
763
     * {@code GRAPH npa:spacesGraph { ... }} block, not nested inside it. When
764
     * nested, RDF4J's planner couples the FILTER NOT EXISTS evaluation into the
765
     * join order (per-row scan multiplied by the candidate set), which we
766
     * measured turning a 39ms query into a 60s+ timeout on the live observer-tier
767
     * data. Outside the GRAPH block, the planner defers the filter until
768
     * {@code ?np}/{@code ?rdNp} are bound and does a targeted index lookup.
769
     *
770
     * <p>Variable names must match {@code [A-Za-z0-9_]+} per SPARQL grammar —
771
     * embedding a {@code ?} inside {@code ?_inv_?np} would yield a parse error.
772
     */
773
    private static String invalidationFilter(String bareVarName) {
774
        return "FILTER NOT EXISTS { GRAPH <" + NPA.GRAPH + "> {"
24✔
775
                + " ?_inv_" + bareVarName
776
                + " <" + NPX.INVALIDATES + "> ?" + bareVarName + " . } }";
777
    }
778

779
    /**
780
     * Admin tier: seed from {@code npadef:...hasRootAdmin} (trusted by construction)
781
     * plus closed-over admin grants; insert any {@code gen:RoleInstantiation} with
782
     * {@code npa:inverseProperty gen:hasAdmin} whose publisher (resolved via mirrored
783
     * trust-approved AccountState) is already in the admin set.
784
     *
785
     * <p>The seed is gated by {@link #spaceRefAliveFilter} (not the per-nanopub
786
     * {@code invalidationFilter("defNp")}): the {@code hasRootAdmin} seed is anchored
787
     * to the root NPID, which is the immutable space-ref identity, so superseding the
788
     * root <em>nanopub</em> with a continuation revision must not strip the seed —
789
     * only retracting every definition of the ref removes it. See issue #110.
790
     */
791
    static String adminTierUpdate(IRI graph, long lastProcessed) {
792
        // Order tuned for RDF4J's evaluator:
793
        //   1. Anchor on the small (seed UNION closed-over) set to bind ?publisher
794
        //      and ?space cheaply.
795
        //   2. Resolve ?pkh from the mirrored AccountState row (?publisher bound).
796
        //   3. Probe instantiations using the now-bound (?space, ?pkh) — targeted
797
        //      lookup, not a full RoleInstantiation scan.
798
        //   4. Load-number filter on bound ?np.
799
        //   5. Dedup at the end.
800
        // Authority is keyed on the space *ref* (npa:forSpaceRef), not the bare Space
801
        // IRI: two refs that share an IRI but have different roots are independent
802
        // domains (see doc/design-spaceref-isolation.md). The instantiation evidence in
803
        // the extraction graph is IRI-keyed (a gen:hasAdmin nanopub names the bare IRI),
804
        // so we project it per-ref by joining each instantiation naming ?space to the
805
        // admin rows of every ref of ?space whose admin set contains the publisher. The
806
        // inserted subject is minted per (?ri, ?spaceRef) so one instantiation validating
807
        // into N refs yields N distinct rows. TRANSITIONAL-DUAL-EMIT (Phase 4: remove):
808
        // forSpace is still emitted alongside forSpaceRef so the not-yet-migrated
809
        // downstream tiers / pre-ref read queries keep functioning on a mixed-version
810
        // fleet; it is dropped once everything keys on forSpaceRef.
811
        return """
69✔
812
                PREFIX npa:  <%1$s>
813
                PREFIX gen:  <%2$s>
814
                INSERT { GRAPH <%3$s> {
815
                  ?sri a gen:RoleInstantiation ;
816
                       npa:forSpaceRef ?spaceRef ;
817
                       npa:forSpace ?space ;
818
                       npa:inverseProperty gen:hasAdmin ;
819
                       npa:forAgent ?agent ;
820
                       npa:viaNanopub ?np .
821
                } }
822
                WHERE {
823
                  # 1. Anchor: who is already an admin of which space ref?
824
                  {
825
                    # Seed branch: root-admin of a space ref that is still alive
826
                    # (has at least one non-invalidated definition). NOT filtered on
827
                    # ?def's own invalidation — superseding the root nanopub with a
828
                    # continuation revision must keep the seed; only a fully-retracted
829
                    # ref drops it (issue #110).
830
                    GRAPH <%4$s> {
831
                      ?def a npa:SpaceDefinition ;
832
                           npa:forSpaceRef  ?spaceRef ;
833
                           npa:hasRootAdmin ?publisher .
834
                      ?spaceRef npa:spaceIri ?space .
835
                    }
836
                    %7$s
837
                  }
838
                  UNION
839
                  {
840
                    # Closed-over branch: an existing admin of this ref. Recurse on the
841
                    # ref, then resolve its bare IRI to probe the IRI-keyed instantiation.
842
                    GRAPH <%3$s> {
843
                      ?prev a gen:RoleInstantiation ;
844
                            npa:forSpaceRef     ?spaceRef ;
845
                            npa:inverseProperty gen:hasAdmin ;
846
                            npa:forAgent        ?publisher .
847
                    }
848
                    GRAPH <%4$s> {
849
                      ?spaceRef npa:spaceIri ?space .
850
                    }
851
                  }
852
                  # 2. Mirror: resolve ?publisher → ?pkh via the trust-approved row.
853
                  GRAPH <%3$s> {
854
                    ?acct a npa:AccountState ;
855
                          npa:agent  ?publisher ;
856
                          npa:pubkey ?pkh .
857
                  }
858
                  # 3. Targeted instantiation lookup by space + pubkey (IRI-keyed).
859
                  GRAPH <%4$s> {
860
                    ?ri a gen:RoleInstantiation ;
861
                        npa:forSpace        ?space ;
862
                        npa:inverseProperty gen:hasAdmin ;
863
                        npa:forAgent        ?agent ;
864
                        npa:pubkeyHash      ?pkh ;
865
                        npa:viaNanopub      ?np .
866
                  }
867
                  # 3a. Mint the per-ref state subject: (?ri, ?spaceRef) → ?sri.
868
                  BIND(IRI(CONCAT(STR(?ri), "__", ENCODE_FOR_URI(STR(?spaceRef)))) AS ?sri)
869
                  %6$s
870
                  # 4. Load-number filter on bound ?np.
871
                  GRAPH <%8$s> {
872
                    ?np npa:hasLoadNumber ?ln .
873
                    FILTER (?ln > %5$d)
874
                  }
875
                  # 5. Dedup last — keyed on (ref, agent).
876
                  FILTER NOT EXISTS { GRAPH <%3$s> {
877
                    ?existing a gen:RoleInstantiation ;
878
                              npa:forSpaceRef ?spaceRef ;
879
                              npa:forAgent ?agent ;
880
                              npa:inverseProperty gen:hasAdmin .
881
                  } }
882
                }
883
                """.formatted(
3✔
884
                NPA.NAMESPACE,
885
                GEN.NAMESPACE,
886
                graph,
887
                SpacesVocab.SPACES_GRAPH,
888
                lastProcessed,
15✔
889
                invalidationFilter("np"),
12✔
890
                spaceRefAliveFilter(),
18✔
891
                NPA.GRAPH);
892
    }
893

894
    /**
895
     * Seed-survival filter for the admin tier (issue #110). The {@code hasRootAdmin}
896
     * seed is anchored to the root NPID, which is the immutable space-ref identity, so
897
     * it must survive supersession of the root <em>nanopub</em> by a continuation
898
     * revision (a later definition re-roots to the same ref via
899
     * {@code gen:hasRootDefinition} and so carries no {@code hasRootAdmin} of its own).
900
     * The previous {@code invalidationFilter("defNp")} dropped the seed the moment the
901
     * root revision was superseded, leaving the whole admin closure — and everything
902
     * cascading from it — unmaterialized for any space whose definition had ever been
903
     * updated.
904
     *
905
     * <p>Expressed positively: the seed survives iff the space ref still has at least
906
     * one non-invalidated {@link SpacesVocab#SPACE_DEFINITION}. A fully-retracted ref
907
     * (every definition invalidated) has no live definition, so the {@code FILTER
908
     * EXISTS} fails and the seed correctly disappears. Anchored on the already-bound
909
     * {@code ?spaceRef}, so it's a targeted lookup over that ref's (few) definitions.
910
     */
911
    private static String spaceRefAliveFilter() {
912
        return """
33✔
913
                FILTER EXISTS {
914
                  GRAPH <%1$s> {
915
                    ?liveDef a npa:SpaceDefinition ;
916
                             npa:forSpaceRef ?spaceRef ;
917
                             npa:viaNanopub  ?liveNp .
918
                  }
919
                  %2$s
920
                }
921
                """.formatted(SpacesVocab.SPACES_GRAPH, invalidationFilter("liveNp"));
9✔
922
    }
923

924
    /**
925
     * {@code gen:hasRole} attachment validation: an attachment is validated iff its
926
     * publisher is already a validated admin of the target space. Adds
927
     * {@code gen:RoleAssignment} rows to the space-state graph.
928
     */
929
    static String attachmentValidationUpdate(IRI graph, long lastProcessed) {
930
        // Ref-keyed (see doc/design-spaceref-isolation.md). The attachment names a bare
931
        // Space IRI; it is validated per-ref for every ref of that IRI whose admin set
932
        // contains the publisher (direct), or — when the named IRI is an owl:sameAs alias
933
        // — for the canonical ref it maps to (issue #113). ?targetRef is the ref the
934
        // RoleAssignment attaches to; the inserted subject is minted per (?ra, ?targetRef)
935
        // so one attachment validating into N refs yields N distinct rows.
936
        // TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace (the attached IRI, possibly an
937
        // alias) is kept so the non-admin tier can probe the IRI-keyed instantiations
938
        // naming it, and so pre-ref read queries keep functioning on a mixed-version fleet.
939
        return """
69✔
940
                PREFIX npa:  <%1$s>
941
                PREFIX gen:  <%2$s>
942
                INSERT { GRAPH <%3$s> {
943
                  ?ra2 a gen:RoleAssignment ;
944
                       npa:forSpaceRef ?targetRef ;
945
                       npa:forSpace ?space ;
946
                       gen:hasRole  ?role ;
947
                       npa:viaNanopub ?np .
948
                } }
949
                WHERE {
950
                  GRAPH <%4$s> {
951
                    ?ra a gen:RoleAssignment ;
952
                        npa:forSpace ?space ;
953
                        gen:hasRole  ?role ;
954
                        npa:pubkeyHash ?pkh ;
955
                        npa:viaNanopub ?np .
956
                  }
957
                  GRAPH <%7$s> {
958
                    ?np npa:hasLoadNumber ?ln .
959
                    FILTER (?ln > %5$d)
960
                  }
961
                  GRAPH <%3$s> {
962
                    ?acct a npa:AccountState ;
963
                          npa:agent  ?publisher ;
964
                          npa:pubkey ?pkh .
965
                  }
966
                  # Per-ref admin gate. ?targetRef = a ref of ?space the publisher admins
967
                  # (direct), or the canonical ref ?space is an owl:sameAs alias of.
968
                  {
969
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?space . }
970
                    GRAPH <%3$s> {
971
                      ?adminRI a gen:RoleInstantiation ;
972
                               npa:forSpaceRef ?targetRef ;
973
                               npa:inverseProperty gen:hasAdmin ;
974
                               npa:forAgent ?publisher .
975
                    }
976
                  }
977
                  UNION
978
                  {
979
                    GRAPH <%3$s> {
980
                      ?space npa:sameAsSpace ?targetRef .
981
                      ?adminRI a gen:RoleInstantiation ;
982
                               npa:forSpaceRef ?targetRef ;
983
                               npa:inverseProperty gen:hasAdmin ;
984
                               npa:forAgent ?publisher .
985
                    }
986
                  }
987
                  BIND(IRI(CONCAT(STR(?ra), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?ra2)
988
                  %6$s
989
                  FILTER NOT EXISTS { GRAPH <%3$s> {
990
                    ?existing a gen:RoleAssignment ;
991
                              npa:forSpaceRef ?targetRef ;
992
                              gen:hasRole  ?role .
993
                  } }
994
                }
995
                """.formatted(
3✔
996
                NPA.NAMESPACE,
997
                GEN.NAMESPACE,
998
                graph,
999
                SpacesVocab.SPACES_GRAPH,
1000
                lastProcessed,
15✔
1001
                invalidationFilter("np"),
18✔
1002
                NPA.GRAPH);
1003
    }
1004

1005
    /**
1006
     * Preset-bundled role materialization (Nanodash issue #302). For each active,
1007
     * admin-authored {@code gen:PresetAssignment} targeting a {@code gen:Space}, inserts
1008
     * one {@code gen:RoleAssignment} per role the preset bundles — exactly as if
1009
     * {@code <space> gen:hasRole <role>} had been published by the assignment's publisher.
1010
     * The materialized rows carry {@code npa:derivedFromPreset} (the assignment nanopub)
1011
     * so the deactivation delete and read-side marking can scope to them without touching
1012
     * directly-published attachments. See {@code doc/design-preset-role-materialization.md}.
1013
     *
1014
     * <p>Activation is resolved by an <b>authorization-scoped latest-wins</b> over the
1015
     * {@code (preset, resource)} pair, NOT {@code npx:invalidates} (§3): the candidate set
1016
     * for the {@code MAX(dct:created)} comparison is restricted to assignments whose
1017
     * publisher is also a validated admin of the target ref, so an unauthorized key's newer
1018
     * assignment cannot shadow an admin's activation (the #113-class anti-hijack rule).
1019
     */
1020
    static String presetAttachmentValidationUpdate(IRI graph, long lastProcessed) {
1021
        // Ref-keyed like attachmentValidationUpdate: the assignment names a bare resource
1022
        // IRI; it is validated per-ref for every Space ref of that IRI whose admin set
1023
        // contains the publisher. The inserted subject is minted per (assignment, ref, role)
1024
        // — one assignment fans out to N roles and N refs. Non-Space targets resolve no
1025
        // ?targetRef and so insert nothing (correct no-op; maintained-resource / individual
1026
        // targets are future work, see design doc §2). TRANSITIONAL-DUAL-EMIT (Phase 4:
1027
        // remove): forSpace kept alongside forSpaceRef so the non-admin tiers can probe the
1028
        // IRI-keyed instantiations and pre-ref read queries keep functioning.
1029
        return """
69✔
1030
                PREFIX npa:  <%1$s>
1031
                PREFIX gen:  <%2$s>
1032
                INSERT { GRAPH <%3$s> {
1033
                  ?ra2 a gen:RoleAssignment ;
1034
                       npa:forSpaceRef ?targetRef ;
1035
                       npa:forSpace    ?resource ;
1036
                       gen:hasRole     ?role ;
1037
                       npa:viaNanopub  ?assignNp ;
1038
                       npa:derivedFromPreset ?assignNp .
1039
                } }
1040
                WHERE {
1041
                  # 1. Anchor: active preset assignments in the extraction graph.
1042
                  GRAPH <%4$s> {
1043
                    ?pa a npa:PresetAssignment ;
1044
                        npa:ofPreset    ?preset ;
1045
                        npa:forResource ?resource ;
1046
                        npa:isActivated true ;
1047
                        npa:pubkeyHash  ?pkh ;
1048
                        npa:viaNanopub  ?assignNp ;
1049
                        <http://purl.org/dc/terms/created> ?created .
1050
                  }
1051
                  # 2. Load-number filter on the assignment nanopub.
1052
                  GRAPH <%7$s> {
1053
                    ?assignNp npa:hasLoadNumber ?ln .
1054
                    FILTER (?ln > %5$d)
1055
                  }
1056
                  # 3. Resolve publisher pkh -> agent via the mirrored trust-approved row.
1057
                  GRAPH <%3$s> {
1058
                    ?acct a npa:AccountState ;
1059
                          npa:agent  ?publisher ;
1060
                          npa:pubkey ?pkh .
1061
                  }
1062
                  # 4. Target must be a Space ref the publisher admins. ?targetRef = that ref.
1063
                  GRAPH <%4$s> { ?targetRef npa:spaceIri ?resource . }
1064
                  GRAPH <%3$s> {
1065
                    ?adminRI a gen:RoleInstantiation ;
1066
                             npa:forSpaceRef ?targetRef ;
1067
                             npa:inverseProperty gen:hasAdmin ;
1068
                             npa:forAgent ?publisher .
1069
                  }
1070
                  # 5. Resolve the assignment's referenced preset IRI (node or kind) to its
1071
                  #    canonical kind, mirroring how Nanodash views key on dct:isVersionOf
1072
                  #    (ViewDisplay.getViewKindIri). Every declaration carries npa:ofPreset for
1073
                  #    both its node IRI and kind, so either reference maps to the same ?kind.
1074
                  GRAPH <%4$s> {
1075
                    ?pdMap a npa:PresetDeclaration ;
1076
                           npa:ofPreset   ?preset ;
1077
                           npa:presetKind ?kind .
1078
                  }
1079
                  # 5a. Roles come from the LATEST live declaration of that kind, restricted to
1080
                  #     Space-targeted presets — so a superseded preset version's roles never leak
1081
                  #     (the per-view-kind latest-wins, ported to materialization).
1082
                  GRAPH <%4$s> {
1083
                    ?pd a npa:PresetDeclaration ;
1084
                        npa:presetKind           ?kind ;
1085
                        npa:presetRole           ?role ;
1086
                        npa:appliesToInstancesOf gen:Space ;
1087
                        npa:viaNanopub           ?pdNp ;
1088
                        <http://purl.org/dc/terms/created> ?pdCreated .
1089
                  }
1090
                  # 5b. Latest-declaration-per-kind: reject if a newer LIVE declaration of the
1091
                  #     same kind exists (tiebreak on subject IRI for equal timestamps).
1092
                  FILTER NOT EXISTS {
1093
                    GRAPH <%4$s> {
1094
                      ?pdNewer a npa:PresetDeclaration ;
1095
                               npa:presetKind ?kind ;
1096
                               npa:viaNanopub ?pdNpNewer ;
1097
                               <http://purl.org/dc/terms/created> ?pdCreatedNewer .
1098
                      FILTER (?pdCreatedNewer > ?pdCreated
1099
                              || (?pdCreatedNewer = ?pdCreated && STR(?pdNewer) > STR(?pd)))
1100
                    }
1101
                    %8$s
1102
                  }
1103
                  # 5c. The chosen declaration must itself be live (not superseded/retracted).
1104
                  %9$s
1105
                  # 6. Mint the per (assignment, ref, role) subject.
1106
                  BIND(IRI(CONCAT(STR(?pa), "__", ENCODE_FOR_URI(STR(?targetRef)),
1107
                                  "__", ENCODE_FOR_URI(STR(?role)))) AS ?ra2)
1108
                  # 7. Authorization-scoped latest-wins (anti-hijack, design doc §3): reject
1109
                  #    if a newer same-(preset,resource) assignment exists whose publisher is
1110
                  #    ALSO a validated admin of ?targetRef. Filtering the shadowing candidate
1111
                  #    to admin-authored rows BEFORE taking the latest is what stops an
1112
                  #    unauthorized key from suppressing an admin's activation. Placed after
1113
                  #    the main vars are bound so the planner defers it (RDF4J quirk).
1114
                  FILTER NOT EXISTS {
1115
                    GRAPH <%4$s> {
1116
                      ?paNewer a npa:PresetAssignment ;
1117
                               npa:ofPreset    ?preset ;
1118
                               npa:forResource ?resource ;
1119
                               npa:pubkeyHash  ?pkhNewer ;
1120
                               <http://purl.org/dc/terms/created> ?createdNewer .
1121
                      FILTER (?createdNewer > ?created
1122
                              || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
1123
                    }
1124
                    GRAPH <%3$s> {
1125
                      ?acctNewer a npa:AccountState ;
1126
                                 npa:agent  ?publisherNewer ;
1127
                                 npa:pubkey ?pkhNewer .
1128
                      ?adminRINewer a gen:RoleInstantiation ;
1129
                                    npa:forSpaceRef ?targetRef ;
1130
                                    npa:inverseProperty gen:hasAdmin ;
1131
                                    npa:forAgent ?publisherNewer .
1132
                    }
1133
                  }
1134
                  # 8. Defensive: drop if the assignment nanopub itself was hard-retracted.
1135
                  %6$s
1136
                  # 9. Dedup last — keyed on (ref, role).
1137
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1138
                    ?existing a gen:RoleAssignment ;
1139
                              npa:forSpaceRef ?targetRef ;
1140
                              gen:hasRole ?role .
1141
                  } }
1142
                }
1143
                """.formatted(
3✔
1144
                NPA.NAMESPACE,
1145
                GEN.NAMESPACE,
1146
                graph,
1147
                SpacesVocab.SPACES_GRAPH,
1148
                lastProcessed,
15✔
1149
                invalidationFilter("assignNp"),
27✔
1150
                NPA.GRAPH,
1151
                invalidationFilter("pdNpNewer"),
15✔
1152
                invalidationFilter("pdNp"));
6✔
1153
    }
1154

1155
    /**
1156
     * Non-admin tier publisher constraints (inserted as a SPARQL sub-pattern).
1157
     * Each constraint owns the AccountState (pkh → agent) lookup so the join
1158
     * variable is bound through a targeted pattern. The observer-self variant
1159
     * binds {@code npa:agent ?agent} directly — no separate {@code ?publisher}
1160
     * variable, no post-join equality filter — which lets the planner anchor
1161
     * the AccountState lookup on the already-bound {@code ?agent} instead of
1162
     * enumerating all approved publishers and filtering at the end.
1163
     */
1164
    static final String PUBLISHER_IS_ADMIN = """
1165
            ?acct a npa:AccountState ;
1166
                  npa:pubkey ?pkh ;
1167
                  npa:agent  ?publisher .
1168
            # Admin of the assignment's ref. The ref already resolves alias →
1169
            # canonical (the attachment tier bound ?spaceRef through the owl:sameAs
1170
            # alias edge for aliased IRIs, issue #113), so no alias arm is needed here.
1171
            ?adminRI a gen:RoleInstantiation ;
1172
                     npa:forSpaceRef ?spaceRef ;
1173
                     npa:inverseProperty gen:hasAdmin ;
1174
                     npa:forAgent ?publisher .
1175
            """;
1176

1177
    /** Observer self-evidence: the assignee's own pubkey signed the instantiation. */
1178
    static final String PUBLISHER_IS_SELF = """
1179
            ?acct a npa:AccountState ;
1180
                  npa:pubkey ?pkh ;
1181
                  npa:agent  ?agent .
1182
            """;
1183

1184
    /**
1185
     * Maintainer / Member / Observer tier INSERT. Same shape: find an instantiation
1186
     * whose predicate matches a RoleDeclaration of the given tier attached to the
1187
     * target space, and whose publisher passes the tier-specific constraint.
1188
     */
1189
    static String nonAdminTierUpdate(IRI graph, long lastProcessed,
1190
                                     IRI tierClass, String publisherConstraint) {
1191
        // Order tuned for RDF4J's evaluator (which executes BGPs roughly in order).
1192
        // The crucial choice is the *anchor*: instantiation-first plans send the
1193
        // planner exploring the full ~thousands of candidate RIs and only filter
1194
        // by tier at the very end. Attachment-first anchors on the small set of
1195
        // gen:RoleAssignment rows already validated in this space-state graph
1196
        // (~hundreds, often zero) and walks outward by bound (?role, ?space).
1197
        //
1198
        //   1. Anchor on RoleAssignments in this space-state graph (small).
1199
        //   2. Match the tier-pinned RoleDeclaration by ?role.
1200
        //   3. Pair role-decl direction to instantiation direction in one UNION
1201
        //      so only (reg, reg)/(inv, inv) combos are explored.
1202
        //   4. Targeted instantiation lookup — (?space, ?pred) are bound.
1203
        //   5. Publisher constraint (incl. AccountState resolution).
1204
        //   6. Load-number filter on bound ?np.
1205
        //   7. Dedup at the end.
1206
        return """
69✔
1207
                PREFIX npa:  <%1$s>
1208
                PREFIX gen:  <%2$s>
1209
                INSERT { GRAPH <%3$s> {
1210
                  ?ri2 a gen:RoleInstantiation ;
1211
                       npa:forSpaceRef ?spaceRef ;
1212
                       # TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace alongside
1213
                       # forSpaceRef so pre-ref read queries (e.g. get-space-members) keep
1214
                       # functioning on a mixed-version fleet; downstream tiers key on the ref.
1215
                       npa:forSpace ?space ;
1216
                       npa:forAgent ?agent ;
1217
                       ?dirPred ?pred ;
1218
                       npa:viaNanopub ?np .
1219
                } }
1220
                WHERE {
1221
                  # 1. Anchor: validated attachments in this space-state graph (ref-keyed).
1222
                  GRAPH <%3$s> {
1223
                    ?ra a gen:RoleAssignment ;
1224
                        gen:hasRole     ?role ;
1225
                        npa:forSpaceRef ?spaceRef ;
1226
                        npa:forSpace    ?space .
1227
                  }
1228
                  # 2. Tier-pinned RoleDeclaration (?role bound from the attachment).
1229
                  GRAPH <%4$s> {
1230
                    ?rd a npa:RoleDeclaration ;
1231
                        npa:hasRoleType <%7$s> ;
1232
                        npa:role        ?role ;
1233
                        npa:viaNanopub  ?rdNp .
1234
                    # 3. Pair direction so only matching combos are explored. ?dirPred
1235
                    #    carries the matched direction so the materialized row records the
1236
                    #    role property (read by get-space-members and publisherIsTieredRole).
1237
                    {
1238
                      ?rd gen:hasRegularProperty ?pred .
1239
                      ?ri npa:regularProperty    ?pred .
1240
                      BIND(npa:regularProperty AS ?dirPred)
1241
                    }
1242
                    UNION
1243
                    {
1244
                      ?rd gen:hasInverseProperty ?pred .
1245
                      ?ri npa:inverseProperty    ?pred .
1246
                      BIND(npa:inverseProperty AS ?dirPred)
1247
                    }
1248
                    # 4. Targeted instantiation lookup — (?space, ?pred) bound.
1249
                    ?ri a gen:RoleInstantiation ;
1250
                        npa:forSpace   ?space ;
1251
                        npa:forAgent   ?agent ;
1252
                        npa:pubkeyHash ?pkh ;
1253
                        npa:viaNanopub ?np .
1254
                  }
1255
                  # 5. Publisher constraint (incl. AccountState resolution).
1256
                  GRAPH <%3$s> {
1257
                    %9$s
1258
                  }
1259
                  # 5a. Mint the per-ref state subject: (?ri, ?spaceRef) → ?ri2.
1260
                  BIND(IRI(CONCAT(STR(?ri), "__", ENCODE_FOR_URI(STR(?spaceRef)))) AS ?ri2)
1261
                  # 6. Load-number filter on bound ?np.
1262
                  GRAPH <%10$s> {
1263
                    ?np npa:hasLoadNumber ?ln .
1264
                    FILTER (?ln > %5$d)
1265
                  }
1266
                  # 7. Invalidation filters — outside the GRAPH block so the
1267
                  #    planner defers them until ?rdNp/?np are bound.
1268
                  %8$s
1269
                  %6$s
1270
                  # 8. Dedup last — keyed on (ref, agent, nanopub).
1271
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1272
                    ?existing a gen:RoleInstantiation ;
1273
                              npa:forSpaceRef ?spaceRef ;
1274
                              npa:forAgent ?agent ;
1275
                              npa:viaNanopub ?np .
1276
                  } }
1277
                }
1278
                """.formatted(
3✔
1279
                NPA.NAMESPACE,
1280
                GEN.NAMESPACE,
1281
                graph,
1282
                SpacesVocab.SPACES_GRAPH,
1283
                lastProcessed,
15✔
1284
                invalidationFilter("np"),
27✔
1285
                tierClass,
1286
                invalidationFilter("rdNp"),
30✔
1287
                publisherConstraint,
1288
                NPA.GRAPH);
1289
    }
1290

1291
    /**
1292
     * Sub-space admit pass. Copies validated {@code npa:SubSpaceDeclaration}
1293
     * extraction rows into the space-state graph (preserving the {@code npasub:}
1294
     * subject) and emits convenience {@code <child> npa:isSubSpaceOf <parent>} and
1295
     * {@code <parent> npa:hasSubSpace <child>} direct triples. Two satisfaction
1296
     * modes joined by UNION:
1297
     * <ul>
1298
     *   <li>Mode A — the declaration's publisher is a validated admin of both the
1299
     *       child and the parent space.</li>
1300
     *   <li>Mode B — a different non-invalidated declaration for the same
1301
     *       {@code (child, parent)} pair exists, and the two publishers between
1302
     *       them cover both admin sides (i.e. one of them is admin of the child,
1303
     *       one of them is admin of the parent — possibly the same one twice if
1304
     *       both happen to be admin of both).</li>
1305
     * </ul>
1306
     *
1307
     * <p>Mode-B late-arrival: when only the partner declaration is new in this
1308
     * cycle (the primary is older than {@code lastProcessed}), the load-number
1309
     * filter on {@code ?np} excludes the candidate. The late-arrival sweep
1310
     * ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass without the
1311
     * load filter and catches it.
1312
     */
1313
    static String subSpaceAdmitUpdate(IRI graph, long lastProcessed) {
1314
        return """
69✔
1315
                PREFIX npa: <%1$s>
1316
                PREFIX gen: <%2$s>
1317
                INSERT { GRAPH <%3$s> {
1318
                  ?d a npa:SubSpaceDeclaration ;
1319
                     npa:childSpace  ?child ;
1320
                     npa:parentSpace ?parent ;
1321
                     npa:viaNanopub  ?np .
1322
                  ?childRef  npa:isSubSpaceOf ?parentRef .
1323
                  ?parentRef npa:hasSubSpace  ?childRef  .
1324
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
1325
                  # sub-space edge alongside the ref-to-ref one, so pre-ref published
1326
                  # queries that key on the bare Space IRI keep binding on a mixed-version
1327
                  # fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
1328
                  ?child  npa:isSubSpaceOf ?parent .
1329
                  ?parent npa:hasSubSpace  ?child  .
1330
                } }
1331
                WHERE {
1332
                  # 1. Anchor: candidate declarations from the extraction graph.
1333
                  GRAPH <%4$s> {
1334
                    ?d a npa:SubSpaceDeclaration ;
1335
                       npa:childSpace  ?child ;
1336
                       npa:parentSpace ?parent ;
1337
                       npa:pubkeyHash  ?pkh ;
1338
                       npa:viaNanopub  ?np .
1339
                  }
1340
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
1341
                  GRAPH <%3$s> {
1342
                    ?acct a npa:AccountState ;
1343
                          npa:pubkey ?pkh ;
1344
                          npa:agent  ?publisher .
1345
                  }
1346
                  # 3. Authority gate, ref-keyed. The edge is emitted ref-to-ref between
1347
                  #    the child ref and parent ref the authorizing admin governs; the
1348
                  #    admin rows' dual-emitted npa:forSpace binds the refs to the child /
1349
                  #    parent IRIs (cross-product when an IRI has several governed refs).
1350
                  {
1351
                    # Mode A — publisher is admin of BOTH a child ref and a parent ref.
1352
                    GRAPH <%3$s> {
1353
                      ?riC a gen:RoleInstantiation ;
1354
                           npa:inverseProperty gen:hasAdmin ;
1355
                           npa:forSpace ?child ;
1356
                           npa:forSpaceRef ?childRef ;
1357
                           npa:forAgent ?publisher .
1358
                      ?riP a gen:RoleInstantiation ;
1359
                           npa:inverseProperty gen:hasAdmin ;
1360
                           npa:forSpace ?parent ;
1361
                           npa:forSpaceRef ?parentRef ;
1362
                           npa:forAgent ?publisher .
1363
                    }
1364
                  }
1365
                  UNION
1366
                  {
1367
                    # Mode B — co-declaration whose publisher covers the side this
1368
                    # one's publisher doesn't. Between {publisher, publisher2},
1369
                    # both admin sides must be covered.
1370
                    GRAPH <%4$s> {
1371
                      ?d2 a npa:SubSpaceDeclaration ;
1372
                          npa:childSpace  ?child ;
1373
                          npa:parentSpace ?parent ;
1374
                          npa:pubkeyHash  ?pkh2 ;
1375
                          npa:viaNanopub  ?np2 .
1376
                      FILTER (?np2 != ?np)
1377
                    }
1378
                    %8$s
1379
                    GRAPH <%3$s> {
1380
                      ?acct2 a npa:AccountState ;
1381
                             npa:pubkey ?pkh2 ;
1382
                             npa:agent  ?publisher2 .
1383
                      ?riA a gen:RoleInstantiation ;
1384
                           npa:inverseProperty gen:hasAdmin ;
1385
                           npa:forSpace ?child ;
1386
                           npa:forSpaceRef ?childRef .
1387
                      { ?riA npa:forAgent ?publisher } UNION { ?riA npa:forAgent ?publisher2 }
1388
                      ?riB a gen:RoleInstantiation ;
1389
                           npa:inverseProperty gen:hasAdmin ;
1390
                           npa:forSpace ?parent ;
1391
                           npa:forSpaceRef ?parentRef .
1392
                      { ?riB npa:forAgent ?publisher } UNION { ?riB npa:forAgent ?publisher2 }
1393
                    }
1394
                  }
1395
                  # 4. Invalidation filter on the primary declaration's nanopub.
1396
                  %6$s
1397
                  # 5. Load-number filter on bound ?np.
1398
                  GRAPH <%7$s> {
1399
                    ?np npa:hasLoadNumber ?ln .
1400
                    FILTER (?ln > %5$d)
1401
                  }
1402
                  # 6. Dedup last — on the emitted ref-to-ref edge.
1403
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1404
                    ?childRef npa:isSubSpaceOf ?parentRef .
1405
                  } }
1406
                }
1407
                """.formatted(
3✔
1408
                NPA.NAMESPACE,
1409
                GEN.NAMESPACE,
1410
                graph,
1411
                SpacesVocab.SPACES_GRAPH,
1412
                lastProcessed,
15✔
1413
                invalidationFilter("np"),
27✔
1414
                NPA.GRAPH,
1415
                invalidationFilter("np2"));
6✔
1416
    }
1417

1418
    /**
1419
     * Maintained-resource admit pass. Copies validated
1420
     * {@code npa:MaintainedResourceDeclaration} extraction rows into the space-state
1421
     * graph (preserving the {@code npamrd:} subject) and emits convenience
1422
     * {@code <r> npa:isMaintainedBy <s>} and {@code <s> npa:hasMaintainedResource <r>}
1423
     * direct triples. Single satisfaction mode:
1424
     * <ul>
1425
     *   <li>Mode A — the declaration's publisher is a validated admin of the
1426
     *       maintaining space.</li>
1427
     * </ul>
1428
     *
1429
     * <p>No Mode B because only one space is involved; the two-sides-must-be-covered
1430
     * concern that drives sub-space Mode B doesn't apply. Late-arrival is still
1431
     * possible (declaration lands before the publisher's admin grant becomes valid):
1432
     * the load-number filter on {@code ?np} excludes the candidate, and the
1433
     * late-arrival sweep ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass
1434
     * without the load filter and catches it.
1435
     */
1436
    static String maintainedResourceAdmitUpdate(IRI graph, long lastProcessed) {
1437
        return """
69✔
1438
                PREFIX npa: <%1$s>
1439
                PREFIX gen: <%2$s>
1440
                INSERT { GRAPH <%3$s> {
1441
                  ?d a npa:MaintainedResourceDeclaration ;
1442
                     npa:resourceIri     ?r ;
1443
                     npa:maintainerSpace ?s ;
1444
                     npa:viaNanopub      ?np .
1445
                  ?r npa:isMaintainedBy        ?sRef .
1446
                  ?sRef npa:hasMaintainedResource ?r .
1447
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
1448
                  # maintained-resource edge alongside the resource→ref one, so pre-ref
1449
                  # published queries (e.g. get-view-displays' maintained hop) keep binding
1450
                  # on a mixed-version fleet. This is the edge whose absence broke 1.15.0 —
1451
                  # see doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
1452
                  ?r npa:isMaintainedBy        ?s .
1453
                  ?s npa:hasMaintainedResource ?r .
1454
                } }
1455
                WHERE {
1456
                  # 1. Anchor: candidate declarations from the extraction graph.
1457
                  GRAPH <%4$s> {
1458
                    ?d a npa:MaintainedResourceDeclaration ;
1459
                       npa:resourceIri     ?r ;
1460
                       npa:maintainerSpace ?s ;
1461
                       npa:pubkeyHash      ?pkh ;
1462
                       npa:viaNanopub      ?np .
1463
                  }
1464
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
1465
                  GRAPH <%3$s> {
1466
                    ?acct a npa:AccountState ;
1467
                          npa:pubkey ?pkh ;
1468
                          npa:agent  ?publisher .
1469
                    # 3. Authority gate (Mode A only): publisher is admin of a ref of the
1470
                    #    maintaining space. ?sRef = that ref (resource → ref edge).
1471
                    ?riA a gen:RoleInstantiation ;
1472
                         npa:inverseProperty gen:hasAdmin ;
1473
                         npa:forSpace ?s ;
1474
                         npa:forSpaceRef ?sRef ;
1475
                         npa:forAgent ?publisher .
1476
                  }
1477
                  # 4. Invalidation filter on the declaration's nanopub.
1478
                  %6$s
1479
                  # 5. Load-number filter on bound ?np.
1480
                  GRAPH <%7$s> {
1481
                    ?np npa:hasLoadNumber ?ln .
1482
                    FILTER (?ln > %5$d)
1483
                  }
1484
                  # 6. Dedup last — on the emitted resource → ref edge.
1485
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1486
                    ?r npa:isMaintainedBy ?sRef .
1487
                  } }
1488
                }
1489
                """.formatted(
3✔
1490
                NPA.NAMESPACE,
1491
                GEN.NAMESPACE,
1492
                graph,
1493
                SpacesVocab.SPACES_GRAPH,
1494
                lastProcessed,
15✔
1495
                invalidationFilter("np"),
18✔
1496
                NPA.GRAPH);
1497
    }
1498

1499
    /**
1500
     * Space-alias admit pass (issue #113). Copies validated
1501
     * {@code npa:SpaceAliasDeclaration} extraction rows into the space-state graph
1502
     * (preserving the {@code npaalias:} subject) and emits the directional
1503
     * {@code <alias> npa:sameAsSpace <canonical>} edge consumed by the alias-aware
1504
     * admin-authority lookups in {@link #attachmentValidationUpdate},
1505
     * {@link #PUBLISHER_IS_ADMIN}, and {@link #publisherIsTieredRole}.
1506
     *
1507
     * <p>Two gates, both read against the (already-settled) admin closure in the
1508
     * space-state graph:
1509
     * <ul>
1510
     *   <li><b>Authority</b> — the declaration's publisher (resolved via the mirrored
1511
     *       trust-approved {@code AccountState}) is a validated admin of the
1512
     *       <em>canonical</em> space. The alias is declared inside the canonical
1513
     *       space's own {@code gen:Space} nanopub, so this is the same evidence rule
1514
     *       as a {@code gen:hasRole} attachment.</li>
1515
     *   <li><b>Anti-hijack</b> — the alias must not be an independently-governed live
1516
     *       space: it must have no admin who is not also an admin of the canonical
1517
     *       space ({@code admins(alias) ⊆ admins(canonical)}). The common rename case
1518
     *       (the alias's own definition was superseded, so it has no live admin
1519
     *       closure) passes trivially; an attacker publishing
1520
     *       {@code <evil> owl:sameAs <activeSpace>} is rejected because the active
1521
     *       space has admins not in evil's set.</li>
1522
     * </ul>
1523
     *
1524
     * <p>Late-arrival: when the canonical admin grant only becomes valid in the same
1525
     * cycle as the declaration, the load-number filter on {@code ?np} excludes the
1526
     * candidate; the late-arrival sweep ({@link #runDownstreamWithoutLoadFilter})
1527
     * re-runs this pass without the load filter and catches it.
1528
     */
1529
    static String aliasAdmitUpdate(IRI graph, long lastProcessed) {
1530
        // Ref-keyed (see doc/design-spaceref-isolation.md). The declaration names bare
1531
        // canonical/alias IRIs. It is admitted per canonical *ref* whose admin set
1532
        // contains the publisher; the emitted edge is ref-valued on the canonical side
1533
        // (<alias> npa:sameAsSpace <canonicalRef>), which is what the alias-aware admin
1534
        // lookups in the attachment tier consume. Anti-hijack compares the alias IRI's
1535
        // admins against that specific canonical ref's admins — strictly tighter than the
1536
        // old bare-IRI form.
1537
        return """
69✔
1538
                PREFIX npa: <%1$s>
1539
                PREFIX gen: <%2$s>
1540
                INSERT { GRAPH <%3$s> {
1541
                  ?d a npa:SpaceAliasDeclaration ;
1542
                     npa:canonicalSpace ?canonical ;
1543
                     npa:aliasSpace     ?alias ;
1544
                     npa:viaNanopub     ?np .
1545
                  ?alias npa:sameAsSpace ?canonRef .
1546
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
1547
                  # alias edge alongside the ref-valued one, so pre-ref published queries
1548
                  # that resolve owl:sameAs by bare canonical IRI keep binding on a
1549
                  # mixed-version fleet. Internal alias-aware lookups (attachment tier)
1550
                  # join through npa:forSpaceRef, which is ref-valued, so this IRI-valued
1551
                  # object never satisfies them — it is inert internally, read-only for
1552
                  # legacy consumers. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
1553
                  ?alias npa:sameAsSpace ?canonical .
1554
                } }
1555
                WHERE {
1556
                  # 1. Anchor: candidate alias declarations from the extraction graph.
1557
                  GRAPH <%4$s> {
1558
                    ?d a npa:SpaceAliasDeclaration ;
1559
                       npa:canonicalSpace ?canonical ;
1560
                       npa:aliasSpace     ?alias ;
1561
                       npa:pubkeyHash     ?pkh ;
1562
                       npa:viaNanopub     ?np .
1563
                  }
1564
                  # 2. Authority gate per canonical ref: ?canonRef is a ref of ?canonical
1565
                  #    whose admin set contains the declaration's publisher.
1566
                  GRAPH <%4$s> { ?canonRef npa:spaceIri ?canonical . }
1567
                  GRAPH <%3$s> {
1568
                    ?acct a npa:AccountState ;
1569
                          npa:pubkey ?pkh ;
1570
                          npa:agent  ?publisher .
1571
                    ?adminRI a gen:RoleInstantiation ;
1572
                             npa:inverseProperty gen:hasAdmin ;
1573
                             npa:forSpaceRef ?canonRef ;
1574
                             npa:forAgent ?publisher .
1575
                  }
1576
                  # 3. Anti-hijack: the alias IRI must have no admin who is not also an
1577
                  #    admin of this canonical ref (admins(alias) ⊆ admins(canonRef)).
1578
                  FILTER NOT EXISTS {
1579
                    GRAPH <%3$s> {
1580
                      ?aliasAdmin a gen:RoleInstantiation ;
1581
                                  npa:inverseProperty gen:hasAdmin ;
1582
                                  npa:forSpace ?alias ;
1583
                                  npa:forAgent ?otherAgent .
1584
                    }
1585
                    FILTER NOT EXISTS {
1586
                      GRAPH <%3$s> {
1587
                        ?canonAdmin a gen:RoleInstantiation ;
1588
                                    npa:inverseProperty gen:hasAdmin ;
1589
                                    npa:forSpaceRef ?canonRef ;
1590
                                    npa:forAgent ?otherAgent .
1591
                      }
1592
                    }
1593
                  }
1594
                  # 4. Invalidation filter on the declaration's nanopub.
1595
                  %6$s
1596
                  # 5. Load-number filter on bound ?np.
1597
                  GRAPH <%7$s> {
1598
                    ?np npa:hasLoadNumber ?ln .
1599
                    FILTER (?ln > %5$d)
1600
                  }
1601
                  # 6. Dedup last — on the emitted (alias, canonical ref) edge.
1602
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1603
                    ?alias npa:sameAsSpace ?canonRef .
1604
                  } }
1605
                }
1606
                """.formatted(
3✔
1607
                NPA.NAMESPACE,
1608
                GEN.NAMESPACE,
1609
                graph,
1610
                SpacesVocab.SPACES_GRAPH,
1611
                lastProcessed,
15✔
1612
                invalidationFilter("np"),
18✔
1613
                NPA.GRAPH);
1614
    }
1615

1616
    /**
1617
     * URL-prefix sub-space fallback admit pass. For every pair of {@code SpaceRef}
1618
     * aggregates where the child's {@code npa:hasIdPrefix} matches the parent's
1619
     * {@code npa:spaceIri}, emits convenience {@code <child> npa:isSubSpaceOf <parent>}
1620
     * and {@code <parent> npa:hasSubSpace <child>} direct triples plus a reified
1621
     * {@code npa:DerivedSubSpaceLink} tag carrying {@code npa:derivationKind
1622
     * npa:byUrlPrefix} so consumers can hide derived edges.
1623
     *
1624
     * <p>Per-child suppression: any validated {@code npa:SubSpaceDeclaration} on the
1625
     * child in {@code npass:<…>} suppresses every fallback edge for that child.
1626
     * Suppression checks the validated set (not raw extraction-graph declarations)
1627
     * so an unapproved or in-flight Mode B declaration doesn't silently hide both
1628
     * the URL-prefix fallback and the (still-invalid) explicit relation.
1629
     *
1630
     * <p>Run order: must run after {@link #subSpaceAdmitUpdate} commits in the
1631
     * same cycle so the suppression check sees this cycle's freshly-validated
1632
     * declarations.
1633
     *
1634
     * <p>No load-number filter: the fallback depends on which Spaces exist (parent
1635
     * + child {@code SpaceRef}s), not on which were just added. Always full-scan;
1636
     * the dedup {@code FILTER NOT EXISTS} on the tag IRI prevents re-insertion.
1637
     *
1638
     * <p>No invalidation handling: derived edges have no source nanopub. Two
1639
     * staleness modes: (a) child later gets first validated declaration → old
1640
     * derived edges stay sticky until the next periodic rebuild (same policy as
1641
     * admin-RI invalidation); (b) child loses last validated declaration → the
1642
     * regular fallback pass on the next cycle re-engages, adds derived edges
1643
     * incrementally, no rebuild needed.
1644
     */
1645
    static String subSpacePrefixFallbackUpdate(IRI graph) {
1646
        return """
48✔
1647
                PREFIX npa: <%1$s>
1648
                INSERT { GRAPH <%2$s> {
1649
                  ?childRef  npa:isSubSpaceOf ?parentRef .
1650
                  ?parentRef npa:hasSubSpace  ?childRef  .
1651
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
1652
                  # derived sub-space edge alongside the ref-to-ref one, mirroring the
1653
                  # explicit sub-space pass, so pre-ref published queries keep binding on a
1654
                  # mixed-version fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
1655
                  ?child  npa:isSubSpaceOf ?parent .
1656
                  ?parent npa:hasSubSpace  ?child  .
1657
                  ?tagIri a npa:DerivedSubSpaceLink ;
1658
                          npa:childSpace     ?child ;
1659
                          npa:parentSpace    ?parent ;
1660
                          npa:derivationKind npa:byUrlPrefix .
1661
                } }
1662
                WHERE {
1663
                  # 1. Anchor: child SpaceRef → its path-prefixes (extracted at load
1664
                  #    time from the Space IRI; see SpacesExtractor.enumerateIdPrefixes).
1665
                  GRAPH <%3$s> {
1666
                    ?childRef  npa:spaceIri    ?child ;
1667
                               npa:hasIdPrefix ?parent .
1668
                    # 2. Parent SpaceRef must exist for the same IRI as the prefix.
1669
                    ?parentRef npa:spaceIri    ?parent .
1670
                  }
1671
                  # 3. Suppress fallback for any child that has a validated declaration
1672
                  #    in this state graph. Per-child IRI, all-or-nothing.
1673
                  FILTER NOT EXISTS {
1674
                    GRAPH <%2$s> {
1675
                      ?d a npa:SubSpaceDeclaration ;
1676
                         npa:childSpace ?child .
1677
                    }
1678
                  }
1679
                  # 4. Mint a deterministic tag IRI per (child ref, parent ref) — the edge
1680
                  #    is emitted ref-to-ref, so the tag and dedup are per ref-pair.
1681
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/derivedlink/",
1682
                                  MD5(CONCAT(STR(?childRef), "|", STR(?parentRef))))) AS ?tagIri)
1683
                  # 5. Dedup: don't re-insert if this tag is already present.
1684
                  FILTER NOT EXISTS {
1685
                    GRAPH <%2$s> {
1686
                      ?tagIri a npa:DerivedSubSpaceLink .
1687
                    }
1688
                  }
1689
                }
1690
                """.formatted(
3✔
1691
                NPA.NAMESPACE,
1692
                graph,
1693
                SpacesVocab.SPACES_GRAPH);
1694
    }
1695

1696
    // ---------------- Invalidation templates (incremental cycle) ----------------
1697

1698
    /**
1699
     * WHERE clause shared by the admin-RI invalidation ASK precheck and the
1700
     * matching DELETE. Identifies admin-tier {@code gen:RoleInstantiation} rows
1701
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
1702
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
1703
     * has a load number in {@code (lastProcessed, ∞)}.
1704
     */
1705
    static String adminInvalidationCheckWhere(IRI graph, long lastProcessed) {
1706
        return String.format("""
60✔
1707
                  GRAPH <%1$s> {
1708
                    ?ri a gen:RoleInstantiation ;
1709
                        npa:inverseProperty gen:hasAdmin ;
1710
                        npa:viaNanopub ?np .
1711
                  }
1712
                  GRAPH <%2$s> {
1713
                    ?invNp <%3$s> ?np ;
1714
                           npa:hasLoadNumber ?ln .
1715
                    FILTER (?ln > %4$d)
1716
                  }
1717
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1718
    }
1719

1720
    /** DELETE template for admin-tier RoleInstantiations whose source nanopub was invalidated. */
1721
    static String adminInvalidationDelete(IRI graph, long lastProcessed) {
1722
        return String.format("""
63✔
1723
                PREFIX npa: <%1$s>
1724
                PREFIX gen: <%2$s>
1725
                DELETE { GRAPH <%3$s> {
1726
                  ?ri ?p ?o .
1727
                } }
1728
                WHERE {
1729
                  GRAPH <%3$s> { ?ri ?p ?o . }
1730
                %4$s
1731
                }
1732
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1733
                adminInvalidationCheckWhere(graph, lastProcessed));
6✔
1734
    }
1735

1736
    /** WHERE clause for RoleAssignment invalidation. */
1737
    static String roleAssignmentInvalidationCheckWhere(IRI graph, long lastProcessed) {
1738
        return String.format("""
60✔
1739
                  GRAPH <%1$s> {
1740
                    ?ra a gen:RoleAssignment ;
1741
                        npa:viaNanopub ?np .
1742
                  }
1743
                  GRAPH <%2$s> {
1744
                    ?invNp <%3$s> ?np ;
1745
                           npa:hasLoadNumber ?ln .
1746
                    FILTER (?ln > %4$d)
1747
                  }
1748
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1749
    }
1750

1751
    /** DELETE template for RoleAssignments whose source nanopub was invalidated. */
1752
    static String roleAssignmentInvalidationDelete(IRI graph, long lastProcessed) {
1753
        return String.format("""
63✔
1754
                PREFIX npa: <%1$s>
1755
                PREFIX gen: <%2$s>
1756
                DELETE { GRAPH <%3$s> {
1757
                  ?ra ?p ?o .
1758
                } }
1759
                WHERE {
1760
                  GRAPH <%3$s> { ?ra ?p ?o . }
1761
                %4$s
1762
                }
1763
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1764
                roleAssignmentInvalidationCheckWhere(graph, lastProcessed));
6✔
1765
    }
1766

1767
    /**
1768
     * WHERE clause for RoleDeclaration invalidation. ASK-only (no DELETE):
1769
     * RoleDeclarations live in {@code npa:spacesGraph} and aren't materialized
1770
     * into the space-state graph, so there's nothing to remove from the
1771
     * space-state. The ASK still flips {@code npa:needsFullRebuild} because
1772
     * sticky downstream RIs that were derived under the now-invalidated RD
1773
     * need a from-scratch recompute.
1774
     */
1775
    static String roleDeclarationInvalidationCheckWhere(long lastProcessed) {
1776
        return String.format("""
60✔
1777
                  GRAPH <%1$s> {
1778
                    ?rd a npa:RoleDeclaration ;
1779
                        npa:viaNanopub ?np .
1780
                  }
1781
                  GRAPH <%2$s> {
1782
                    ?invNp <%3$s> ?np ;
1783
                           npa:hasLoadNumber ?ln .
1784
                    FILTER (?ln > %4$d)
1785
                  }
1786
                """, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1787
    }
1788

1789
    /**
1790
     * DELETE template for non-admin (leaf-tier) RoleInstantiations whose source
1791
     * nanopub was invalidated. Identified as {@code gen:RoleInstantiation} rows
1792
     * lacking the admin-pinning {@code npa:inverseProperty gen:hasAdmin} triple.
1793
     * No flag is set; leaf-tier removals are recoverable on the next cycle.
1794
     */
1795
    static String leafTierInvalidationDelete(IRI graph, long lastProcessed) {
1796
        return String.format("""
84✔
1797
                PREFIX npa: <%1$s>
1798
                PREFIX gen: <%2$s>
1799
                DELETE { GRAPH <%3$s> {
1800
                  ?ri ?p ?o .
1801
                } }
1802
                WHERE {
1803
                  GRAPH <%3$s> {
1804
                    ?ri a gen:RoleInstantiation ;
1805
                        npa:viaNanopub ?np .
1806
                    FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
1807
                    ?ri ?p ?o .
1808
                  }
1809
                  GRAPH <%4$s> {
1810
                    ?invNp <%5$s> ?np ;
1811
                           npa:hasLoadNumber ?ln .
1812
                    FILTER (?ln > %6$d)
1813
                  }
1814
                }
1815
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1816
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1817
    }
1818

1819
    /**
1820
     * WHERE clause shared by the sub-space invalidation ASK precheck and the
1821
     * matching DELETE. Identifies validated {@code npa:SubSpaceDeclaration} rows
1822
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
1823
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
1824
     * has a load number in {@code (lastProcessed, ∞)}.
1825
     */
1826
    static String subSpaceInvalidationCheckWhere(IRI graph, long lastProcessed) {
1827
        return String.format("""
60✔
1828
                  GRAPH <%1$s> {
1829
                    ?d a npa:SubSpaceDeclaration ;
1830
                       npa:viaNanopub ?np .
1831
                  }
1832
                  GRAPH <%2$s> {
1833
                    ?invNp <%3$s> ?np ;
1834
                           npa:hasLoadNumber ?ln .
1835
                    FILTER (?ln > %4$d)
1836
                  }
1837
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1838
    }
1839

1840
    /**
1841
     * DELETE template for validated {@code npa:SubSpaceDeclaration} rows whose
1842
     * source nanopub was invalidated. Removes the per-declaration row by subject;
1843
     * the convenience direct triples ({@code <child> npa:isSubSpaceOf <parent>}
1844
     * and inverse) are left sticky and cleaned by the next periodic full rebuild
1845
     * (same staleness policy as admin-RI invalidation — see {@code
1846
     * doc/design-space-repositories.md} on the structural-rebuild flag).
1847
     */
1848
    static String subSpaceInvalidationDelete(IRI graph, long lastProcessed) {
1849
        return String.format("""
63✔
1850
                PREFIX npa: <%1$s>
1851
                PREFIX gen: <%2$s>
1852
                DELETE { GRAPH <%3$s> {
1853
                  ?d ?p ?o .
1854
                } }
1855
                WHERE {
1856
                  GRAPH <%3$s> { ?d ?p ?o . }
1857
                %4$s
1858
                }
1859
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1860
                subSpaceInvalidationCheckWhere(graph, lastProcessed));
6✔
1861
    }
1862

1863
    /**
1864
     * DELETE template for validated {@code npa:MaintainedResourceDeclaration} rows
1865
     * whose source nanopub was invalidated. Removes the per-declaration row by
1866
     * subject; the convenience direct triples ({@code <r> npa:isMaintainedBy <s>}
1867
     * and inverse) are left sticky and cleaned by the next periodic full rebuild
1868
     * (same staleness policy as sub-space declaration invalidation, but without
1869
     * the structural-rebuild flag — maintained-resource is a leaf relation, no
1870
     * downstream consumers depend on its closure).
1871
     */
1872
    static String maintainedResourceInvalidationDelete(IRI graph, long lastProcessed) {
1873
        return String.format("""
84✔
1874
                PREFIX npa: <%1$s>
1875
                PREFIX gen: <%2$s>
1876
                DELETE { GRAPH <%3$s> {
1877
                  ?d ?p ?o .
1878
                } }
1879
                WHERE {
1880
                  GRAPH <%3$s> {
1881
                    ?d a npa:MaintainedResourceDeclaration ;
1882
                       npa:viaNanopub ?np .
1883
                    ?d ?p ?o .
1884
                  }
1885
                  GRAPH <%4$s> {
1886
                    ?invNp <%5$s> ?np ;
1887
                           npa:hasLoadNumber ?ln .
1888
                    FILTER (?ln > %6$d)
1889
                  }
1890
                }
1891
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1892
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1893
    }
1894

1895
    /**
1896
     * WHERE clause shared by the alias invalidation ASK precheck and the matching
1897
     * DELETE. Identifies validated {@code npa:SpaceAliasDeclaration} rows in the
1898
     * space-state graph whose {@code npa:viaNanopub} is the target of an
1899
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
1900
     * load number in {@code (lastProcessed, ∞)}.
1901
     */
1902
    static String aliasInvalidationCheckWhere(IRI graph, long lastProcessed) {
1903
        return String.format("""
60✔
1904
                  GRAPH <%1$s> {
1905
                    ?d a npa:SpaceAliasDeclaration ;
1906
                       npa:viaNanopub ?np .
1907
                  }
1908
                  GRAPH <%2$s> {
1909
                    ?invNp <%3$s> ?np ;
1910
                           npa:hasLoadNumber ?ln .
1911
                    FILTER (?ln > %4$d)
1912
                  }
1913
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed);
6✔
1914
    }
1915

1916
    /**
1917
     * DELETE template for validated {@code npa:SpaceAliasDeclaration} rows whose
1918
     * source nanopub was invalidated. Removes the per-declaration row by subject; the
1919
     * convenience {@code <alias> npa:sameAsSpace <canonical>} edge is left sticky and
1920
     * cleaned by the next periodic full rebuild (same staleness policy as sub-space
1921
     * declaration invalidation — the alias feeds the authority closure, so this kind
1922
     * is structural and flips {@code npa:needsFullRebuild}).
1923
     */
1924
    static String aliasInvalidationDelete(IRI graph, long lastProcessed) {
1925
        return String.format("""
63✔
1926
                PREFIX npa: <%1$s>
1927
                PREFIX gen: <%2$s>
1928
                DELETE { GRAPH <%3$s> {
1929
                  ?d ?p ?o .
1930
                } }
1931
                WHERE {
1932
                  GRAPH <%3$s> { ?d ?p ?o . }
1933
                %4$s
1934
                }
1935
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
1936
                aliasInvalidationCheckWhere(graph, lastProcessed));
6✔
1937
    }
1938

1939
    /**
1940
     * WHERE clause shared by the preset-deactivation ASK precheck and the matching DELETE
1941
     * (Nanodash issue #302). Binds {@code ?ra} = a materialized preset-derived
1942
     * {@code gen:RoleAssignment} ({@code npa:derivedFromPreset}) for which a <em>newer,
1943
     * admin-authored</em> same-{@code (preset, resource)} assignment exists by
1944
     * {@code dct:created} (load number in {@code (lastProcessed, ∞)}). This is NOT an
1945
     * {@code npx:invalidates} check — preset activation is latest-wins by timestamp.
1946
     *
1947
     * <p>Authorization-scoped (anti-hijack, design doc §3/§4.4): the newer assignment's
1948
     * publisher must itself be a validated admin of the row's {@code npa:forSpaceRef}, so an
1949
     * unauthorized key's newer assignment can neither delete nor shadow an admin's
1950
     * materialized role. {@code dct:created} is written as a full IRI (not a {@code dct:}
1951
     * prefix) because {@link #wouldInvalidate}'s ASK wrapper only declares {@code npa:} /
1952
     * {@code gen:}.
1953
     */
1954
    static String presetDeactivationCheckWhere(IRI graph, long lastProcessed) {
1955
        return String.format("""
60✔
1956
                  GRAPH <%1$s> {
1957
                    ?ra a gen:RoleAssignment ;
1958
                        npa:derivedFromPreset ?assignNp ;
1959
                        npa:forSpaceRef ?targetRef .
1960
                  }
1961
                  GRAPH <%2$s> {
1962
                    ?pa a npa:PresetAssignment ;
1963
                        npa:viaNanopub  ?assignNp ;
1964
                        npa:ofPreset    ?preset ;
1965
                        npa:forResource ?resource ;
1966
                        <http://purl.org/dc/terms/created> ?created .
1967
                    ?paNewer a npa:PresetAssignment ;
1968
                             npa:ofPreset    ?preset ;
1969
                             npa:forResource ?resource ;
1970
                             npa:pubkeyHash  ?pkhNewer ;
1971
                             npa:viaNanopub  ?assignNpNewer ;
1972
                             <http://purl.org/dc/terms/created> ?createdNewer .
1973
                    FILTER (?createdNewer > ?created
1974
                            || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
1975
                  }
1976
                  GRAPH <%3$s> {
1977
                    ?assignNpNewer npa:hasLoadNumber ?lnNewer .
1978
                    FILTER (?lnNewer > %4$d)
1979
                  }
1980
                  GRAPH <%1$s> {
1981
                    ?acctNewer a npa:AccountState ;
1982
                               npa:agent  ?publisherNewer ;
1983
                               npa:pubkey ?pkhNewer .
1984
                    ?adminRINewer a gen:RoleInstantiation ;
1985
                                  npa:forSpaceRef ?targetRef ;
1986
                                  npa:inverseProperty gen:hasAdmin ;
1987
                                  npa:forAgent ?publisherNewer .
1988
                  }
1989
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
1990
    }
1991

1992
    /**
1993
     * DELETE template for preset-derived {@code gen:RoleAssignment} rows superseded by a
1994
     * newer admin-authored same-pair assignment (issue #302). Removes the whole row by
1995
     * subject; scoped via {@code npa:derivedFromPreset} so directly-published attachments
1996
     * are never touched. The {@link #presetAttachmentValidationUpdate} re-INSERT in the
1997
     * same cycle re-materializes the pair iff the newest assignment is still active.
1998
     */
1999
    static String presetDeactivationDelete(IRI graph, long lastProcessed) {
2000
        return String.format("""
63✔
2001
                PREFIX npa: <%1$s>
2002
                PREFIX gen: <%2$s>
2003
                DELETE { GRAPH <%3$s> {
2004
                  ?ra ?p ?o .
2005
                } }
2006
                WHERE {
2007
                  GRAPH <%3$s> { ?ra ?p ?o . }
2008
                %4$s
2009
                }
2010
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2011
                presetDeactivationCheckWhere(graph, lastProcessed));
6✔
2012
    }
2013

2014
    /** Wraps an ASK by joining the shared prefixes. */
2015
    private boolean wouldInvalidate(IRI graph, long lastProcessed,
2016
                                    boolean adminPinned, String whereClause) {
2017
        // adminPinned is informational only — kept to make call sites read clearly;
2018
        // the WHERE clause already encodes the kind via its own type predicates.
2019
        String ask = String.format("""
×
2020
                PREFIX npa: <%1$s>
2021
                PREFIX gen: <%2$s>
2022
                ASK { %3$s }
2023
                """, NPA.NAMESPACE, GEN.NAMESPACE, whereClause);
2024
        return runAsk(ask);
×
2025
    }
2026

2027
    private boolean runAsk(String sparql) {
2028
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2029
            return conn.prepareBooleanQuery(QueryLanguage.SPARQL, sparql).evaluate();
×
2030
        }
2031
    }
2032

2033
    private void executeUpdate(String sparqlUpdate) {
2034
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2035
            conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
2036
        }
2037
    }
×
2038

2039
    // ---------------- Mirror step ----------------
2040

2041
    /**
2042
     * Copies trust-approved {@code npa:AccountState} rows from {@code npat:<T>}
2043
     * in the {@code trust} repo into {@code newGraph} in the {@code spaces} repo,
2044
     * inside one spaces-side serializable transaction.
2045
     *
2046
     * @return number of rows mirrored (useful for metrics / logging)
2047
     */
2048
    int mirrorTrustState(String trustStateHash, IRI newGraph) {
2049
        IRI trustStateIri = NPAT.forHash(trustStateHash);
×
2050
        int count = 0;
×
2051
        try (RepositoryConnection trustConn = TripleStore.get().getRepoConnection(TRUST_REPO);
×
2052
             RepositoryConnection spacesConn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2053
            trustConn.begin(IsolationLevels.READ_COMMITTED);
×
2054
            spacesConn.begin(IsolationLevels.SERIALIZABLE);
×
2055
            // Walk rdf:type triples in the trust state's graph; for each AccountState,
2056
            // check status and copy the approved ones verbatim (minus status-specific
2057
            // detail triples, which we don't need for validation).
2058
            try (RepositoryResult<Statement> typeRows = trustConn.getStatements(
×
2059
                    null, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri)) {
2060
                while (typeRows.hasNext()) {
×
2061
                    Statement st = typeRows.next();
×
2062
                    if (!(st.getSubject() instanceof IRI accountStateIri)) continue;
×
2063
                    Value status = trustConn.getStatements(accountStateIri, NPA_TRUST_STATUS, null, trustStateIri)
×
2064
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
2065
                    if (!(status instanceof IRI statusIri) || !APPROVED_SET.contains(statusIri)) continue;
×
2066
                    Value agent = trustConn.getStatements(accountStateIri, NPA_AGENT, null, trustStateIri)
×
2067
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
2068
                    Value pubkey = trustConn.getStatements(accountStateIri, NPA_PUBKEY, null, trustStateIri)
×
2069
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
2070
                    if (agent == null || pubkey == null) {
×
2071
                        logger.warn("AuthorityResolver.mirror: account {} missing agent or pubkey; skipping",
×
2072
                                accountStateIri);
2073
                        continue;
×
2074
                    }
2075
                    spacesConn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, newGraph);
×
2076
                    spacesConn.add(accountStateIri, NPA_AGENT, agent, newGraph);
×
2077
                    spacesConn.add(accountStateIri, NPA_PUBKEY, pubkey, newGraph);
×
2078
                    spacesConn.add(accountStateIri, NPA_TRUST_STATUS, statusIri, newGraph);
×
2079
                    count++;
×
2080
                }
×
2081
            }
2082
            // Mirror canonical foaf:name triples for approved agents. The trust
2083
            // loader emits one per agent (across approved keys, MAX(ratio) wins).
2084
            // Copying them into the space-state graph means consumers reading
2085
            // ?agent foaf:name ?n inside the state graph hit local data, with no
2086
            // cross-repo SERVICE.
2087
            try (RepositoryResult<Statement> nameRows = trustConn.getStatements(
×
2088
                    null, FOAF.NAME, null, trustStateIri)) {
2089
                while (nameRows.hasNext()) {
×
2090
                    Statement st = nameRows.next();
×
2091
                    spacesConn.add(st.getSubject(), st.getPredicate(), st.getObject(), newGraph);
×
2092
                }
×
2093
            }
2094
            spacesConn.commit();
×
2095
            trustConn.commit();
×
2096
        }
2097
        return count;
×
2098
    }
2099

2100
    // ---------------- Pointer + counter helpers ----------------
2101

2102
    /**
2103
     * Reads the current {@code npa:hasCurrentSpaceState} pointer from the
2104
     * {@code npa:graph} admin graph of the {@code spaces} repo. Returns
2105
     * {@code null} if no pointer exists yet.
2106
     */
2107
    IRI getCurrentSpaceStateGraph() {
2108
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2109
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
2110
                    SpacesVocab.HAS_CURRENT_SPACE_STATE);
2111
            return (v instanceof IRI iri) ? iri : null;
×
2112
        } catch (Exception ex) {
×
2113
            logger.warn("AuthorityResolver: failed to read hasCurrentSpaceState pointer: {}", ex.toString());
×
2114
            return null;
×
2115
        }
2116
    }
2117

2118
    long getCurrentLoadCounter() {
2119
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2120
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
2121
                    SpacesVocab.CURRENT_LOAD_COUNTER);
2122
            if (v == null) return 0;
×
2123
            try {
2124
                return Long.parseLong(v.stringValue());
×
2125
            } catch (NumberFormatException ex) {
×
2126
                logger.warn("AuthorityResolver: non-numeric currentLoadCounter: {}", v);
×
2127
                return 0;
×
2128
            }
2129
        } catch (Exception ex) {
×
2130
            logger.warn("AuthorityResolver: failed to read currentLoadCounter: {}", ex.toString());
×
2131
            return 0;
×
2132
        }
2133
    }
2134

2135
    /**
2136
     * Atomic pointer flip: a single SPARQL {@code DELETE … INSERT … WHERE}
2137
     * replaces the old pointer with the new one in one statement, so readers
2138
     * never see a zero-pointer window.
2139
     */
2140
    void flipPointer(IRI newGraph) {
2141
        String update = String.format("""
×
2142
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
2143
                INSERT { GRAPH <%s> { <%s> <%s> <%s> } }
2144
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
2145
                """,
2146
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE,
2147
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE, newGraph,
2148
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE);
2149
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2150
            conn.begin(IsolationLevels.SERIALIZABLE);
×
2151
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
2152
            conn.commit();
×
2153
        }
2154
    }
×
2155

2156
    void writeProcessedUpTo(IRI graph, long loadCounter) {
2157
        String update = String.format("""
×
2158
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
2159
                INSERT { GRAPH <%s> { <%s> <%s> "%d"^^<http://www.w3.org/2001/XMLSchema#long> } }
2160
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
2161
                """,
2162
                graph, graph, SpacesVocab.PROCESSED_UP_TO,
2163
                graph, graph, SpacesVocab.PROCESSED_UP_TO, loadCounter,
×
2164
                graph, graph, SpacesVocab.PROCESSED_UP_TO);
2165
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2166
            conn.begin(IsolationLevels.SERIALIZABLE);
×
2167
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
2168
            conn.commit();
×
2169
        }
2170
    }
×
2171

2172
    /**
2173
     * Reads {@code processedUpTo} from the given space-state graph.
2174
     * Returns {@code -1} if absent (graph not fully built yet).
2175
     */
2176
    long readProcessedUpTo(IRI graph) {
2177
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2178
            String query = String.format(
×
2179
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
2180
                    graph, graph, SpacesVocab.PROCESSED_UP_TO);
2181
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
2182
                if (!r.hasNext()) return -1;
×
2183
                BindingSet b = r.next();
×
2184
                return Long.parseLong(b.getBinding("n").getValue().stringValue());
×
2185
            }
×
2186
        } catch (Exception ex) {
×
2187
            logger.warn("AuthorityResolver: failed to read processedUpTo for {}: {}", graph, ex.toString());
×
2188
            return -1;
×
2189
        }
2190
    }
2191

2192
    /**
2193
     * Reads the {@code npa:needsFullRebuild} flag (boolean literal) from
2194
     * {@code npa:graph} in the {@code spaces} repo. Defaults to {@code false}
2195
     * when the triple is absent.
2196
     */
2197
    boolean readNeedsFullRebuild() {
2198
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2199
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
2200
                    SpacesVocab.NEEDS_FULL_REBUILD);
2201
            return v != null && Boolean.parseBoolean(v.stringValue());
×
2202
        } catch (Exception ex) {
×
2203
            logger.warn("AuthorityResolver: failed to read needsFullRebuild: {}", ex.toString());
×
2204
            return false;
×
2205
        }
2206
    }
2207

2208
    void setNeedsFullRebuild() {
2209
        writeNeedsFullRebuild(true);
×
2210
    }
×
2211

2212
    void clearNeedsFullRebuild() {
2213
        writeNeedsFullRebuild(false);
×
2214
    }
×
2215

2216
    private void writeNeedsFullRebuild(boolean value) {
2217
        String update = String.format("""
×
2218
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
2219
                INSERT { GRAPH <%s> { <%s> <%s> "%s"^^<http://www.w3.org/2001/XMLSchema#boolean> } }
2220
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
2221
                """,
2222
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD,
2223
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD, value,
×
2224
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD);
2225
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2226
            conn.begin(IsolationLevels.SERIALIZABLE);
×
2227
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
2228
            conn.commit();
×
2229
        }
2230
    }
×
2231

2232
    void dropGraph(IRI graph) {
2233
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
2234
            conn.begin(IsolationLevels.SERIALIZABLE);
×
2235
            conn.clear(graph);
×
2236
            conn.commit();
×
2237
            logger.info("AuthorityResolver: dropped old space-state graph {}", graph);
×
2238
        }
2239
    }
×
2240

2241
    // ---------------- Trust-repo pointer lookup (used by TrustStateRegistry's bootstrap) ----------------
2242

2243
    /**
2244
     * Queries the {@code trust} repo directly for the current trust-state hash.
2245
     * Prefer {@link TrustStateRegistry#getCurrentHash()} in normal operation —
2246
     * this helper exists for tests and diagnostics.
2247
     *
2248
     * @return the current trust-state hash, or empty if none is set
2249
     */
2250
    Optional<String> readTrustRepoCurrentHash() {
2251
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
2252
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
2253
                    NPA_HAS_CURRENT_TRUST_STATE);
2254
            if (!(v instanceof IRI iri)) return Optional.empty();
×
2255
            String s = iri.stringValue();
×
2256
            if (!s.startsWith(NPAT.NAMESPACE)) return Optional.empty();
×
2257
            return Optional.of(s.substring(NPAT.NAMESPACE.length()));
×
2258
        } catch (Exception ex) {
×
2259
            logger.warn("AuthorityResolver: failed to read trust-repo current pointer: {}", ex.toString());
×
2260
            return Optional.empty();
×
2261
        }
2262
    }
2263

2264
    private static String abbrev(String hash) {
2265
        return hash.length() > 12 ? hash.substring(0, 12) + "…" : hash;
×
2266
    }
2267

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