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

knowledgepixels / nanopub-query / 32582449307

22 Aug 2026 03:41PM UTC coverage: 76.67% (+0.2%) from 76.49%
32582449307

Pull #194

github

web-flow
Merge d7176f4be into f6b8c5d42
Pull Request #194: Detect and self-heal truncated space-state graphs via an integrity triple-count stamp

859 of 1264 branches covered (67.96%)

Branch coverage included in aggregate %.

2503 of 3121 relevant lines covered (80.2%)

12.6 hits per line

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

91.67
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_VIA_NANOPUB = vf.createIRI(NPA.NAMESPACE, "viaNanopub");
15✔
72
    private static final IRI NPA_LOADED = vf.createIRI(NPA.NAMESPACE, "loaded");
15✔
73
    private static final IRI NPA_TO_LOAD = vf.createIRI(NPA.NAMESPACE, "toLoad");
15✔
74

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

85
    private static AuthorityResolver instance;
86

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

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

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

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

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

119
    /**
120
     * Raised when the space-state bookkeeping in the {@code spaces} repo cannot be
121
     * <em>read</em>, as opposed to being legitimately absent.
122
     *
123
     * <p>The distinction is the whole point. Before 2026-08-05 every reader here
124
     * collapsed both cases onto the same value — {@code null} pointer, load counter
125
     * {@code 0}, {@code processedUpTo} {@code -1} — so a degraded RDF4J looked
126
     * identical to a fresh install. On that day RDF4J was answering reads with
127
     * {@code Read timed out}; {@link #tick()} saw a {@code null} pointer, logged a
128
     * "trust-state flip" that had not happened, and ran a full build whose every
129
     * source read also failed. The build inserted nothing, published the resulting
130
     * empty graph, and dropped the previous good one — 2730 triples of live space
131
     * state, on a query server that then served zero rows to every state query for
132
     * hours. A sibling instance that stayed healthy still had all of it.
133
     *
134
     * <p>Throwing instead lets the caller abort. Doing nothing this tick is always
135
     * safe; acting on a failed read is not.
136
     */
137
    static class SpaceStateUnavailableException extends RuntimeException {
138
        SpaceStateUnavailableException(String message, Throwable cause) {
139
            super(message, cause);
12✔
140
        }
3✔
141
    }
142

143
    // ---------------- Public entry points ----------------
144

145
    /**
146
     * Poll entry point. Behaviour:
147
     * <ul>
148
     *   <li>If no current space-state graph or the trust state has flipped → full build.</li>
149
     *   <li>Otherwise → {@link #runIncrementalCycle incremental cycle} on the load-number
150
     *       delta {@code (processedUpTo, currentLoadCounter]}. No-op if {@code
151
     *       processedUpTo == currentLoadCounter}.</li>
152
     * </ul>
153
     * Safe to call repeatedly on a schedule. Gated by {@link FeatureFlags#spacesEnabled()}.
154
     */
155
    public void tick() {
156
        if (!FeatureFlags.spacesEnabled()) return;
9✔
157
        String trustStateHash = TrustStateRegistry.get().getCurrentHash().orElse(null);
18✔
158
        if (trustStateHash == null) {
6✔
159
            logger.debug("AuthorityResolver.tick: no current trust state yet — skipping");
9✔
160
            return;
3✔
161
        }
162
        // Any of the reads below may throw SpaceStateUnavailableException. Let it
163
        // propagate: the caller logs "AuthorityResolver tick failed" and we retry on
164
        // the next tick with the state untouched.
165
        IRI currentGraph = getCurrentSpaceStateGraph();
9✔
166
        String currentGraphName = (currentGraph == null) ? null
12✔
167
                : currentGraph.stringValue().substring(SpacesVocab.NPASS_NAMESPACE.length());
18✔
168
        if (currentGraphName == null) {
6✔
169
            logger.info("AuthorityResolver.tick: no current space-state graph; running full build");
9✔
170
            runFullBuild(trustStateHash);
9✔
171
            return;
3✔
172
        }
173
        if (!currentGraphName.startsWith(trustStateHash + "_")) {
15✔
174
            logger.info("AuthorityResolver.tick: trust-state flip detected (now {}); running full build",
12✔
175
                    abbrev(trustStateHash));
3✔
176
            runFullBuild(trustStateHash);
9✔
177
            return;
3✔
178
        }
179
        // A pointer at a graph that never got its processedUpTo stamp means the build
180
        // that published it did not finish. runIncrementalCycle used to log "missing
181
        // processedUpTo; skipping" and return — every 2 s, forever, with every
182
        // state-backed query answering empty in the meantime. Rebuild instead.
183
        if (readProcessedUpTo(currentGraph) < 0) {
18✔
184
            logger.warn("AuthorityResolver.tick: current space-state graph {} has no processedUpTo "
12✔
185
                    + "stamp (incomplete or damaged build); running full build", currentGraph);
186
            runFullBuild(trustStateHash);
9✔
187
            return;
3✔
188
        }
189
        // Integrity check: the stateTripleCount stamp is rewritten by every mutation,
190
        // so a disagreement means part of a write was lost after the fact — e.g. rdf4j
191
        // dropping acked-but-unmerged changesets across a restart (2026-08-22: a state
192
        // graph survived with 7,439 of 19,283 triples, processedUpTo intact, and served
193
        // truncated authority data until repaired by hand). A stamp of -1 is a graph
194
        // published by a pre-stamp version: skip, it becomes verifiable at its next
195
        // mutation.
196
        long expectedCount = readStateTripleCount(currentGraph);
12✔
197
        if (expectedCount >= 0) {
12✔
198
            long actualCount = countStateGraphTriples(currentGraph);
12✔
199
            if (actualCount != expectedCount) {
12✔
200
                logger.warn("AuthorityResolver.tick: current space-state graph {} holds {} triples "
36✔
201
                        + "but its stateTripleCount stamp says {} — truncated or partially lost "
202
                        + "state; running full build", currentGraph, actualCount, expectedCount);
21✔
203
                runFullBuild(trustStateHash);
9✔
204
                return;
3✔
205
            }
206
        }
207
        runIncrementalCycle(currentGraph);
9✔
208
    }
3✔
209

210
    /**
211
     * Periodic worker. If {@code npa:needsFullRebuild} was raised by an
212
     * incremental cycle's structural DELETE, runs a from-scratch rebuild into
213
     * a fresh space-state graph (using the current trust-state hash and load
214
     * counter) and clears the flag. No-op when the flag is not set. Safe to
215
     * call concurrently with {@link #tick()} when both are scheduled on the
216
     * same single-threaded executor.
217
     */
218
    public void periodicRebuildTick() {
219
        if (!FeatureFlags.spacesEnabled()) return;
9✔
220
        if (!readNeedsFullRebuild()) return;
12✔
221
        String trustStateHash = TrustStateRegistry.get().getCurrentHash().orElse(null);
18✔
222
        if (trustStateHash == null) {
6✔
223
            logger.debug("AuthorityResolver.periodicRebuildTick: no current trust state — deferring");
9✔
224
            return;
3✔
225
        }
226
        logger.info("AuthorityResolver.periodicRebuildTick: needsFullRebuild flag set; rebuilding");
9✔
227
        runFullBuild(trustStateHash);
9✔
228
        clearNeedsFullRebuild();
6✔
229
    }
3✔
230

231
    /**
232
     * Startup cleanup: drop any {@code npass:*} graph that the
233
     * {@code npa:hasCurrentSpaceState} pointer isn't pointing at. Orphans come
234
     * from crashes mid-build. Safe to call at any time; idempotent.
235
     */
236
    public synchronized void cleanOrphans() {
237
        if (!FeatureFlags.spacesEnabled()) return;
9✔
238
        IRI current;
239
        try {
240
            current = getCurrentSpaceStateGraph();
9✔
241
        } catch (SpaceStateUnavailableException ex) {
3✔
242
            // Every npass:* graph is "not the current one" when the pointer cannot be
243
            // read, so continuing here would drop the live state along with the
244
            // orphans. Skipping costs nothing: orphans are inert, and the next start
245
            // will clean them up.
246
            logger.warn("AuthorityResolver.cleanOrphans: cannot read the current-state pointer, "
12✔
247
                    + "skipping so orphan cleanup cannot delete the live graph: {}", ex.toString());
3✔
248
            return;
3✔
249
        }
3✔
250
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
251
            int dropped = 0;
6✔
252
            try (RepositoryResult<org.eclipse.rdf4j.model.Resource> ctxs = conn.getContextIDs()) {
9✔
253
                List<IRI> toDrop = new ArrayList<>();
12✔
254
                while (ctxs.hasNext()) {
9✔
255
                    org.eclipse.rdf4j.model.Resource ctx = ctxs.next();
12✔
256
                    if (!(ctx instanceof IRI iri)) continue;
18!
257
                    if (!iri.stringValue().startsWith(SpacesVocab.NPASS_NAMESPACE)) continue;
18✔
258
                    if (iri.equals(current)) continue;
15✔
259
                    toDrop.add(iri);
12✔
260
                }
3✔
261
                for (IRI iri : toDrop) {
30✔
262
                    conn.begin(IsolationLevels.SNAPSHOT);
9✔
263
                    conn.clear(iri);
24✔
264
                    conn.commit();
6✔
265
                    dropped++;
3✔
266
                    logger.info("AuthorityResolver.cleanOrphans: dropped orphan graph {}", iri);
12✔
267
                }
3✔
268
            }
269
            if (dropped == 0) {
6✔
270
                logger.debug("AuthorityResolver.cleanOrphans: no orphan space-state graphs");
9✔
271
            }
272
        } catch (Exception ex) {
×
273
            logger.info("AuthorityResolver.cleanOrphans: failed: {}", ex.toString());
×
274
        }
3✔
275
    }
3✔
276

277
    // ---------------- Full build ----------------
278

279
    /**
280
     * Mutex-protected full build of the space-state graph for the given trust
281
     * state. Captures {@code M = currentLoadCounter}, mirrors trust-approved
282
     * rows, (PR 2b: runs per-tier UPDATE loops from scratch), stamps
283
     * {@code processedUpTo = M}, flips the pointer, drops the previous graph.
284
     */
285
    synchronized void runFullBuild(String trustStateHash) {
286
        long startNanos = System.nanoTime();
6✔
287
        long loadCounter = getCurrentLoadCounter();
9✔
288
        IRI newGraph = SpacesVocab.forSpaceState(trustStateHash, loadCounter);
12✔
289
        IRI oldGraph = getCurrentSpaceStateGraph();
9✔
290
        boolean rebuildInPlace = newGraph.equals(oldGraph);
12✔
291
        if (rebuildInPlace) {
6✔
292
            // "Already current" is only true if that graph was actually finished AND
293
            // still holds everything it claims to. Without the processedUpTo check
294
            // this early return was the second half of the 2026-08-05 trap: once a
295
            // damaged graph was published, the pointer name still matched, so every
296
            // subsequent full build returned here and the instance could never repair
297
            // itself. The integrity-count check closes the same loophole for the
298
            // 2026-08-22 shape (graph truncated after publication, stamps intact) —
299
            // without it, tick() would detect the mismatch, call this method, and be
300
            // bounced right back here forever.
301
            long expected = readStateTripleCount(oldGraph);
12✔
302
            boolean countConsistent = expected < 0 || expected == countStateGraphTriples(oldGraph);
42✔
303
            if (readProcessedUpTo(oldGraph) >= 0 && countConsistent) {
24✔
304
                logger.debug("AuthorityResolver.runFullBuild: already current at {}", newGraph);
12✔
305
                return;
3✔
306
            }
307
            logger.warn("AuthorityResolver.runFullBuild: {} is the current graph but is "
39✔
308
                    + "unfinished or inconsistent (processedUpTo={}, countConsistent={}); "
309
                    + "rebuilding it in place", newGraph, readProcessedUpTo(oldGraph), countConsistent);
24✔
310
            dropGraph(newGraph);
9✔
311
        }
312

313
        // 1. Mirror trust-approved rows into the new graph.
314
        int mirrored = mirrorTrustState(trustStateHash, newGraph);
15✔
315

316
        // 2. Per-tier UPDATE loops (from scratch: lastProcessed = -1 so the
317
        //    delta filter FILTER(?ln > ?lastProcessed) includes everything).
318
        TierInsertedTriples counts = runAllTierLoops(newGraph, -1);
15✔
319

320
        // 2b. Refuse to publish an empty build over a state we already have — but only
321
        // when the emptiness cannot be true.
322
        //
323
        // Steps 4 and 5 below are destructive, so a build that read nothing must not
324
        // reach them. The trap is that "produced nothing" has two causes: every source
325
        // read failed, or the sources really are empty. Refusing in the second case
326
        // would pin a stale space state forever, and stale trust data is
327
        // over-permissive — revocations would stop propagating. That is the wrong way
328
        // to fail for a trust-derived state.
329
        //
330
        // So the condition is: nothing was produced *while the trust state still has
331
        // content to mirror*. That is the shape of a read failure. A genuinely empty
332
        // trust state yields an empty build and is published normally.
333
        //
334
        // Only guarded when a previous state exists: a genuinely empty first build on
335
        // a fresh instance has nothing to lose and must still be allowed to publish.
336
        //
337
        // Note this would NOT have fired on 2026-08-05: that build reported
338
        // subspace-prefix=2478, so it was not empty. The wipe there came from the
339
        // registry's trust state collapsing (correctly reflected) plus 2478 triples
340
        // that were reported inserted and then measured as zero. This guard is for the
341
        // total-read-failure case, which the same outage came close to several times.
342
        long insertedTotal = totalInserted(counts);
9✔
343
        if (mirrored == 0 && insertedTotal == 0 && oldGraph != null
30!
344
                && trustStateHasContent(trustStateHash)) {
6✔
345
            logger.error("AuthorityResolver.runFullBuild: build produced an empty state graph "
12✔
346
                    + "(mirrored=0, inserted=0) while trust state {} still has content and {} "
347
                    + "holds the current state — refusing to flip the pointer or drop it. "
348
                    + "This is the shape of a total read failure; the next tick will retry.",
349
                    abbrev(trustStateHash), oldGraph);
6✔
350
            if (!rebuildInPlace) {
6!
351
                dropGraph(newGraph);
9✔
352
            }
353
            return;
3✔
354
        }
355

356
        // 3. Stamp processedUpTo inside the new graph.
357
        writeProcessedUpTo(newGraph, loadCounter);
12✔
358

359
        // 3b. Stamp the integrity triple-count, so tick() can detect a graph
360
        //     that later loses part of its content (truncated writes, dropped
361
        //     changesets across a store restart) and rebuild it automatically.
362
        writeStateTripleCount(newGraph);
9✔
363

364
        // 4. Flip the current-space-state pointer.
365
        flipPointer(newGraph);
9✔
366

367
        // 5. Drop the old graph if a *different* one existed. Dropping it when
368
        //    rebuilding in place would delete what we just built.
369
        if (oldGraph != null && !rebuildInPlace) {
12✔
370
            dropGraph(oldGraph);
9✔
371
        }
372

373
        TierSubjectTotals totals = computeTierSubjectTotals(newGraph);
12✔
374
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
18✔
375
        lastSubjectTotals = totals;
9✔
376
        lastInsertedTriplesTotal = insertedTotal;
9✔
377
        lastFullBuildDurationMs = durationMs;
9✔
378
        lastProcessedUpToLag = 0L;
9✔
379
        logger.info("AuthorityResolver: full build complete — graph={} mirrored={} rows loadCounter={} "
36✔
380
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
381
                        + "(inserted-triples: admin={} alias={} preset-attachment={} preset-assignment-ref={} attachment={} maintainer={} member={} observer={} "
382
                        + "subspace={} subspace-prefix={} maintained-resource={} governing-space-ref={}) durationMs={}",
383
                newGraph, mirrored, loadCounter,
30✔
384
                totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(),
57✔
385
                counts.admin, counts.alias, counts.presetAttachment, counts.presetAssignmentRef, counts.attachment, counts.maintainer, counts.member, counts.observer,
144✔
386
                counts.subSpace, counts.subSpacePrefix, counts.maintainedResource, counts.governingSpaceRef,
69✔
387
                durationMs);
6✔
388
    }
3✔
389

390
    // ---------------- Incremental cycle ----------------
391

392
    /**
393
     * Single delta cycle on the current space-state graph. Bounded by
394
     * {@code (processedUpTo, currentLoadCounter]}; no-op if the range is empty.
395
     *
396
     * <p>Order:
397
     * <ol>
398
     *   <li>Apply invalidation DELETEs (admin RI, RoleAssignment, non-admin RI)
399
     *       and the RoleDeclaration ASK. Any DELETE on a structural kind sets
400
     *       {@code npa:needsFullRebuild} to bound the staleness from sticky
401
     *       downstream entries; the periodic worker turns that into a from-scratch
402
     *       rebuild on its next pass.</li>
403
     *   <li>Run per-tier INSERTs in the same order as the full build.</li>
404
     *   <li>Late-arrival sweep: if any structural row was added, re-run downstream
405
     *       tier INSERTs with {@code lastProcessed = -1} to catch candidates whose
406
     *       enabling event landed in this same cycle. Dedup filters protect
407
     *       against double-insert.</li>
408
     *   <li>Bump {@code processedUpTo} to {@code currentLoadCounter}.</li>
409
     * </ol>
410
     */
411
    synchronized void runIncrementalCycle(IRI graph) {
412
        long startNanos = System.nanoTime();
6✔
413
        long currentLoadCounter = getCurrentLoadCounter();
9✔
414
        long lastProcessed = readProcessedUpTo(graph);
12✔
415
        if (lastProcessed < 0) {
12✔
416
            // tick() now catches this first and rebuilds, so reaching here means a
417
            // direct caller. Still refuse to run a delta against a graph that was
418
            // never finished — the deltas would be layered onto missing base rows.
419
            logger.warn("AuthorityResolver.runIncrementalCycle: missing processedUpTo on {}; "
12✔
420
                    + "skipping (a full build is needed to repair this graph)", graph);
421
            return;
3✔
422
        }
423
        lastProcessedUpToLag = currentLoadCounter - lastProcessed;
15✔
424
        if (currentLoadCounter <= lastProcessed) {
12✔
425
            logger.debug("AuthorityResolver.runIncrementalCycle: caught up at load {} on {}",
12✔
426
                    currentLoadCounter, graph);
6✔
427
            return;
3✔
428
        }
429

430
        boolean structuralInvalidation = applyInvalidations(graph, lastProcessed);
15✔
431
        TierInsertedTriples counts = runAllTierLoops(graph, lastProcessed);
15✔
432
        boolean structuralAdds = (counts.admin > 0)
51!
433
                || (counts.alias > 0)
434
                || (counts.presetAttachment > 0)
435
                || (counts.attachment > 0)
436
                || (counts.subSpace > 0)
437
                || newRoleDeclarationsArrived(lastProcessed)
12✔
438
                || newPresetAssignmentsArrived(lastProcessed);
18!
439
        if (structuralAdds) {
6✔
440
            // Late-arrival sweep: leaf tiers (attachment/maintainer/member/observer)
441
            // can promote candidates whose enabling event arrived in this same cycle.
442
            // Sub-space admit is also re-run here for Mode-B late-arrival (a new
443
            // partner declaration can validate an older primary that the regular
444
            // pass's load-number filter excluded). The URL-prefix fallback also
445
            // re-runs so newly-orphaned children pick up derived edges. Skip the
446
            // admin tier — its only enabling event is the admin grant itself,
447
            // already handled by the regular pass.
448
            TierInsertedTriples lateCounts = runDownstreamWithoutLoadFilter(graph);
12✔
449
            counts.alias              += lateCounts.alias;
21✔
450
            counts.presetAttachment   += lateCounts.presetAttachment;
21✔
451
            counts.presetAssignmentRef += lateCounts.presetAssignmentRef;
21✔
452
            counts.attachment         += lateCounts.attachment;
21✔
453
            counts.maintainer         += lateCounts.maintainer;
21✔
454
            counts.member             += lateCounts.member;
21✔
455
            counts.observer           += lateCounts.observer;
21✔
456
            counts.subSpace           += lateCounts.subSpace;
21✔
457
            counts.subSpacePrefix     += lateCounts.subSpacePrefix;
21✔
458
            counts.governingSpaceRef  += lateCounts.governingSpaceRef;
21✔
459
            counts.maintainedResource += lateCounts.maintainedResource;
21✔
460
        }
461

462
        writeProcessedUpTo(graph, currentLoadCounter);
12✔
463
        // Re-stamp the integrity count after this cycle's mutations (also
464
        // upgrades pre-stamp graphs to verifiable on their first mutation).
465
        writeStateTripleCount(graph);
9✔
466

467
        TierSubjectTotals totals = computeTierSubjectTotals(graph);
12✔
468
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
18✔
469
        lastSubjectTotals = totals;
9✔
470
        lastInsertedTriplesTotal = (long) counts.admin + counts.alias + counts.presetAttachment
147✔
471
                + counts.presetAssignmentRef
472
                + counts.attachment + counts.maintainer + counts.member + counts.observer
473
                + counts.subSpace + counts.subSpacePrefix + counts.maintainedResource
474
                + counts.governingSpaceRef;
475
        lastIncrementalCycleDurationMs = durationMs;
9✔
476
        logger.info("AuthorityResolver: incremental cycle complete — graph={} delta=({}, {}] "
36✔
477
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
478
                        + "(inserted-triples: admin={} alias={} preset-attachment={} preset-assignment-ref={} attachment={} maintainer={} member={} observer={} "
479
                        + "subspace={} subspace-prefix={} maintained-resource={} governing-space-ref={}) "
480
                        + "structuralInvalidation={} structuralAdds={} durationMs={}",
481
                graph, lastProcessed, currentLoadCounter,
30✔
482
                totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(),
57✔
483
                counts.admin, counts.alias, counts.presetAttachment, counts.presetAssignmentRef, counts.attachment, counts.maintainer, counts.member, counts.observer,
144✔
484
                counts.subSpace, counts.subSpacePrefix, counts.maintainedResource, counts.governingSpaceRef,
69✔
485
                structuralInvalidation, structuralAdds, durationMs);
36✔
486
    }
3✔
487

488
    /**
489
     * Runs the four invalidation-DELETE / ASK steps. Sets {@code npa:needsFullRebuild}
490
     * when admin-RI, RoleAssignment, or RoleDeclaration invalidations matched (the
491
     * three structural kinds). Leaf-tier RI deletes don't set the flag.
492
     *
493
     * @return true iff at least one structural kind was invalidated
494
     */
495
    boolean applyInvalidations(IRI graph, long lastProcessed) {
496
        boolean structural = false;
6✔
497
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ true,
24!
498
                            adminInvalidationCheckWhere(graph, lastProcessed))) {
3✔
499
            executeUpdate(adminInvalidationDelete(graph, lastProcessed));
×
500
            structural = true;
×
501
        }
502
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
24!
503
                            roleAssignmentInvalidationCheckWhere(graph, lastProcessed))) {
3✔
504
            executeUpdate(roleAssignmentInvalidationDelete(graph, lastProcessed));
×
505
            structural = true;
×
506
        }
507
        // Role-declaration invalidation is deliberately NOT acted on (see
508
        // nonAdminTierUpdate): a role assignment is governed by the admin-validated
509
        // attachment, not by the declaration author's later supersession/retraction, so
510
        // an invalidated RD neither deletes rows nor triggers a rebuild.
511
        // Sub-space declarations are structural — invalidating one (Mode A) or one
512
        // of two co-declarations (Mode B) changes the validated parent/child
513
        // topology. The DELETE removes the per-declaration row; the convenience-edge
514
        // cleanup then drops the now-unbacked direct triples (issue #125 finding #5)
515
        // instead of leaving them sticky until the periodic rebuild. The structural
516
        // flag still fires so downstream rows derived through a removed edge stay
517
        // rebuild-bounded.
518
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
24!
519
                            subSpaceInvalidationCheckWhere(graph, lastProcessed))) {
3✔
520
            executeUpdate(subSpaceInvalidationDelete(graph, lastProcessed));
×
521
            executeUpdate(subSpaceConvenienceEdgeCleanup(graph, lastProcessed));
×
522
            structural = true;
×
523
        }
524
        // Space-alias declarations are structural — invalidating one removes an
525
        // owl:sameAs edge that feeds the admin-authority closure (issue #113). The
526
        // DELETE removes the per-declaration row; the convenience-edge cleanup then
527
        // drops the now-unbacked npa:sameAsSpace edge (issue #125 finding #5 — the
528
        // load-bearing case), so admin authority can no longer outlive a retraction
529
        // until the next periodic rebuild.
530
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
24!
531
                            aliasInvalidationCheckWhere(graph, lastProcessed))) {
3✔
532
            executeUpdate(aliasInvalidationDelete(graph, lastProcessed));
×
533
            executeUpdate(aliasConvenienceEdgeCleanup(graph, lastProcessed));
×
534
            structural = true;
×
535
        }
536
        // Preset-derived RoleAssignment removal (issue #302). NOT npx:invalidates: a newer
537
        // admin-authored same-(preset,resource) assignment supersedes by dct:created (a
538
        // gen:DeactivatedPresetAssignment, or any newer assignment that is no longer active).
539
        // Structural — sticky downstream non-admin RIs derived through a removed attachment
540
        // are bounded by the periodic full rebuild. The DELETE is scoped by
541
        // npa:derivedFromPreset so directly-published gen:hasRole attachments are never
542
        // touched; the §4.3 re-INSERT re-materializes only currently-active pairs in the same
543
        // cycle. See doc/design-preset-role-materialization.md §4.4.
544
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
24!
545
                            presetDeactivationCheckWhere(graph, lastProcessed))) {
3✔
546
            executeUpdate(presetDeactivationDelete(graph, lastProcessed));
×
547
            structural = true;
×
548
        }
549
        // Admin role-instantiation revocation (issue #129). STRUCTURAL — admin RIs feed every
550
        // downstream tier, so a removed admin must bound the staleness via a full rebuild
551
        // (mirrors adminInvalidationDelete). Root admins are exempt inside the check-where.
552
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ true,
24✔
553
                            adminRevocationCheckWhere(graph, lastProcessed))) {
3✔
554
            executeUpdate(adminRevocationDelete(graph, lastProcessed));
15✔
555
            structural = true;
6✔
556
        }
557
        // Role detachment (issue #129). STRUCTURAL — removing a (ref, role) attachment
558
        // (direct or preset-derived) cascades to the instantiations anchored on it, bounded
559
        // by the periodic full rebuild. The attachment-tier inline filters then keep the
560
        // detached role suppressed until a newer attachment / preset assignment out-ranks it.
561
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
24!
562
                            roleDetachmentCheckWhere(graph, lastProcessed))) {
3✔
563
            executeUpdate(roleDetachmentDelete(graph, lastProcessed));
×
564
            structural = true;
×
565
        }
566
        // Non-admin role-instantiation revocation (issue #129), run once per tier so the
567
        // authorization arms are the compile-time set for that tier (mirrors the inline
568
        // suppression filter). STRUCTURAL: a revoked maintainer or member is a sub-granting
569
        // authority — members/observers they granted are validated via the maint-pub /
570
        // member-pub arms of nonAdminTierUpdate, so removing the revoked agent's own RI must
571
        // schedule a full rebuild to re-evaluate (and drop) those now-unauthorized downstream
572
        // grants. The inline suppression filter prevents re-materialization on that rebuild.
573
        for (IRI revTier : List.of(GEN.MAINTAINER_ROLE, GEN.MEMBER_ROLE, GEN.OBSERVER_ROLE)) {
39✔
574
            if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
27!
575
                                roleRevocationCheckWhere(graph, lastProcessed, revTier))) {
3✔
576
                executeUpdate(roleRevocationDelete(graph, lastProcessed, revTier));
×
577
                structural = true;
×
578
            }
579
        }
3✔
580
        // Leaf-tier RI deletes — no flag.
581
        executeUpdate(leafTierInvalidationDelete(graph, lastProcessed));
15✔
582
        // Ref-scoped preset-assignment listing stamps whose assignment nanopub was
583
        // hard-retracted (issue #122) — no flag (display leaf, nothing downstream).
584
        executeUpdate(presetAssignmentRefInvalidationDelete(graph, lastProcessed));
15✔
585
        // Maintained-resource declaration deletes — no flag (leaf relation, no
586
        // downstream caches to bound). The per-declaration delete removes the row; the
587
        // convenience-edge cleanup drops the now-unbacked isMaintainedBy edges (issue
588
        // #125 finding #5). Guarded so the orphan-sweep only scans when something was
589
        // actually invalidated (the delete itself was already a no-op otherwise).
590
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
24!
591
                            maintainedResourceInvalidationCheckWhere(graph, lastProcessed))) {
3✔
592
            executeUpdate(maintainedResourceInvalidationDelete(graph, lastProcessed));
×
593
            executeUpdate(maintainedResourceConvenienceEdgeCleanup(graph, lastProcessed));
×
594
        }
595
        if (structural) setNeedsFullRebuild();
12✔
596
        return structural;
6✔
597
    }
598

599
    /**
600
     * Runs the four leaf tiers (attachment/maintainer/member/observer) with
601
     * {@code lastProcessed = -1} so the load-number filter on the candidate
602
     * side admits everything. Dedup filters in the tier templates prevent
603
     * double-insert. Used by the late-arrival sweep.
604
     */
605
    TierInsertedTriples runDownstreamWithoutLoadFilter(IRI graph) {
606
        TierInsertedTriples c = new TierInsertedTriples();
12✔
607
        // Alias late-arrival: catches alias declarations whose canonical admin grant
608
        // became valid only in this same cycle (the load-number filter on the
609
        // declaration's nanopub would otherwise exclude it). Runs first so the
610
        // attachment / role tiers below see this cycle's fresh npa:sameAsSpace edges.
611
        c.alias = runTierLabeled("alias(late)", graph, aliasAdmitUpdate(graph, -1));
27✔
612
        // Sub-space late-arrival: catches Mode-B candidates whose primary
613
        // declaration is older than lastProcessed but whose partner just landed.
614
        c.subSpace = runTierLabeled("subspace(late)", graph,
24✔
615
                subSpaceAdmitUpdate(graph, -1));
3✔
616
        // Maintained-resource late-arrival: catches declarations that landed
617
        // before the publisher's admin grant became valid in this state.
618
        c.maintainedResource = runTierLabeled("maintained-resource(late)", graph,
24✔
619
                maintainedResourceAdmitUpdate(graph, -1));
3✔
620
        // URL-prefix fallback: re-run after the late-arrival sub-space admit so
621
        // any newly-validated children get their fallback edges suppressed (for
622
        // future inserts) and any newly-orphaned children pick up fallback edges.
623
        c.subSpacePrefix = runTierLabeled("subspace-prefix(late)", graph,
21✔
624
                subSpacePrefixFallbackUpdate(graph));
3✔
625
        // Reflexive governing-space-ref late sweep (issue #130): catches refs whose
626
        // SpaceRef aggregate became visible only this cycle. Self-healing dedup.
627
        c.governingSpaceRef = runTierLabeled("governing-space-ref(late)", graph,
21✔
628
                governingSpaceRefReflexiveUpdate(graph));
3✔
629
        // Preset-attachment late-arrival: catches assignments whose preset declaration or
630
        // admin grant only became valid in this same cycle. Runs before attachment(late)
631
        // so the non-admin late tiers below see this cycle's fresh preset-derived RAs.
632
        c.presetAttachment = runTierLabeled("preset-attachment(late)", graph,
24✔
633
                presetAttachmentValidationUpdate(graph, -1));
3✔
634
        // Ref-scoped preset-assignment late stamp: catches assignments whose authorizing
635
        // admin grant only became valid this cycle (the load filter would skip the older
636
        // assignment nanopub). Mirrors the preset-attachment late sweep above.
637
        c.presetAssignmentRef = runTierLabeled("preset-assignment-ref(late)", graph,
24✔
638
                presetAssignmentRefStampUpdate(graph, -1));
3✔
639
        c.attachment = runTierLabeled("attachment(late)", graph,
24✔
640
                attachmentValidationUpdate(graph, -1));
3✔
641
        c.maintainer = runTierLabeled("maintainer(late)", graph,
30✔
642
                nonAdminTierUpdate(graph, -1, GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
3✔
643
        c.member = runTierLabeled("member(admin-pub,late)", graph,
30✔
644
                nonAdminTierUpdate(graph, -1, GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
3✔
645
        c.member += runTierLabeled("member(maint-pub,late)", graph,
39✔
646
                nonAdminTierUpdate(graph, -1,
3✔
647
                        GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
3✔
648
        c.observer = runTierLabeled("observer(admin-pub,late)", graph,
30✔
649
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
3✔
650
        c.observer += runTierLabeled("observer(maint-pub,late)", graph,
39✔
651
                nonAdminTierUpdate(graph, -1,
3✔
652
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
3✔
653
        c.observer += runTierLabeled("observer(member-pub,late)", graph,
39✔
654
                nonAdminTierUpdate(graph, -1,
3✔
655
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
3✔
656
        c.observer += runTierLabeled("observer(self,late)", graph,
39✔
657
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
3✔
658
        return c;
6✔
659
    }
660

661
    /**
662
     * Cheap ASK: did any new {@code npa:RoleDeclaration} extraction land in the
663
     * load-number delta {@code (lastProcessed, ∞)}? Used by the late-arrival
664
     * trigger so an RD that arrives in the same cycle as a matching candidate
665
     * still gets validated.
666
     */
667
    boolean newRoleDeclarationsArrived(long lastProcessed) {
668
        String ask = String.format("""
60✔
669
                PREFIX npa: <%1$s>
670
                ASK {
671
                  GRAPH <%2$s> {
672
                    ?rd a npa:RoleDeclaration ;
673
                        npa:viaNanopub ?np .
674
                  }
675
                  GRAPH <%3$s> {
676
                    ?np npa:hasLoadNumber ?ln .
677
                    FILTER (?ln > %4$d)
678
                  }
679
                }
680
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
681
        return runAsk(ask);
12✔
682
    }
683

684
    /**
685
     * Cheap ASK: did any new {@code npa:PresetAssignment} or {@code npa:PresetDeclaration}
686
     * extraction land in the load-number delta {@code (lastProcessed, ∞)}? Drives the
687
     * late-arrival re-run so a preset assignment that arrives in the same cycle as its
688
     * declaration (or admin grant) still materializes, and so an arriving newer assignment
689
     * triggers the deactivation/latest-wins re-evaluation.
690
     */
691
    boolean newPresetAssignmentsArrived(long lastProcessed) {
692
        String ask = String.format("""
60✔
693
                PREFIX npa: <%1$s>
694
                ASK {
695
                  GRAPH <%2$s> {
696
                    ?x a ?t ;
697
                       npa:viaNanopub ?np .
698
                    FILTER (?t = npa:PresetAssignment || ?t = npa:PresetDeclaration)
699
                  }
700
                  GRAPH <%3$s> {
701
                    ?np npa:hasLoadNumber ?ln .
702
                    FILTER (?ln > %4$d)
703
                  }
704
                }
705
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
706
        return runAsk(ask);
12✔
707
    }
708

709
    // ---------------- Tier UPDATE loops ----------------
710

711
    /**
712
     * Per-tier inserted-triple tallies for one build or cycle. Counts the sum
713
     * of {@code (graphSize_after - graphSize_before)} across all iterations of
714
     * each tier's fixed-point INSERT loop — i.e. inserted *triples*, not
715
     * distinct subjects (a single RoleInstantiation insert writes 4–5 triples).
716
     *
717
     * <p>Used internally by the {@link #runIncrementalCycle structuralAdds}
718
     * boolean check (we only care whether any tier inserted at all).
719
     * Not what the log lines report: see {@link TierSubjectTotals} +
720
     * {@link #computeTierSubjectTotals} for the distinct-subject totals
721
     * surfaced to operators.
722
     */
723
    /**
724
     * Total triples inserted across every tier. Used both for the metrics gauge and
725
     * for the empty-build guard in {@link #runFullBuild}, so the two can never
726
     * disagree about what "this build produced nothing" means.
727
     */
728
    static long totalInserted(TierInsertedTriples c) {
729
        return (long) c.admin + c.alias + c.presetAttachment + c.presetAssignmentRef
144✔
730
                + c.attachment + c.maintainer + c.member + c.observer
731
                + c.subSpace + c.subSpacePrefix + c.maintainedResource
732
                + c.governingSpaceRef;
733
    }
734

735
    static final class TierInsertedTriples {
9✔
736
        int admin;
737
        int alias;
738
        int presetAttachment;
739
        int presetAssignmentRef;
740
        int attachment;
741
        int maintainer;
742
        int member;
743
        int observer;
744
        int subSpace;
745
        int subSpacePrefix;
746
        int maintainedResource;
747
        int governingSpaceRef;
748
    }
749

750
    /**
751
     * Snapshot of distinct-subject totals in a space-state graph at a moment
752
     * in time. Independent of which tier-loop added each subject.
753
     */
754
    record TierSubjectTotals(long adminRIs, long attachmentRAs, long nonAdminRIs) {}
36✔
755

756
    /**
757
     * Runs the five tier loops in order: admin → {@code gen:hasRole} attachment
758
     * validation → maintainer → member → observer. Each loop iterates a SPARQL
759
     * INSERT to fixed point (no new triples added). Returns per-tier counts.
760
     *
761
     * @param graph         target space-state graph
762
     * @param lastProcessed load-number horizon; use {@code -1} for full build
763
     */
764
    TierInsertedTriples runAllTierLoops(IRI graph, long lastProcessed) {
765
        TierInsertedTriples c = new TierInsertedTriples();
12✔
766
        c.admin = runTierLabeled("admin", graph, adminTierUpdate(graph, lastProcessed));
27✔
767
        // Alias admit runs after the admin closure has settled (both the authority
768
        // gate and the anti-hijack check read the admin set) and before attachment /
769
        // role tiers (their alias-aware admin lookups consume the npa:sameAsSpace edge
770
        // this pass emits). See issue #113.
771
        c.alias = runTierLabeled("alias", graph, aliasAdmitUpdate(graph, lastProcessed));
27✔
772
        // Sub-space admit runs after admin closure has settled (Mode A + Mode B both
773
        // need the admin set). Independent of role tiers — order between subspace
774
        // and attachment / maintainer / member / observer doesn't matter.
775
        c.subSpace = runTierLabeled("subspace", graph, subSpaceAdmitUpdate(graph, lastProcessed));
27✔
776
        // Maintained-resource admit also depends only on the admin closure. Single
777
        // Mode A: publisher must be admin of the maintaining space. No co-declaration
778
        // partner, no URL-prefix fallback.
779
        c.maintainedResource = runTierLabeled("maintained-resource", graph,
24✔
780
                maintainedResourceAdmitUpdate(graph, lastProcessed));
3✔
781
        // URL-prefix sub-space fallback runs after the explicit-declaration admit
782
        // pass commits so the per-child suppression check sees this cycle's fresh
783
        // validations. No load filter — depends on which Spaces exist, not on
784
        // delta-arrivals; the dedup FILTER NOT EXISTS prevents re-insertion.
785
        c.subSpacePrefix = runTierLabeled("subspace-prefix", graph,
21✔
786
                subSpacePrefixFallbackUpdate(graph));
3✔
787
        // Reflexive governing-space-ref edges (issue #130). Self-healing, no load filter;
788
        // runs after the maintained-resource admit so a maintained resource that is itself
789
        // a space already has its maintained governing edge by now (the two are independent
790
        // anyway — different subjects/objects). Order vs. other tiers doesn't matter.
791
        c.governingSpaceRef = runTierLabeled("governing-space-ref", graph,
21✔
792
                governingSpaceRefReflexiveUpdate(graph));
3✔
793
        // Preset-attachment runs immediately before the regular attachment tier so the
794
        // gen:RoleAssignment rows it materializes (from active, admin-authored preset
795
        // assignments) are picked up by the downstream non-admin tiers in the same pass,
796
        // exactly like directly-published attachments. See
797
        // doc/design-preset-role-materialization.md.
798
        c.presetAttachment = runTierLabeled("preset-attachment", graph,
24✔
799
                presetAttachmentValidationUpdate(graph, lastProcessed));
3✔
800
        // Ref-scoped preset-assignment listing stamp (issue #122). Display-only leaf —
801
        // independent of the role tiers and of structuralAdds; order doesn't matter.
802
        c.presetAssignmentRef = runTierLabeled("preset-assignment-ref", graph,
24✔
803
                presetAssignmentRefStampUpdate(graph, lastProcessed));
3✔
804
        c.attachment = runTierLabeled("attachment", graph,
24✔
805
                attachmentValidationUpdate(graph, lastProcessed));
3✔
806
        c.maintainer = runTierLabeled("maintainer", graph, nonAdminTierUpdate(graph, lastProcessed,
33✔
807
                GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
808
        // Member tier: admin OR maintainer publisher — split into two simpler updates
809
        // so the query planner doesn't struggle with the UNION.
810
        c.member = runTierLabeled("member(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
33✔
811
                GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
812
        c.member += runTierLabeled("member(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
42✔
813
                GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
3✔
814
        // Observer tier: self-evidence OR a downward grant from any higher tier.
815
        // ObserverRole is the default tier when a role definition omits an
816
        // explicit subclass (see "Role types" in design-space-repositories.md), so
817
        // most "X assigned Y this role" nanopubs land here. Restricting the tier
818
        // to PUBLISHER_IS_SELF would silently drop those grants. The four
819
        // sub-loops mirror the trust-state's downward-only chain: admin grants
820
        // anything; maintainers and members grant observer; everyone may
821
        // self-attest.
822
        c.observer = runTierLabeled("observer(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
33✔
823
                GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
824
        c.observer += runTierLabeled("observer(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
42✔
825
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
3✔
826
        c.observer += runTierLabeled("observer(member-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
42✔
827
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
3✔
828
        c.observer += runTierLabeled("observer(self)", graph, nonAdminTierUpdate(graph, lastProcessed,
42✔
829
                GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
830
        return c;
6✔
831
    }
832

833
    /**
834
     * Builds a publisher constraint requiring the publisher to be a validated holder
835
     * of the given tier's role (maintainer or member) in the target space.
836
     * Owns its own AccountState resolution so ?publisher is bound through the
837
     * targeted (pkh → agent) lookup rather than enumerated.
838
     */
839
    private static String publisherIsTieredRole(IRI tierClass) {
840
        // Re-keyed on the assignment's ref (alias → canonical already resolved by the
841
        // attachment tier). Relies on materialized non-admin RIs carrying their role
842
        // property (npa:regularProperty / npa:inverseProperty) — supplied by the
843
        // enrichment in nonAdminTierUpdate; without it this constraint matched nothing.
844
        return """
24✔
845
                ?acct a npa:AccountState ;
846
                      npa:pubkey ?pkh ;
847
                      npa:agent  ?publisher .
848
                ?tierRI a gen:RoleInstantiation ;
849
                        npa:forSpaceRef ?spaceRef ;
850
                        npa:forAgent ?publisher .
851
                ?rdT a npa:RoleDeclaration ;
852
                     npa:hasRoleType <%1$s> .
853
                { ?tierRI npa:regularProperty ?predT . ?rdT gen:hasRegularProperty ?predT . }
854
                UNION
855
                { ?tierRI npa:inverseProperty ?predT . ?rdT gen:hasInverseProperty ?predT . }
856
                """.formatted(tierClass);
3✔
857
    }
858

859
    // ---------------- Role revocation / detachment (issue #129) ----------------
860

861
    /**
862
     * {@code xsd:dateTime} epoch literal — the latest-wins fallback for any assertion that
863
     * lacks {@code dct:created}. Per issue #129's "treat missing as epoch": a positive
864
     * assertion without a timestamp sorts oldest (always loses), and a negative
865
     * (revocation / detachment) without one is inert (can never out-rank a timestamped
866
     * positive). Written as a full datatype IRI since the tier templates only declare
867
     * {@code npa:} / {@code gen:}.
868
     */
869
    private static final String EPOCH_DT =
870
            "\"1970-01-01T00:00:00.000Z\"^^<http://www.w3.org/2001/XMLSchema#dateTime>";
871

872
    /** Inner {@code GRAPH} block matching a revoker who is a validated admin of {@code ?spaceRef}. */
873
    private static String revokerAdminGraphBlock(IRI graph) {
874
        return String.format("""
27✔
875
                GRAPH <%1$s> {
876
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?revAgent .
877
                  ?revRI a gen:RoleInstantiation ;
878
                         npa:forSpaceRef ?spaceRef ;
879
                         npa:inverseProperty gen:hasAdmin ;
880
                         npa:forAgent ?revAgent .
881
                }""", graph);
882
    }
883

884
    /** Inner {@code GRAPH} block matching a revoker who holds {@code tier} in {@code ?spaceRef}. */
885
    private static String revokerTierGraphBlock(IRI graph, IRI tier) {
886
        return String.format("""
39✔
887
                GRAPH <%1$s> {
888
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?revAgent .
889
                  ?revRI a gen:RoleInstantiation ;
890
                         npa:forSpaceRef ?spaceRef ;
891
                         npa:forAgent ?revAgent ;
892
                         npa:hasRoleType <%2$s> .
893
                }""", graph, tier);
894
    }
895

896
    /** Inner {@code GRAPH} block matching a self-revoke: the revoker's key belongs to {@code ?agent}. */
897
    private static String revokerSelfGraphBlock(IRI graph) {
898
        return String.format("""
27✔
899
                GRAPH <%1$s> {
900
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?agent .
901
                }""", graph);
902
    }
903

904
    /**
905
     * Authorization arms for an instantiation revocation targeting a <em>compile-time</em>
906
     * tier — issue #129's matrix, the single arm builder used by BOTH the inline suppression
907
     * filter and the (per-tier-scoped) displacement DELETE, so the two paths can never
908
     * authorize different revokers and flip-flop a row. UNION of only the arms the matrix
909
     * permits for {@code targetTier}: admin of {@code ?spaceRef} (revokes any non-admin); a
910
     * maintainer (member/observer targets); a member (observer target); plus the assignee
911
     * itself (self-leave, any tier). A revoker must hold a tier strictly higher than the
912
     * target. Compile-time selection (no runtime {@code ?tier} variable) deliberately avoids
913
     * the SPARQL pitfall where a {@code FILTER} inside a {@code UNION} branch cannot see a
914
     * {@code ?tier} bound in the enclosing group.
915
     */
916
    private static String revocationAuthorityArmsForTier(IRI graph, IRI targetTier) {
917
        List<String> arms = new ArrayList<>();
12✔
918
        arms.add("{ " + revokerAdminGraphBlock(graph) + " }");
18✔
919
        if (GEN.MEMBER_ROLE.equals(targetTier) || GEN.OBSERVER_ROLE.equals(targetTier)) {
24✔
920
            arms.add("{ " + revokerTierGraphBlock(graph, GEN.MAINTAINER_ROLE) + " }");
21✔
921
        }
922
        if (GEN.OBSERVER_ROLE.equals(targetTier)) {
12✔
923
            arms.add("{ " + revokerTierGraphBlock(graph, GEN.MEMBER_ROLE) + " }");
21✔
924
        }
925
        arms.add("{ " + revokerSelfGraphBlock(graph) + " }");
18✔
926
        return String.join("\nUNION\n", arms);
12✔
927
    }
928

929
    /**
930
     * Inline suppression filter for {@code nonAdminTierUpdate}: rejects a candidate
931
     * instantiation ({@code ?ri}, created {@code ?candCreated}) whose {@code (space, agent,
932
     * role)} key has a newer authorized {@code npa:RoleRevocation}, using
933
     * {@link #revocationAuthorityArmsForTier} for {@code targetTier} (the loop tier) — the same
934
     * builder the displacement DELETE uses, so suppression and re-materialization always agree.
935
     * The revocation's named space is matched against any IRI denoting {@code ?spaceRef}
936
     * (canonical or validated {@code owl:sameAs} alias, issue #113), so an alias-named
937
     * revocation is not a silent no-op. Latest-wins by {@code dct:created} ({@link #EPOCH_DT}
938
     * fallback) with an {@code STR()} subject tiebreak. Not wrapped in {@code invalidationFilter}:
939
     * per issue #129 the only un-revoke path is a newer positive re-assignment.
940
     */
941
    private static String nonAdminRevocationSuppressionFilter(IRI graph, IRI targetTier) {
942
        return String.format("""
51✔
943
                FILTER NOT EXISTS {
944
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
945
                  UNION
946
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
947
                  GRAPH <%2$s> {
948
                    ?rev a npa:RoleRevocation ;
949
                         npa:forSpace    ?revSpace ;
950
                         npa:forAgent    ?agent ;
951
                         npa:revokedRole ?role ;
952
                         npa:pubkeyHash  ?revPkh .
953
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
954
                  }
955
                  BIND(COALESCE(?revCreatedRaw, %4$s) AS ?revCreated)
956
                  FILTER (?revCreated > ?candCreated
957
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri)))
958
                  { %3$s }
959
                }""", graph, SpacesVocab.SPACES_GRAPH,
960
                revocationAuthorityArmsForTier(graph, targetTier), EPOCH_DT);
18✔
961
    }
962

963
    /**
964
     * Inline suppression filter for {@code adminTierUpdate}: rejects an admin instantiation
965
     * ({@code ?ri}, created {@code ?candCreated}) whose {@code (ref, agent)} key has a newer
966
     * authorized admin {@code npa:RoleRevocation} ({@code revokedRole = gen:AdminRole}) —
967
     * authorized by an admin of the ref (admins revoke admins) or by the agent itself
968
     * (self-leave). <b>Root admins are exempt</b> (issue #129/#110): a nested
969
     * {@code FILTER NOT EXISTS} on {@code npa:hasRootAdmin} makes any revocation against a
970
     * root admin structurally inert, overriding self-leave. {@code gen:AdminRole} resolves
971
     * via the {@code gen:} prefix the admin-tier template declares.
972
     */
973
    private static String adminRevocationSuppressionFilter(IRI graph) {
974
        return String.format("""
48✔
975
                FILTER NOT EXISTS {
976
                  FILTER NOT EXISTS { GRAPH <%2$s> {
977
                    ?rootDef a npa:SpaceDefinition ;
978
                             npa:forSpaceRef  ?spaceRef ;
979
                             npa:hasRootAdmin ?agent .
980
                  } }
981
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
982
                  UNION
983
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
984
                  GRAPH <%2$s> {
985
                    ?rev a npa:RoleRevocation ;
986
                         npa:forSpace    ?revSpace ;
987
                         npa:forAgent    ?agent ;
988
                         npa:revokedRole gen:AdminRole ;
989
                         npa:pubkeyHash  ?revPkh .
990
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
991
                  }
992
                  BIND(COALESCE(?revCreatedRaw, %4$s) AS ?revCreated)
993
                  FILTER (?revCreated > ?candCreated
994
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri)))
995
                  { %3$s }
996
                }""", graph, SpacesVocab.SPACES_GRAPH,
997
                "{ " + revokerAdminGraphBlock(graph) + " }\nUNION\n{ "
6✔
998
                        + revokerSelfGraphBlock(graph) + " }",
21✔
999
                EPOCH_DT);
1000
    }
1001

1002
    /**
1003
     * Inline suppression filter for the attachment tiers ({@code attachmentValidationUpdate}
1004
     * and {@code presetAttachmentValidationUpdate}): rejects a {@code (targetRef, role)}
1005
     * attachment whose effective timestamp ({@code ?<createdVar>}) is out-ranked by a newer
1006
     * admin-authored {@code npa:RoleDetachment} (issue #129). Authority = admin of
1007
     * {@code ?targetRef} (matching who may attach). Non-sticky latest-wins: a newer
1008
     * attachment / preset assignment naturally re-attaches because its timestamp beats the
1009
     * detachment. The detachment's named space is matched against any IRI denoting
1010
     * {@code ?targetRef} (canonical or {@code owl:sameAs} alias).
1011
     *
1012
     * @param createdVar     bare name of the attachment's effective-created variable
1013
     * @param attachSubjVar  bare name of the attachment subject variable (for the STR tiebreak)
1014
     */
1015
    private static String roleDetachmentSuppressionFilter(IRI graph, String createdVar, String attachSubjVar) {
1016
        return String.format("""
75✔
1017
                FILTER NOT EXISTS {
1018
                  { GRAPH <%2$s> { ?targetRef npa:spaceIri ?detSpace . } }
1019
                  UNION
1020
                  { GRAPH <%1$s> { ?detSpace npa:sameAsSpace ?targetRef . } }
1021
                  GRAPH <%2$s> {
1022
                    ?det a npa:RoleDetachment ;
1023
                         npa:forSpace    ?detSpace ;
1024
                         npa:revokedRole ?role ;
1025
                         npa:pubkeyHash  ?detPkh .
1026
                    OPTIONAL { ?det <http://purl.org/dc/terms/created> ?detCreatedRaw . }
1027
                  }
1028
                  BIND(COALESCE(?detCreatedRaw, %5$s) AS ?detCreated)
1029
                  FILTER (?detCreated > ?%3$s
1030
                          || (?detCreated = ?%3$s && STR(?det) > STR(?%4$s)))
1031
                  GRAPH <%1$s> {
1032
                    ?detAcct a npa:AccountState ; npa:pubkey ?detPkh ; npa:agent ?detAgent .
1033
                    ?detAdminRI a gen:RoleInstantiation ;
1034
                                npa:forSpaceRef ?targetRef ;
1035
                                npa:inverseProperty gen:hasAdmin ;
1036
                                npa:forAgent ?detAgent .
1037
                  }
1038
                }""", graph, SpacesVocab.SPACES_GRAPH, createdVar, attachSubjVar, EPOCH_DT);
1039
    }
1040

1041
    /** Wraps {@link #runTierLoop} with tier-name context for logs/exceptions. */
1042
    private int runTierLabeled(String tier, IRI graph, String sparqlUpdate) {
1043
        try {
1044
            return runTierLoop(graph, sparqlUpdate);
15✔
1045
        } catch (RuntimeException ex) {
×
1046
            logger.error("AuthorityResolver: tier={} failed with SPARQL UPDATE:\n{}\n", tier, sparqlUpdate, ex);
×
1047
            throw ex;
×
1048
        }
1049
    }
1050

1051
    /**
1052
     * Runs a single tier's INSERT to fixed point. Counts rows by probing
1053
     * graph size before/after each INSERT; stops when the size doesn't change.
1054
     *
1055
     * @return total number of triples inserted by this tier across all iterations
1056
     */
1057
    int runTierLoop(IRI graph, String sparqlUpdate) {
1058
        int total = 0;
6✔
1059
        long before = graphSize(graph);
12✔
1060
        while (true) {
1061
            // Note: no explicit transaction wrapping here. In tests we observed that
1062
            // HTTPRepository's RDF4J-transaction protocol silently no-op'd cross-graph
1063
            // SPARQL UPDATEs with UNION sub-patterns inside conn.begin()/commit(),
1064
            // while the same UPDATE POSTed directly to /statements applied correctly.
1065
            // A bare prepareUpdate().execute() takes the direct /statements path and
1066
            // runs the UPDATE atomically per SPARQL 1.1 semantics — which is all we
1067
            // need; there's nothing else to commit atomically alongside the UPDATE.
1068
            try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
1069
                conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
15✔
1070
            }
1071
            long after = graphSize(graph);
12✔
1072
            long added = after - before;
12✔
1073
            if (added <= 0) break;
15✔
1074
            total += added;
18✔
1075
            before = after;
6✔
1076
        }
3✔
1077
        return total;
6✔
1078
    }
1079

1080
    private long graphSize(IRI graph) {
1081
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
1082
            return conn.size(graph);
33✔
1083
        }
1084
    }
1085

1086
    /**
1087
     * Distinct-subject totals in the given space-state graph, broken down by
1088
     * RoleInstantiation kind (admin-pinned vs not) and RoleAssignment.
1089
     * Three SELECT-COUNT queries — cheap, called once per build/cycle for
1090
     * the user-facing log line. Returns zeros on failure (logged) so a flaky
1091
     * count read can't wedge the cycle.
1092
     */
1093
    TierSubjectTotals computeTierSubjectTotals(IRI graph) {
1094
        long adminRIs       = countDistinctSubjects(graph, """
18✔
1095
                ?ri a gen:RoleInstantiation ; npa:inverseProperty gen:hasAdmin .
1096
                """, "ri");
1097
        long attachmentRAs  = countDistinctSubjects(graph, """
18✔
1098
                ?ra a gen:RoleAssignment .
1099
                """, "ra");
1100
        long nonAdminRIs    = countDistinctSubjects(graph, """
18✔
1101
                ?ri a gen:RoleInstantiation .
1102
                FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
1103
                """, "ri");
1104
        return new TierSubjectTotals(adminRIs, attachmentRAs, nonAdminRIs);
21✔
1105
    }
1106

1107
    private long countDistinctSubjects(IRI graph, String wherePattern, String varName) {
1108
        String query = String.format("""
75✔
1109
                PREFIX npa: <%1$s>
1110
                PREFIX gen: <%2$s>
1111
                SELECT (COUNT(DISTINCT ?%3$s) AS ?n) WHERE {
1112
                  GRAPH <%4$s> {
1113
                    %5$s
1114
                  }
1115
                }
1116
                """, NPA.NAMESPACE, GEN.NAMESPACE, varName, graph, wherePattern);
1117
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
12✔
1118
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18✔
1119
            if (!r.hasNext()) return 0;
9!
1120
            return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
33✔
1121
        } catch (Exception ex) {
3!
1122
            logger.warn("AuthorityResolver: countDistinctSubjects on {} failed: {}",
15✔
1123
                    graph, ex.toString());
3✔
1124
            return 0;
6✔
1125
        }
1126
    }
1127

1128
    // ---------------- SPARQL templates ----------------
1129

1130
    /**
1131
     * Reusable invalidation filter on a bound nanopub-IRI variable. Pass the bare
1132
     * variable name (no leading {@code ?}); e.g. {@code invalidationFilter("np")}
1133
     * produces an outer-scoped {@code FILTER NOT EXISTS { GRAPH npa:graph
1134
     * { ?_inv_np npx:invalidates ?np . } }}.
1135
     *
1136
     * <p>Joins on the raw {@code npx:invalidates} triple in {@code npa:graph},
1137
     * which {@link com.knowledgepixels.query.NanopubLoader} writes into the
1138
     * spaces repo from two complementary directions, making the filter symmetric
1139
     * in load order:
1140
     * <ul>
1141
     *   <li>At the invalidator's own load: the loader's space-repo trigger fires
1142
     *       whenever the nanopub has either its own space-relevant extractions
1143
     *       OR an {@code npx:invalidates}/{@code npx:retracts}/{@code npx:supersedes}
1144
     *       triple, so a pure-retraction nanopub still lands its raw triple plus
1145
     *       {@code npa:hasLoadNumber} stamp in {@code npa:graph}.</li>
1146
     *   <li>At the invalidated target's load (when the invalidator landed
1147
     *       earlier): {@code NanopubLoader.getInvalidatingStatements} reads the
1148
     *       triple back from the meta repo and mirrors it into the target's own
1149
     *       write to the spaces repo.</li>
1150
     * </ul>
1151
     *
1152
     * <p>The earlier shape joined on a structured {@code npa:Invalidation} entry
1153
     * in {@code npa:spacesGraph} that was only emitted on the invalidator's side
1154
     * AND only when the invalidated target's meta had already loaded, leaving a
1155
     * window where a superseding nanopub loaded before its target produced no
1156
     * entry and the stale row was never filtered out (see also the matching
1157
     * change in the tier-specific {@code *InvalidationCheckWhere}/{@code
1158
     * *InvalidationDelete} templates below).
1159
     *
1160
     * <p>Important: this filter must be placed OUTSIDE the surrounding
1161
     * {@code GRAPH npa:spacesGraph { ... }} block, not nested inside it. When
1162
     * nested, RDF4J's planner couples the FILTER NOT EXISTS evaluation into the
1163
     * join order (per-row scan multiplied by the candidate set), which we
1164
     * measured turning a 39ms query into a 60s+ timeout on the live observer-tier
1165
     * data. Outside the GRAPH block, the planner defers the filter until
1166
     * {@code ?np}/{@code ?rdNp} are bound and does a targeted index lookup.
1167
     *
1168
     * <p>Variable names must match {@code [A-Za-z0-9_]+} per SPARQL grammar —
1169
     * embedding a {@code ?} inside {@code ?_inv_?np} would yield a parse error.
1170
     */
1171
    private static String invalidationFilter(String bareVarName) {
1172
        return "FILTER NOT EXISTS { GRAPH <" + NPA.GRAPH + "> {"
30✔
1173
                + " ?_inv_" + bareVarName
1174
                + " <" + NPX.INVALIDATES + "> ?" + bareVarName + " . "
1175
                + samePublisherClause("_inv_" + bareVarName, bareVarName)
6✔
1176
                + " } }";
1177
    }
1178

1179
    /**
1180
     * SPARQL triple pair (placed inside a {@code GRAPH npa:graph { ... }} block)
1181
     * requiring the invalidating nanopub and its target to share a signing public
1182
     * key — the self-retraction authority gate for issue #112. Without it, the
1183
     * materializer honors {@code npx:invalidates}/{@code retracts}/{@code supersedes}
1184
     * from <em>any</em> validly-signed nanopub, so any agent can erase another
1185
     * space's materialized state (griefing/DoS of the view — fail-closed, no
1186
     * privilege escalation, but real). Additions are already admin-gated; this is
1187
     * the symmetric gate on removals.
1188
     *
1189
     * <p>Both {@code npa:hasValidSignatureForPublicKeyHash} triples live in
1190
     * {@code npa:graph} of the spaces repo: the target via its own space-load, the
1191
     * invalidator via the symmetric retractor propagation in
1192
     * {@link com.knowledgepixels.query.NanopubLoader} (forward {@code
1193
     * loadInvalidateStatements} + reverse {@code loadInvalidatorIntoSpacesRepo}),
1194
     * so the join is populated regardless of load order.
1195
     *
1196
     * <p>"Same pubkey" is intentionally stricter than "same agent": a retraction
1197
     * signed by a different key the author owns (key rotation) is not honored, and
1198
     * cross-admin supersession is out of scope here (would need an admin-authority
1199
     * arm). The pubkey-bridge variable is suffixed with {@code targetVar} so two
1200
     * filters in one query (e.g. on {@code ?np} and {@code ?rdNp}) don't collide.
1201
     *
1202
     * @param invVar    invalidator nanopub variable name (no leading {@code ?})
1203
     * @param targetVar invalidated-target nanopub variable name (no leading {@code ?})
1204
     */
1205
    private static String samePublisherClause(String invVar, String targetVar) {
1206
        String pk = "?_invpk_" + targetVar;
9✔
1207
        return "?" + invVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " . "
30✔
1208
                + "?" + targetVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " .";
1209
    }
1210

1211
    /**
1212
     * Admin tier: seed from {@code npadef:...hasRootAdmin} (trusted by construction)
1213
     * plus closed-over admin grants; insert any {@code gen:RoleInstantiation} with
1214
     * {@code npa:inverseProperty gen:hasAdmin} whose publisher (resolved via mirrored
1215
     * trust-approved AccountState) is already in the admin set.
1216
     *
1217
     * <p>The seed is gated by {@link #spaceRefAliveFilter} (not the per-nanopub
1218
     * {@code invalidationFilter("defNp")}): the {@code hasRootAdmin} seed is anchored
1219
     * to the root NPID, which is the immutable space-ref identity, so superseding the
1220
     * root <em>nanopub</em> with a continuation revision must not strip the seed —
1221
     * only retracting every definition of the ref removes it. See issue #110.
1222
     */
1223
    static String adminTierUpdate(IRI graph, long lastProcessed) {
1224
        // Order tuned for RDF4J's evaluator:
1225
        //   1. Anchor on the small (seed UNION closed-over) set to bind ?publisher
1226
        //      and ?space cheaply.
1227
        //   2. Resolve ?pkh from the mirrored AccountState row (?publisher bound).
1228
        //   3. Probe instantiations using the now-bound (?space, ?pkh) — targeted
1229
        //      lookup, not a full RoleInstantiation scan.
1230
        //   4. Load-number filter on bound ?np.
1231
        //   5. Dedup at the end.
1232
        // Authority is keyed on the space *ref* (npa:forSpaceRef), not the bare Space
1233
        // IRI: two refs that share an IRI but have different roots are independent
1234
        // domains (see doc/design-spaceref-isolation.md). The instantiation evidence in
1235
        // the extraction graph is IRI-keyed (a gen:hasAdmin nanopub names the bare IRI),
1236
        // so we project it per-ref by joining each instantiation naming ?space to the
1237
        // admin rows of every ref of ?space whose admin set contains the publisher. The
1238
        // inserted subject is minted per (?ri, ?spaceRef) so one instantiation validating
1239
        // into N refs yields N distinct rows. TRANSITIONAL-DUAL-EMIT (Phase 4: remove):
1240
        // forSpace is still emitted alongside forSpaceRef so the not-yet-migrated
1241
        // downstream tiers / pre-ref read queries keep functioning on a mixed-version
1242
        // fleet; it is dropped once everything keys on forSpaceRef.
1243
        return """
69✔
1244
                PREFIX npa:  <%1$s>
1245
                PREFIX gen:  <%2$s>
1246
                INSERT { GRAPH <%3$s> {
1247
                  ?sri a gen:RoleInstantiation ;
1248
                       npa:forSpaceRef ?spaceRef ;
1249
                       npa:forSpace ?space ;
1250
                       npa:inverseProperty gen:hasAdmin ;
1251
                       # Stamp the admin tier so consumers read tier uniformly across all
1252
                       # RoleInstantiations (?ri npa:hasRoleType ?tier) with no admin
1253
                       # special-case — matching the non-admin path (issue #125, #127).
1254
                       npa:hasRoleType gen:AdminRole ;
1255
                       npa:forAgent ?agent ;
1256
                       npa:viaNanopub ?np .
1257
                } }
1258
                WHERE {
1259
                  # 1. Anchor: who is already an admin of which space ref?
1260
                  {
1261
                    # Seed branch: root-admin of a space ref that is still alive
1262
                    # (has at least one non-invalidated definition). NOT filtered on
1263
                    # ?def's own invalidation — superseding the root nanopub with a
1264
                    # continuation revision must keep the seed; only a fully-retracted
1265
                    # ref drops it (issue #110).
1266
                    GRAPH <%4$s> {
1267
                      ?def a npa:SpaceDefinition ;
1268
                           npa:forSpaceRef  ?spaceRef ;
1269
                           npa:hasRootAdmin ?publisher .
1270
                      ?spaceRef npa:spaceIri ?space .
1271
                    }
1272
                    %7$s
1273
                  }
1274
                  UNION
1275
                  {
1276
                    # Closed-over branch: an existing admin of this ref. Recurse on the
1277
                    # ref, then resolve its bare IRI to probe the IRI-keyed instantiation.
1278
                    GRAPH <%3$s> {
1279
                      ?prev a gen:RoleInstantiation ;
1280
                            npa:forSpaceRef     ?spaceRef ;
1281
                            npa:inverseProperty gen:hasAdmin ;
1282
                            npa:forAgent        ?publisher .
1283
                    }
1284
                    GRAPH <%4$s> {
1285
                      ?spaceRef npa:spaceIri ?space .
1286
                    }
1287
                  }
1288
                  # 2. Mirror: resolve ?publisher → ?pkh via the trust-approved row.
1289
                  GRAPH <%3$s> {
1290
                    ?acct a npa:AccountState ;
1291
                          npa:agent  ?publisher ;
1292
                          npa:pubkey ?pkh .
1293
                  }
1294
                  # 3. Targeted instantiation lookup by space + pubkey (IRI-keyed).
1295
                  GRAPH <%4$s> {
1296
                    ?ri a gen:RoleInstantiation ;
1297
                        npa:forSpace        ?space ;
1298
                        npa:inverseProperty gen:hasAdmin ;
1299
                        npa:forAgent        ?agent ;
1300
                        npa:pubkeyHash      ?pkh ;
1301
                        npa:viaNanopub      ?np .
1302
                    # Candidate grant timestamp for the admin-revocation latest-wins (#129).
1303
                    OPTIONAL { ?ri <http://purl.org/dc/terms/created> ?candCreatedRaw . }
1304
                  }
1305
                  BIND(COALESCE(?candCreatedRaw, %9$s) AS ?candCreated)
1306
                  # 3a. Mint the per-ref state subject: (?ri, ?spaceRef) → ?sri.
1307
                  BIND(IRI(CONCAT(STR(?ri), "__", ENCODE_FOR_URI(STR(?spaceRef)))) AS ?sri)
1308
                  %6$s
1309
                  # 4. Load-number filter on bound ?np.
1310
                  GRAPH <%8$s> {
1311
                    ?np npa:hasLoadNumber ?ln .
1312
                    FILTER (?ln > %5$d)
1313
                  }
1314
                  # 4a. Admin-revocation latest-wins (issue #129): suppress if a newer
1315
                  #     authorized admin revocation shadows (ref, agent) — unless ?agent is a
1316
                  #     root admin (constitutional exemption, overrides self-leave).
1317
                  %10$s
1318
                  # 5. Dedup last — keyed on (ref, agent).
1319
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1320
                    ?existing a gen:RoleInstantiation ;
1321
                              npa:forSpaceRef ?spaceRef ;
1322
                              npa:forAgent ?agent ;
1323
                              npa:inverseProperty gen:hasAdmin .
1324
                  } }
1325
                }
1326
                """.formatted(
3✔
1327
                NPA.NAMESPACE,
1328
                GEN.NAMESPACE,
1329
                graph,
1330
                SpacesVocab.SPACES_GRAPH,
1331
                lastProcessed,
15✔
1332
                invalidationFilter("np"),
12✔
1333
                spaceRefAliveFilter(),
39✔
1334
                NPA.GRAPH,
1335
                EPOCH_DT,
1336
                adminRevocationSuppressionFilter(graph));
6✔
1337
    }
1338

1339
    /**
1340
     * Seed-survival filter for the admin tier (issue #110). The {@code hasRootAdmin}
1341
     * seed is anchored to the root NPID, which is the immutable space-ref identity, so
1342
     * it must survive supersession of the root <em>nanopub</em> by a continuation
1343
     * revision (a later definition re-roots to the same ref via
1344
     * {@code gen:hasRootDefinition} and so carries no {@code hasRootAdmin} of its own).
1345
     * The previous {@code invalidationFilter("defNp")} dropped the seed the moment the
1346
     * root revision was superseded, leaving the whole admin closure — and everything
1347
     * cascading from it — unmaterialized for any space whose definition had ever been
1348
     * updated.
1349
     *
1350
     * <p>Expressed positively: the seed survives iff the space ref still has at least
1351
     * one non-invalidated {@link SpacesVocab#SPACE_DEFINITION}. A fully-retracted ref
1352
     * (every definition invalidated) has no live definition, so the {@code FILTER
1353
     * EXISTS} fails and the seed correctly disappears. Anchored on the already-bound
1354
     * {@code ?spaceRef}, so it's a targeted lookup over that ref's (few) definitions.
1355
     */
1356
    private static String spaceRefAliveFilter() {
1357
        return """
33✔
1358
                FILTER EXISTS {
1359
                  GRAPH <%1$s> {
1360
                    ?liveDef a npa:SpaceDefinition ;
1361
                             npa:forSpaceRef ?spaceRef ;
1362
                             npa:viaNanopub  ?liveNp .
1363
                  }
1364
                  %2$s
1365
                }
1366
                """.formatted(SpacesVocab.SPACES_GRAPH, invalidationFilter("liveNp"));
9✔
1367
    }
1368

1369
    /**
1370
     * {@code gen:hasRole} attachment validation: an attachment is validated iff its
1371
     * publisher is already a validated admin of the target space. Adds
1372
     * {@code gen:RoleAssignment} rows to the space-state graph.
1373
     */
1374
    static String attachmentValidationUpdate(IRI graph, long lastProcessed) {
1375
        // Ref-keyed (see doc/design-spaceref-isolation.md). The attachment names a bare
1376
        // Space IRI; it is validated per-ref for every ref of that IRI whose admin set
1377
        // contains the publisher (direct), or — when the named IRI is an owl:sameAs alias
1378
        // — for the canonical ref it maps to (issue #113). ?targetRef is the ref the
1379
        // RoleAssignment attaches to; the inserted subject is minted per (?ra, ?targetRef)
1380
        // so one attachment validating into N refs yields N distinct rows.
1381
        // TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace (the attached IRI, possibly an
1382
        // alias) is kept so the non-admin tier can probe the IRI-keyed instantiations
1383
        // naming it, and so pre-ref read queries keep functioning on a mixed-version fleet.
1384
        return """
69✔
1385
                PREFIX npa:  <%1$s>
1386
                PREFIX gen:  <%2$s>
1387
                INSERT { GRAPH <%3$s> {
1388
                  ?ra2 a gen:RoleAssignment ;
1389
                       npa:forSpaceRef ?targetRef ;
1390
                       npa:forSpace ?space ;
1391
                       gen:hasRole  ?role ;
1392
                       npa:viaNanopub ?np .
1393
                } }
1394
                WHERE {
1395
                  GRAPH <%4$s> {
1396
                    ?ra a gen:RoleAssignment ;
1397
                        npa:forSpace ?space ;
1398
                        gen:hasRole  ?role ;
1399
                        npa:pubkeyHash ?pkh ;
1400
                        npa:viaNanopub ?np .
1401
                    # Attachment timestamp for the detachment latest-wins (issue #129).
1402
                    OPTIONAL { ?ra <http://purl.org/dc/terms/created> ?attCreatedRaw . }
1403
                  }
1404
                  BIND(COALESCE(?attCreatedRaw, %8$s) AS ?attCreated)
1405
                  GRAPH <%7$s> {
1406
                    ?np npa:hasLoadNumber ?ln .
1407
                    FILTER (?ln > %5$d)
1408
                  }
1409
                  GRAPH <%3$s> {
1410
                    ?acct a npa:AccountState ;
1411
                          npa:agent  ?publisher ;
1412
                          npa:pubkey ?pkh .
1413
                  }
1414
                  # Per-ref admin gate. ?targetRef = a ref of ?space the publisher admins
1415
                  # (direct), or the canonical ref ?space is an owl:sameAs alias of.
1416
                  {
1417
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?space . }
1418
                    GRAPH <%3$s> {
1419
                      ?adminRI a gen:RoleInstantiation ;
1420
                               npa:forSpaceRef ?targetRef ;
1421
                               npa:inverseProperty gen:hasAdmin ;
1422
                               npa:forAgent ?publisher .
1423
                    }
1424
                  }
1425
                  UNION
1426
                  {
1427
                    GRAPH <%3$s> {
1428
                      ?space npa:sameAsSpace ?targetRef .
1429
                      ?adminRI a gen:RoleInstantiation ;
1430
                               npa:forSpaceRef ?targetRef ;
1431
                               npa:inverseProperty gen:hasAdmin ;
1432
                               npa:forAgent ?publisher .
1433
                    }
1434
                  }
1435
                  BIND(IRI(CONCAT(STR(?ra), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?ra2)
1436
                  %6$s
1437
                  # Detachment latest-wins (issue #129): suppress if a newer admin-authored
1438
                  # gen:detachedRole out-ranks this (ref, role) attachment.
1439
                  %9$s
1440
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1441
                    ?existing a gen:RoleAssignment ;
1442
                              npa:forSpaceRef ?targetRef ;
1443
                              gen:hasRole  ?role .
1444
                  } }
1445
                }
1446
                """.formatted(
3✔
1447
                NPA.NAMESPACE,
1448
                GEN.NAMESPACE,
1449
                graph,
1450
                SpacesVocab.SPACES_GRAPH,
1451
                lastProcessed,
15✔
1452
                invalidationFilter("np"),
45✔
1453
                NPA.GRAPH,
1454
                EPOCH_DT,
1455
                roleDetachmentSuppressionFilter(graph, "attCreated", "ra"));
6✔
1456
    }
1457

1458
    /**
1459
     * Preset-bundled role materialization (Nanodash issue #302). For each active,
1460
     * admin-authored {@code gen:PresetAssignment} targeting a {@code gen:Space}, inserts
1461
     * one {@code gen:RoleAssignment} per role the preset bundles — exactly as if
1462
     * {@code <space> gen:hasRole <role>} had been published by the assignment's publisher.
1463
     * The materialized rows carry {@code npa:derivedFromPreset} (the assignment nanopub)
1464
     * so the deactivation delete and read-side marking can scope to them without touching
1465
     * directly-published attachments. See {@code doc/design-preset-role-materialization.md}.
1466
     *
1467
     * <p>Activation is resolved by an <b>authorization-scoped latest-wins</b> over the
1468
     * {@code (preset, resource)} pair, NOT {@code npx:invalidates} (§3): the candidate set
1469
     * for the {@code MAX(dct:created)} comparison is restricted to assignments whose
1470
     * publisher is also a validated admin of the target ref, so an unauthorized key's newer
1471
     * assignment cannot shadow an admin's activation (the #113-class anti-hijack rule).
1472
     */
1473
    static String presetAttachmentValidationUpdate(IRI graph, long lastProcessed) {
1474
        // Ref-keyed like attachmentValidationUpdate: the assignment names a bare resource
1475
        // IRI; it is validated per-ref for every Space ref of that IRI whose admin set
1476
        // contains the publisher. The inserted subject is minted per (assignment, ref, role)
1477
        // — one assignment fans out to N roles and N refs. Non-Space targets resolve no
1478
        // ?targetRef and so insert nothing (correct no-op; maintained-resource / individual
1479
        // targets are future work, see design doc §2). TRANSITIONAL-DUAL-EMIT (Phase 4:
1480
        // remove): forSpace kept alongside forSpaceRef so the non-admin tiers can probe the
1481
        // IRI-keyed instantiations and pre-ref read queries keep functioning.
1482
        return """
69✔
1483
                PREFIX npa:  <%1$s>
1484
                PREFIX gen:  <%2$s>
1485
                INSERT { GRAPH <%3$s> {
1486
                  ?ra2 a gen:RoleAssignment ;
1487
                       npa:forSpaceRef ?targetRef ;
1488
                       npa:forSpace    ?resource ;
1489
                       gen:hasRole     ?role ;
1490
                       npa:viaNanopub  ?assignNp ;
1491
                       npa:derivedFromPreset ?assignNp .
1492
                } }
1493
                WHERE {
1494
                  # 1. Anchor: active preset assignments in the extraction graph.
1495
                  GRAPH <%4$s> {
1496
                    ?pa a npa:PresetAssignment ;
1497
                        npa:ofPreset    ?preset ;
1498
                        npa:forResource ?resource ;
1499
                        npa:isActivated true ;
1500
                        npa:pubkeyHash  ?pkh ;
1501
                        npa:viaNanopub  ?assignNp ;
1502
                        <http://purl.org/dc/terms/created> ?created .
1503
                  }
1504
                  # 2. Load-number filter on the assignment nanopub.
1505
                  GRAPH <%7$s> {
1506
                    ?assignNp npa:hasLoadNumber ?ln .
1507
                    FILTER (?ln > %5$d)
1508
                  }
1509
                  # 3. Resolve publisher pkh -> agent via the mirrored trust-approved row.
1510
                  GRAPH <%3$s> {
1511
                    ?acct a npa:AccountState ;
1512
                          npa:agent  ?publisher ;
1513
                          npa:pubkey ?pkh .
1514
                  }
1515
                  # 4. Target must be a Space ref the publisher admins — direct, or the
1516
                  #    canonical ref ?resource is an owl:sameAs alias of (issue #113 parity
1517
                  #    with attachmentValidationUpdate, so a preset assigned against an alias
1518
                  #    IRI still materializes against the canonical ref).
1519
                  {
1520
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?resource . }
1521
                    GRAPH <%3$s> {
1522
                      ?adminRI a gen:RoleInstantiation ;
1523
                               npa:forSpaceRef ?targetRef ;
1524
                               npa:inverseProperty gen:hasAdmin ;
1525
                               npa:forAgent ?publisher .
1526
                    }
1527
                  }
1528
                  UNION
1529
                  {
1530
                    GRAPH <%3$s> {
1531
                      ?resource npa:sameAsSpace ?targetRef .
1532
                      ?adminRI a gen:RoleInstantiation ;
1533
                               npa:forSpaceRef ?targetRef ;
1534
                               npa:inverseProperty gen:hasAdmin ;
1535
                               npa:forAgent ?publisher .
1536
                    }
1537
                  }
1538
                  # 5. Resolve the assignment's referenced preset IRI (node or kind) to its
1539
                  #    canonical kind, mirroring how Nanodash views key on dct:isVersionOf
1540
                  #    (ViewDisplay.getViewKindIri). Every declaration carries npa:ofPreset for
1541
                  #    both its node IRI and kind, so either reference maps to the same ?kind.
1542
                  GRAPH <%4$s> {
1543
                    ?pdMap a npa:PresetDeclaration ;
1544
                           npa:ofPreset   ?preset ;
1545
                           npa:presetKind ?kind .
1546
                  }
1547
                  # 5a. Roles come from the LATEST live declaration of that kind, restricted to
1548
                  #     Space-targeted presets — so a superseded preset version's roles never leak
1549
                  #     (the per-view-kind latest-wins, ported to materialization).
1550
                  GRAPH <%4$s> {
1551
                    ?pd a npa:PresetDeclaration ;
1552
                        npa:presetKind           ?kind ;
1553
                        npa:presetRole           ?role ;
1554
                        npa:appliesToInstancesOf gen:Space ;
1555
                        npa:viaNanopub           ?pdNp ;
1556
                        <http://purl.org/dc/terms/created> ?pdCreated .
1557
                  }
1558
                  # 5b. Latest-declaration-per-kind: reject if a newer LIVE declaration of the
1559
                  #     same kind exists (tiebreak on subject IRI for equal timestamps).
1560
                  FILTER NOT EXISTS {
1561
                    GRAPH <%4$s> {
1562
                      ?pdNewer a npa:PresetDeclaration ;
1563
                               npa:presetKind ?kind ;
1564
                               npa:viaNanopub ?pdNpNewer ;
1565
                               <http://purl.org/dc/terms/created> ?pdCreatedNewer .
1566
                      FILTER (?pdCreatedNewer > ?pdCreated
1567
                              || (?pdCreatedNewer = ?pdCreated && STR(?pdNewer) > STR(?pd)))
1568
                    }
1569
                    %8$s
1570
                  }
1571
                  # 5c. The chosen declaration must itself be live (not superseded/retracted).
1572
                  %9$s
1573
                  # 6. Mint the per (assignment, ref, role) subject.
1574
                  BIND(IRI(CONCAT(STR(?pa), "__", ENCODE_FOR_URI(STR(?targetRef)),
1575
                                  "__", ENCODE_FOR_URI(STR(?role)))) AS ?ra2)
1576
                  # 7. Authorization-scoped latest-wins (anti-hijack, design doc §3): reject
1577
                  #    if a newer same-(preset,resource) assignment exists whose publisher is
1578
                  #    ALSO a validated admin of ?targetRef. Filtering the shadowing candidate
1579
                  #    to admin-authored rows BEFORE taking the latest is what stops an
1580
                  #    unauthorized key from suppressing an admin's activation. Placed after
1581
                  #    the main vars are bound so the planner defers it (RDF4J quirk).
1582
                  FILTER NOT EXISTS {
1583
                    GRAPH <%4$s> {
1584
                      ?paNewer a npa:PresetAssignment ;
1585
                               npa:ofPreset    ?preset ;
1586
                               npa:forResource ?resource ;
1587
                               npa:pubkeyHash  ?pkhNewer ;
1588
                               <http://purl.org/dc/terms/created> ?createdNewer .
1589
                      FILTER (?createdNewer > ?created
1590
                              || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
1591
                    }
1592
                    GRAPH <%3$s> {
1593
                      ?acctNewer a npa:AccountState ;
1594
                                 npa:agent  ?publisherNewer ;
1595
                                 npa:pubkey ?pkhNewer .
1596
                      ?adminRINewer a gen:RoleInstantiation ;
1597
                                    npa:forSpaceRef ?targetRef ;
1598
                                    npa:inverseProperty gen:hasAdmin ;
1599
                                    npa:forAgent ?publisherNewer .
1600
                    }
1601
                  }
1602
                  # 8. Defensive: drop if the assignment nanopub itself was hard-retracted.
1603
                  %6$s
1604
                  # 8a. Detachment latest-wins (issue #129): suppress if a newer admin-authored
1605
                  #     gen:detachedRole out-ranks this preset-derived (ref, role) attachment.
1606
                  #     Non-sticky: a newer PresetAssignment (newer ?created) re-attaches.
1607
                  %10$s
1608
                  # 9. Dedup last — keyed on (ref, role).
1609
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1610
                    ?existing a gen:RoleAssignment ;
1611
                              npa:forSpaceRef ?targetRef ;
1612
                              gen:hasRole ?role .
1613
                  } }
1614
                }
1615
                """.formatted(
3✔
1616
                NPA.NAMESPACE,
1617
                GEN.NAMESPACE,
1618
                graph,
1619
                SpacesVocab.SPACES_GRAPH,
1620
                lastProcessed,
15✔
1621
                invalidationFilter("assignNp"),
27✔
1622
                NPA.GRAPH,
1623
                invalidationFilter("pdNpNewer"),
15✔
1624
                invalidationFilter("pdNp"),
21✔
1625
                roleDetachmentSuppressionFilter(graph, "created", "pa"));
6✔
1626
    }
1627

1628
    /**
1629
     * Stamps a ref-scoped, admin-validated mirror of each {@code npa:PresetAssignment}
1630
     * into the state graph (issue #122). The publisher-agnostic extraction row
1631
     * ({@link SpacesExtractor#extractPresetAssignment}) is keyed only by
1632
     * {@code npa:forResource}, so a consumer listing a space's preset assignments by IRI
1633
     * sees the union across <em>all</em> refs claiming that IRI. This stamp adds
1634
     * {@code npa:forSpaceRef ?targetRef} so the "Assigned presets" listing is no longer
1635
     * merged across refs of the same IRI — the one remaining About-tab listing that still
1636
     * merged across refs (every other ref-scoped listing already has a {@code forSpaceRef}
1637
     * companion).
1638
     *
1639
     * <p>Faithful per-assignment mirror — deliberately <em>not</em> role-gated and
1640
     * <em>not</em> latest-wins-resolved, unlike {@link #presetAttachmentValidationUpdate}:
1641
     * <ul>
1642
     *   <li>No {@code npa:PresetDeclaration}/role join, so a preset that bundles only
1643
     *       <em>views</em> (no roles) is still listed.</li>
1644
     *   <li>Emits active <em>and</em> deactivated rows (carries {@code npa:isActivated})
1645
     *       so the listing can show state; a deactivation is just a newer admin-authored
1646
     *       row, so no {@code dct:created}-driven removal is needed here (contrast §4.4).</li>
1647
     *   <li>Latest-wins is deferred to the consumer query, which ranges only over these
1648
     *       admin-authored rows — so it is authorization-scoped for free (design §3): a
1649
     *       non-admin of the ref can never get a row stamped, so it cannot enter the
1650
     *       latest-wins race.</li>
1651
     * </ul>
1652
     *
1653
     * <p>Display-only leaf: nothing downstream derives from these rows (contrast the
1654
     * preset-derived {@code gen:RoleAssignment}), so the caller must <em>not</em> feed this
1655
     * tier's count into {@code structuralAdds}. The {@code npa:forSpaceRef} predicate also
1656
     * distinguishes a stamped row from the IRI-keyed extraction row (which never carries it),
1657
     * so {@link #presetAssignmentRefInvalidationDelete} can target exactly these rows.
1658
     * Reuses steps 1–4 of {@link #presetAttachmentValidationUpdate}; see
1659
     * doc/design-preset-role-materialization.md §3 and issue #122.
1660
     */
1661
    static String presetAssignmentRefStampUpdate(IRI graph, long lastProcessed) {
1662
        return """
69✔
1663
                PREFIX npa:  <%1$s>
1664
                PREFIX gen:  <%2$s>
1665
                INSERT { GRAPH <%3$s> {
1666
                  ?paRef a npa:PresetAssignment ;
1667
                         npa:ofPreset    ?preset ;
1668
                         npa:forResource ?resource ;
1669
                         npa:forSpaceRef ?targetRef ;
1670
                         npa:isActivated ?activated ;
1671
                         npa:viaNanopub  ?assignNp ;
1672
                         <http://purl.org/dc/terms/created> ?created .
1673
                } }
1674
                WHERE {
1675
                  # 1. Anchor: every assignment row (active or not) in the extraction graph.
1676
                  GRAPH <%4$s> {
1677
                    ?pa a npa:PresetAssignment ;
1678
                        npa:ofPreset    ?preset ;
1679
                        npa:forResource ?resource ;
1680
                        npa:isActivated ?activated ;
1681
                        npa:pubkeyHash  ?pkh ;
1682
                        npa:viaNanopub  ?assignNp ;
1683
                        <http://purl.org/dc/terms/created> ?created .
1684
                  }
1685
                  # 2. Load-number filter on the assignment nanopub (delta window).
1686
                  GRAPH <%6$s> {
1687
                    ?assignNp npa:hasLoadNumber ?ln .
1688
                    FILTER (?ln > %5$d)
1689
                  }
1690
                  # 3. Resolve publisher pkh -> agent via the mirrored trust-approved row.
1691
                  GRAPH <%3$s> {
1692
                    ?acct a npa:AccountState ;
1693
                          npa:agent  ?publisher ;
1694
                          npa:pubkey ?pkh .
1695
                  }
1696
                  # 4. Target must be a Space ref the publisher admins. ?targetRef = that ref;
1697
                  #    fan-out to N refs the publisher admins (per-ref isolation, consistent
1698
                  #    with the role materializer and design-spaceref-isolation.md). Direct,
1699
                  #    or the canonical ref ?resource is an owl:sameAs alias of (issue #113),
1700
                  #    so an assignment naming an alias is still listed under the canonical ref.
1701
                  {
1702
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?resource . }
1703
                    GRAPH <%3$s> {
1704
                      ?adminRI a gen:RoleInstantiation ;
1705
                               npa:forSpaceRef ?targetRef ;
1706
                               npa:inverseProperty gen:hasAdmin ;
1707
                               npa:forAgent ?publisher .
1708
                    }
1709
                  }
1710
                  UNION
1711
                  {
1712
                    GRAPH <%3$s> {
1713
                      ?resource npa:sameAsSpace ?targetRef .
1714
                      ?adminRI a gen:RoleInstantiation ;
1715
                               npa:forSpaceRef ?targetRef ;
1716
                               npa:inverseProperty gen:hasAdmin ;
1717
                               npa:forAgent ?publisher .
1718
                    }
1719
                  }
1720
                  # 5. Defensive: drop if the assignment nanopub itself was hard-retracted.
1721
                  %7$s
1722
                  # 6. Mint per (assignment, ref); dedup on the bound subject. No latest-wins
1723
                  #    here — a deactivation is just a newer admin-authored row, and the
1724
                  #    consumer resolves latest dct:created per (preset,resource) over these
1725
                  #    admin-authored rows (so the resolution is authorization-scoped).
1726
                  BIND(IRI(CONCAT(STR(?pa), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?paRef)
1727
                  FILTER NOT EXISTS { GRAPH <%3$s> { ?paRef a npa:PresetAssignment . } }
1728
                }
1729
                """.formatted(
3✔
1730
                NPA.NAMESPACE,
1731
                GEN.NAMESPACE,
1732
                graph,
1733
                SpacesVocab.SPACES_GRAPH,
1734
                lastProcessed,
27✔
1735
                NPA.GRAPH,
1736
                invalidationFilter("assignNp"));
6✔
1737
    }
1738

1739
    /**
1740
     * Non-admin tier publisher constraints (inserted as a SPARQL sub-pattern).
1741
     * Each constraint owns the AccountState (pkh → agent) lookup so the join
1742
     * variable is bound through a targeted pattern. The observer-self variant
1743
     * binds {@code npa:agent ?agent} directly — no separate {@code ?publisher}
1744
     * variable, no post-join equality filter — which lets the planner anchor
1745
     * the AccountState lookup on the already-bound {@code ?agent} instead of
1746
     * enumerating all approved publishers and filtering at the end.
1747
     */
1748
    static final String PUBLISHER_IS_ADMIN = """
1749
            ?acct a npa:AccountState ;
1750
                  npa:pubkey ?pkh ;
1751
                  npa:agent  ?publisher .
1752
            # Admin of the assignment's ref. The ref already resolves alias →
1753
            # canonical (the attachment tier bound ?spaceRef through the owl:sameAs
1754
            # alias edge for aliased IRIs, issue #113), so no alias arm is needed here.
1755
            ?adminRI a gen:RoleInstantiation ;
1756
                     npa:forSpaceRef ?spaceRef ;
1757
                     npa:inverseProperty gen:hasAdmin ;
1758
                     npa:forAgent ?publisher .
1759
            """;
1760

1761
    /** Observer self-evidence: the assignee's own pubkey signed the instantiation. */
1762
    static final String PUBLISHER_IS_SELF = """
1763
            ?acct a npa:AccountState ;
1764
                  npa:pubkey ?pkh ;
1765
                  npa:agent  ?agent .
1766
            """;
1767

1768
    /**
1769
     * Maintainer / Member / Observer tier INSERT. Same shape: find an instantiation
1770
     * whose predicate matches a RoleDeclaration of the given tier attached to the
1771
     * target space, and whose publisher passes the tier-specific constraint.
1772
     */
1773
    static String nonAdminTierUpdate(IRI graph, long lastProcessed,
1774
                                     IRI tierClass, String publisherConstraint) {
1775
        // Order tuned for RDF4J's evaluator (which executes BGPs roughly in order).
1776
        // The crucial choice is the *anchor*: instantiation-first plans send the
1777
        // planner exploring the full ~thousands of candidate RIs and only filter
1778
        // by tier at the very end. Attachment-first anchors on the small set of
1779
        // gen:RoleAssignment rows already validated in this space-state graph
1780
        // (~hundreds, often zero) and walks outward by bound (?role, ?space).
1781
        //
1782
        //   1. Anchor on RoleAssignments in this space-state graph (small).
1783
        //   1a. Resolve the IRIs that denote the assignment's ref — its canonical
1784
        //      IRI plus any validated owl:sameAs aliases — so an instantiation that
1785
        //      names an alias of the space still matches (issue #113). Bound here so
1786
        //      the instantiation lookup below stays anchored by ?instSpace.
1787
        //   2. Match the tier-pinned RoleDeclaration by ?role.
1788
        //   3. Pair role-decl direction to instantiation direction in one UNION
1789
        //      so only (reg, reg)/(inv, inv) combos are explored.
1790
        //   4. Targeted instantiation lookup — (?instSpace, ?pred) are bound.
1791
        //   5. Publisher constraint (incl. AccountState resolution).
1792
        //   6. Load-number filter on bound ?np.
1793
        //   7. Dedup at the end.
1794
        return """
69✔
1795
                PREFIX npa:  <%1$s>
1796
                PREFIX gen:  <%2$s>
1797
                INSERT { GRAPH <%3$s> {
1798
                  ?ri2 a gen:RoleInstantiation ;
1799
                       npa:forSpaceRef ?spaceRef ;
1800
                       # TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace alongside
1801
                       # forSpaceRef so pre-ref read queries (e.g. get-space-members) keep
1802
                       # functioning on a mixed-version fleet; downstream tiers key on the ref.
1803
                       npa:forSpace ?space ;
1804
                       npa:forAgent ?agent ;
1805
                       ?dirPred ?pred ;
1806
                       # Persist the tier and role IRI that are already bound at this point —
1807
                       # the loop's tierClass arg (%7$s) and the anchoring attachment's ?role
1808
                       # (step 1) — so ref-scoped consumers key on identity rather than
1809
                       # re-deriving the tier from the bare predicate against GLOBAL
1810
                       # RoleDeclarations. The bare-predicate re-derivation bleeds tiers
1811
                       # across spaces that declare the same predicate at different tiers
1812
                       # (issue #125): consumers should match ?ri2 npa:hasRoleType <tier>
1813
                       # / gen:hasRole ?role, exactly as the *-roles-ref queries do.
1814
                       npa:hasRoleType <%7$s> ;
1815
                       gen:hasRole ?role ;
1816
                       npa:viaNanopub ?np .
1817
                } }
1818
                WHERE {
1819
                  # 1. Anchor: validated attachments in this space-state graph (ref-keyed).
1820
                  GRAPH <%3$s> {
1821
                    ?ra a gen:RoleAssignment ;
1822
                        gen:hasRole     ?role ;
1823
                        npa:forSpaceRef ?spaceRef ;
1824
                        npa:forSpace    ?space .
1825
                  }
1826
                  # 1a. The IRIs that denote this ref: its canonical IRI, plus any validated
1827
                  #     owl:sameAs aliases of it (issue #113) — so an instantiation naming an
1828
                  #     alias of the space still materializes here. Bound BEFORE the
1829
                  #     instantiation BGP so that lookup stays anchored by ?instSpace (planner
1830
                  #     note above); ?spaceRef is already bound, so each arm is a targeted
1831
                  #     lookup yielding a tiny IRI set. The alias arm only follows admin-
1832
                  #     validated npa:sameAsSpace edges, so it grants no authority the admin
1833
                  #     tier would not (anti-hijack is enforced upstream, not relaxed here).
1834
                  {
1835
                    GRAPH <%4$s> { ?spaceRef npa:spaceIri ?instSpace . }
1836
                  }
1837
                  UNION
1838
                  {
1839
                    GRAPH <%3$s> { ?instSpace npa:sameAsSpace ?spaceRef . }
1840
                  }
1841
                  # 2. Tier-pinned RoleDeclaration (?role bound from the attachment). Its
1842
                  #    nanopub's invalidation is intentionally NOT consulted (see step 7), so
1843
                  #    no ?rdNp binding is needed.
1844
                  GRAPH <%4$s> {
1845
                    ?rd a npa:RoleDeclaration ;
1846
                        npa:hasRoleType <%7$s> ;
1847
                        npa:role        ?role .
1848
                    # 3. Pair role-decl direction to the instantiation in one UNION so only
1849
                    #    matching combos are explored, binding (?instSpace, ?agent) per arm.
1850
                    #    ?dirPred carries the resolved direction so the materialized row
1851
                    #    records the role property (read by get-space-members and
1852
                    #    publisherIsTieredRole) — identical shape whichever arm matched.
1853
                    #
1854
                    #    The first two arms handle instantiations the extractor already
1855
                    #    classified (npa:regularProperty / npa:inverseProperty). The last two
1856
                    #    resolve a custom predicate the extractor left neutral (npa:rolePredicate
1857
                    #    with raw npa:bindingSubject / npa:bindingObject): the role declaration
1858
                    #    supplies the direction, which fixes which raw endpoint is the space vs
1859
                    #    the agent. INVERSE = <space> pred <agent>; REGULAR = <agent> pred <space>.
1860
                    {
1861
                      ?rd gen:hasRegularProperty ?pred .
1862
                      ?ri npa:regularProperty ?pred ;
1863
                          npa:forSpace ?instSpace ;
1864
                          npa:forAgent ?agent .
1865
                      BIND(npa:regularProperty AS ?dirPred)
1866
                    }
1867
                    UNION
1868
                    {
1869
                      ?rd gen:hasInverseProperty ?pred .
1870
                      ?ri npa:inverseProperty ?pred ;
1871
                          npa:forSpace ?instSpace ;
1872
                          npa:forAgent ?agent .
1873
                      BIND(npa:inverseProperty AS ?dirPred)
1874
                    }
1875
                    UNION
1876
                    {
1877
                      ?rd gen:hasInverseProperty ?pred .
1878
                      ?ri npa:rolePredicate   ?pred ;
1879
                          npa:bindingSubject  ?instSpace ;
1880
                          npa:bindingObject   ?agent .
1881
                      BIND(npa:inverseProperty AS ?dirPred)
1882
                    }
1883
                    UNION
1884
                    {
1885
                      ?rd gen:hasRegularProperty ?pred .
1886
                      ?ri npa:rolePredicate   ?pred ;
1887
                          npa:bindingObject   ?instSpace ;
1888
                          npa:bindingSubject  ?agent .
1889
                      BIND(npa:regularProperty AS ?dirPred)
1890
                    }
1891
                    # 4. Common instantiation columns. ?instSpace was resolved to this ref
1892
                    #    above (canonical or owl:sameAs alias), so an alias-named instantiation
1893
                    #    joins the same ?spaceRef as a canonical one. The materialized row still
1894
                    #    carries npa:forSpace ?space (the attachment's IRI) for the transitional
1895
                    #    dual-emit, so pre-ref reads see the member under the space's primary IRI.
1896
                    ?ri a gen:RoleInstantiation ;
1897
                        npa:pubkeyHash ?pkh ;
1898
                        npa:viaNanopub ?np .
1899
                    # Candidate grant timestamp for the revocation latest-wins (issue #129);
1900
                    # absent ⇒ epoch (always loses).
1901
                    OPTIONAL { ?ri <http://purl.org/dc/terms/created> ?candCreatedRaw . }
1902
                  }
1903
                  BIND(COALESCE(?candCreatedRaw, %11$s) AS ?candCreated)
1904
                  # 5. Publisher constraint (incl. AccountState resolution).
1905
                  GRAPH <%3$s> {
1906
                    %8$s
1907
                  }
1908
                  # 5a. Mint the per-ref state subject: (?ri, ?spaceRef) → ?ri2.
1909
                  BIND(IRI(CONCAT(STR(?ri), "__", ENCODE_FOR_URI(STR(?spaceRef)))) AS ?ri2)
1910
                  # 6. Load-number filter on bound ?np.
1911
                  GRAPH <%9$s> {
1912
                    ?np npa:hasLoadNumber ?ln .
1913
                    FILTER (?ln > %5$d)
1914
                  }
1915
                  # 7. Instantiation invalidation filter — outside the GRAPH block so the
1916
                  #    planner defers it until ?np is bound. Role-DECLARATION invalidation is
1917
                  #    deliberately NOT consulted: the tier already anchors on the admin-
1918
                  #    validated attachment (?ra), which is removed when an admin retracts it,
1919
                  #    so admin control is fully enforced there. Letting the declaration's
1920
                  #    author (usually not the space admin) supersede/retract their declaration
1921
                  #    strip a space's members is the same cross-author-strip anti-pattern as
1922
                  #    issue #112. Role IRIs are version-pinned, so the attached definition is
1923
                  #    immutable regardless of the declaration nanopub's later lifecycle.
1924
                  %6$s
1925
                  # 7a. Revocation latest-wins (issue #129): suppress if a newer authorized
1926
                  #     gen:RevokedRoleInstantiation shadows this (space, agent, role) key.
1927
                  %10$s
1928
                  # 8. Dedup last — keyed on (ref, agent, nanopub).
1929
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1930
                    ?existing a gen:RoleInstantiation ;
1931
                              npa:forSpaceRef ?spaceRef ;
1932
                              npa:forAgent ?agent ;
1933
                              npa:viaNanopub ?np .
1934
                  } }
1935
                }
1936
                """.formatted(
3✔
1937
                NPA.NAMESPACE,
1938
                GEN.NAMESPACE,
1939
                graph,
1940
                SpacesVocab.SPACES_GRAPH,
1941
                lastProcessed,
15✔
1942
                invalidationFilter("np"),
54✔
1943
                tierClass,
1944
                publisherConstraint,
1945
                NPA.GRAPH,
1946
                nonAdminRevocationSuppressionFilter(graph, tierClass),
18✔
1947
                EPOCH_DT);
1948
    }
1949

1950
    /**
1951
     * Sub-space admit pass. Copies validated {@code npa:SubSpaceDeclaration}
1952
     * extraction rows into the space-state graph (preserving the {@code npasub:}
1953
     * subject) and emits convenience {@code <child> npa:isSubSpaceOf <parent>} and
1954
     * {@code <parent> npa:hasSubSpace <child>} direct triples. Two satisfaction
1955
     * modes joined by UNION:
1956
     * <ul>
1957
     *   <li>Mode A — the declaration's publisher is a validated admin of both the
1958
     *       child and the parent space.</li>
1959
     *   <li>Mode B — a different non-invalidated declaration for the same
1960
     *       {@code (child, parent)} pair exists, and the two publishers between
1961
     *       them cover both admin sides (i.e. one of them is admin of the child,
1962
     *       one of them is admin of the parent — possibly the same one twice if
1963
     *       both happen to be admin of both).</li>
1964
     * </ul>
1965
     *
1966
     * <p>Mode-B late-arrival: when only the partner declaration is new in this
1967
     * cycle (the primary is older than {@code lastProcessed}), the load-number
1968
     * filter on {@code ?np} excludes the candidate. The late-arrival sweep
1969
     * ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass without the
1970
     * load filter and catches it.
1971
     */
1972
    static String subSpaceAdmitUpdate(IRI graph, long lastProcessed) {
1973
        return """
69✔
1974
                PREFIX npa: <%1$s>
1975
                PREFIX gen: <%2$s>
1976
                INSERT { GRAPH <%3$s> {
1977
                  ?d a npa:SubSpaceDeclaration ;
1978
                     npa:childSpace  ?child ;
1979
                     npa:parentSpace ?parent ;
1980
                     npa:viaNanopub  ?np .
1981
                  ?childRef  npa:isSubSpaceOf ?parentRef .
1982
                  ?parentRef npa:hasSubSpace  ?childRef  .
1983
                  # Reified per-(nanopub, ref-pair) provenance link (issue #125 finding #5):
1984
                  # carries npa:viaNanopub plus both the ref and IRI endpoints, so the
1985
                  # invalidation cleanup can drop the convenience edges below once no
1986
                  # surviving link backs them — instead of leaving them sticky until the
1987
                  # next periodic full rebuild.
1988
                  ?ssLink a npa:SubSpaceLink ;
1989
                          npa:viaNanopub     ?np ;
1990
                          npa:childSpaceRef  ?childRef ;
1991
                          npa:parentSpaceRef ?parentRef ;
1992
                          npa:childSpace     ?child ;
1993
                          npa:parentSpace    ?parent .
1994
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
1995
                  # sub-space edge alongside the ref-to-ref one, so pre-ref published
1996
                  # queries that key on the bare Space IRI keep binding on a mixed-version
1997
                  # fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
1998
                  ?child  npa:isSubSpaceOf ?parent .
1999
                  ?parent npa:hasSubSpace  ?child  .
2000
                } }
2001
                WHERE {
2002
                  # 1. Anchor: candidate declarations from the extraction graph.
2003
                  GRAPH <%4$s> {
2004
                    ?d a npa:SubSpaceDeclaration ;
2005
                       npa:childSpace  ?child ;
2006
                       npa:parentSpace ?parent ;
2007
                       npa:pubkeyHash  ?pkh ;
2008
                       npa:viaNanopub  ?np .
2009
                  }
2010
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
2011
                  GRAPH <%3$s> {
2012
                    ?acct a npa:AccountState ;
2013
                          npa:pubkey ?pkh ;
2014
                          npa:agent  ?publisher .
2015
                  }
2016
                  # 3. Authority gate, ref-keyed. The edge is emitted ref-to-ref between
2017
                  #    the child ref and parent ref the authorizing admin governs; the
2018
                  #    admin rows' dual-emitted npa:forSpace binds the refs to the child /
2019
                  #    parent IRIs (cross-product when an IRI has several governed refs).
2020
                  {
2021
                    # Mode A — publisher is admin of BOTH a child ref and a parent ref.
2022
                    GRAPH <%3$s> {
2023
                      ?riC a gen:RoleInstantiation ;
2024
                           npa:inverseProperty gen:hasAdmin ;
2025
                           npa:forSpace ?child ;
2026
                           npa:forSpaceRef ?childRef ;
2027
                           npa:forAgent ?publisher .
2028
                      ?riP a gen:RoleInstantiation ;
2029
                           npa:inverseProperty gen:hasAdmin ;
2030
                           npa:forSpace ?parent ;
2031
                           npa:forSpaceRef ?parentRef ;
2032
                           npa:forAgent ?publisher .
2033
                    }
2034
                  }
2035
                  UNION
2036
                  {
2037
                    # Mode B — co-declaration whose publisher covers the side this
2038
                    # one's publisher doesn't. Between {publisher, publisher2},
2039
                    # both admin sides must be covered.
2040
                    GRAPH <%4$s> {
2041
                      ?d2 a npa:SubSpaceDeclaration ;
2042
                          npa:childSpace  ?child ;
2043
                          npa:parentSpace ?parent ;
2044
                          npa:pubkeyHash  ?pkh2 ;
2045
                          npa:viaNanopub  ?np2 .
2046
                      FILTER (?np2 != ?np)
2047
                    }
2048
                    %8$s
2049
                    GRAPH <%3$s> {
2050
                      ?acct2 a npa:AccountState ;
2051
                             npa:pubkey ?pkh2 ;
2052
                             npa:agent  ?publisher2 .
2053
                      ?riA a gen:RoleInstantiation ;
2054
                           npa:inverseProperty gen:hasAdmin ;
2055
                           npa:forSpace ?child ;
2056
                           npa:forSpaceRef ?childRef .
2057
                      { ?riA npa:forAgent ?publisher } UNION { ?riA npa:forAgent ?publisher2 }
2058
                      ?riB a gen:RoleInstantiation ;
2059
                           npa:inverseProperty gen:hasAdmin ;
2060
                           npa:forSpace ?parent ;
2061
                           npa:forSpaceRef ?parentRef .
2062
                      { ?riB npa:forAgent ?publisher } UNION { ?riB npa:forAgent ?publisher2 }
2063
                    }
2064
                  }
2065
                  # 4. Invalidation filter on the primary declaration's nanopub.
2066
                  %6$s
2067
                  # 5. Load-number filter on bound ?np.
2068
                  GRAPH <%7$s> {
2069
                    ?np npa:hasLoadNumber ?ln .
2070
                    FILTER (?ln > %5$d)
2071
                  }
2072
                  # 6. Mint the per-(nanopub, ref-pair) provenance link IRI and dedup on it
2073
                  #    (not on the bare edge). Keyed on ?np so every backing declaration of
2074
                  #    the same ref-pair records its own removable link; the convenience
2075
                  #    edges above are re-asserted idempotently.
2076
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/subspace/",
2077
                                  MD5(CONCAT(STR(?np), "|", STR(?childRef), "|", STR(?parentRef))))) AS ?ssLink)
2078
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2079
                    ?ssLink a npa:SubSpaceLink .
2080
                  } }
2081
                }
2082
                """.formatted(
3✔
2083
                NPA.NAMESPACE,
2084
                GEN.NAMESPACE,
2085
                graph,
2086
                SpacesVocab.SPACES_GRAPH,
2087
                lastProcessed,
15✔
2088
                invalidationFilter("np"),
27✔
2089
                NPA.GRAPH,
2090
                invalidationFilter("np2"));
6✔
2091
    }
2092

2093
    /**
2094
     * Maintained-resource admit pass. Copies validated
2095
     * {@code npa:MaintainedResourceDeclaration} extraction rows into the space-state
2096
     * graph (preserving the {@code npamrd:} subject) and emits convenience
2097
     * {@code <r> npa:isMaintainedBy <s>} and {@code <s> npa:hasMaintainedResource <r>}
2098
     * direct triples. Single satisfaction mode:
2099
     * <ul>
2100
     *   <li>Mode A — the declaration's publisher is a validated admin of the
2101
     *       maintaining space.</li>
2102
     * </ul>
2103
     *
2104
     * <p>No Mode B because only one space is involved; the two-sides-must-be-covered
2105
     * concern that drives sub-space Mode B doesn't apply. Late-arrival is still
2106
     * possible (declaration lands before the publisher's admin grant becomes valid):
2107
     * the load-number filter on {@code ?np} excludes the candidate, and the
2108
     * late-arrival sweep ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass
2109
     * without the load filter and catches it.
2110
     */
2111
    static String maintainedResourceAdmitUpdate(IRI graph, long lastProcessed) {
2112
        return """
69✔
2113
                PREFIX npa: <%1$s>
2114
                PREFIX gen: <%2$s>
2115
                INSERT { GRAPH <%3$s> {
2116
                  ?d a npa:MaintainedResourceDeclaration ;
2117
                     npa:resourceIri     ?r ;
2118
                     npa:maintainerSpace ?s ;
2119
                     npa:viaNanopub      ?np .
2120
                  ?r npa:isMaintainedBy        ?sRef .
2121
                  ?sRef npa:hasMaintainedResource ?r .
2122
                  # Uniform ref-valued resource→governing-space-ref edge (issue #130). The
2123
                  # same predicate the reflexive space self-edge uses, so a single consumer
2124
                  # hop covers both "resource maintained by space S" and "resource IS a space".
2125
                  # Backed by the same MaintainedResourceLink below, so the invalidation
2126
                  # cleanup sweeps it alongside isMaintainedBy.
2127
                  ?r npa:hasGoverningSpaceRef  ?sRef .
2128
                  # Reified per-(nanopub, resource→ref) provenance link (issue #125 finding
2129
                  # #5): lets the invalidation cleanup drop the convenience edges below once
2130
                  # no surviving link backs them, instead of leaving them sticky.
2131
                  ?mrLink a npa:MaintainedResourceLink ;
2132
                          npa:viaNanopub         ?np ;
2133
                          npa:resourceIri        ?r ;
2134
                          npa:maintainerSpaceRef ?sRef ;
2135
                          npa:maintainerSpace    ?s .
2136
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2137
                  # maintained-resource edge alongside the resource→ref one, so pre-ref
2138
                  # published queries (e.g. get-view-displays' maintained hop) keep binding
2139
                  # on a mixed-version fleet. This is the edge whose absence broke 1.15.0 —
2140
                  # see doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2141
                  ?r npa:isMaintainedBy        ?s .
2142
                  ?s npa:hasMaintainedResource ?r .
2143
                } }
2144
                WHERE {
2145
                  # 1. Anchor: candidate declarations from the extraction graph.
2146
                  GRAPH <%4$s> {
2147
                    ?d a npa:MaintainedResourceDeclaration ;
2148
                       npa:resourceIri     ?r ;
2149
                       npa:maintainerSpace ?s ;
2150
                       npa:pubkeyHash      ?pkh ;
2151
                       npa:viaNanopub      ?np .
2152
                  }
2153
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
2154
                  GRAPH <%3$s> {
2155
                    ?acct a npa:AccountState ;
2156
                          npa:pubkey ?pkh ;
2157
                          npa:agent  ?publisher .
2158
                    # 3. Authority gate (Mode A only): publisher is admin of a ref of the
2159
                    #    maintaining space. ?sRef = that ref (resource → ref edge).
2160
                    ?riA a gen:RoleInstantiation ;
2161
                         npa:inverseProperty gen:hasAdmin ;
2162
                         npa:forSpace ?s ;
2163
                         npa:forSpaceRef ?sRef ;
2164
                         npa:forAgent ?publisher .
2165
                  }
2166
                  # 4. Invalidation filter on the declaration's nanopub.
2167
                  %6$s
2168
                  # 5. Load-number filter on bound ?np.
2169
                  GRAPH <%7$s> {
2170
                    ?np npa:hasLoadNumber ?ln .
2171
                    FILTER (?ln > %5$d)
2172
                  }
2173
                  # 6. Mint the per-(nanopub, resource→ref) provenance link IRI and dedup on
2174
                  #    it (not on the bare edge), so every backing declaration records its own
2175
                  #    removable link; the convenience edges above are re-asserted idempotently.
2176
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/maintained/",
2177
                                  MD5(CONCAT(STR(?np), "|", STR(?r), "|", STR(?sRef))))) AS ?mrLink)
2178
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2179
                    ?mrLink a npa:MaintainedResourceLink .
2180
                  } }
2181
                }
2182
                """.formatted(
3✔
2183
                NPA.NAMESPACE,
2184
                GEN.NAMESPACE,
2185
                graph,
2186
                SpacesVocab.SPACES_GRAPH,
2187
                lastProcessed,
15✔
2188
                invalidationFilter("np"),
18✔
2189
                NPA.GRAPH);
2190
    }
2191

2192
    /**
2193
     * Space-alias admit pass (issue #113). Copies validated
2194
     * {@code npa:SpaceAliasDeclaration} extraction rows into the space-state graph
2195
     * (preserving the {@code npaalias:} subject) and emits the directional
2196
     * {@code <alias> npa:sameAsSpace <canonical>} edge consumed by the alias-aware
2197
     * admin-authority lookups in {@link #attachmentValidationUpdate},
2198
     * {@link #PUBLISHER_IS_ADMIN}, and {@link #publisherIsTieredRole}.
2199
     *
2200
     * <p>Two gates, both read against the (already-settled) admin closure in the
2201
     * space-state graph:
2202
     * <ul>
2203
     *   <li><b>Authority</b> — the declaration's publisher (resolved via the mirrored
2204
     *       trust-approved {@code AccountState}) is a validated admin of the
2205
     *       <em>canonical</em> space. The alias is declared inside the canonical
2206
     *       space's own {@code gen:Space} nanopub, so this is the same evidence rule
2207
     *       as a {@code gen:hasRole} attachment.</li>
2208
     *   <li><b>Anti-hijack</b> — the alias must not be an independently-governed live
2209
     *       space: it must have no admin who is not also an admin of the canonical
2210
     *       space ({@code admins(alias) ⊆ admins(canonical)}). The common rename case
2211
     *       (the alias's own definition was superseded, so it has no live admin
2212
     *       closure) passes trivially; an attacker publishing
2213
     *       {@code <evil> owl:sameAs <activeSpace>} is rejected because the active
2214
     *       space has admins not in evil's set.</li>
2215
     * </ul>
2216
     *
2217
     * <p>Late-arrival: when the canonical admin grant only becomes valid in the same
2218
     * cycle as the declaration, the load-number filter on {@code ?np} excludes the
2219
     * candidate; the late-arrival sweep ({@link #runDownstreamWithoutLoadFilter})
2220
     * re-runs this pass without the load filter and catches it.
2221
     */
2222
    static String aliasAdmitUpdate(IRI graph, long lastProcessed) {
2223
        // Ref-keyed (see doc/design-spaceref-isolation.md). The declaration names bare
2224
        // canonical/alias IRIs. It is admitted per canonical *ref* whose admin set
2225
        // contains the publisher; the emitted edge is ref-valued on the canonical side
2226
        // (<alias> npa:sameAsSpace <canonicalRef>), which is what the alias-aware admin
2227
        // lookups in the attachment tier consume. Anti-hijack compares the alias IRI's
2228
        // admins against that specific canonical ref's admins — strictly tighter than the
2229
        // old bare-IRI form.
2230
        return """
69✔
2231
                PREFIX npa: <%1$s>
2232
                PREFIX gen: <%2$s>
2233
                INSERT { GRAPH <%3$s> {
2234
                  ?d a npa:SpaceAliasDeclaration ;
2235
                     npa:canonicalSpace ?canonical ;
2236
                     npa:aliasSpace     ?alias ;
2237
                     npa:viaNanopub     ?np .
2238
                  ?alias npa:sameAsSpace ?canonRef .
2239
                  # Reified per-(nanopub, alias→canonical ref) provenance link (issue #125
2240
                  # finding #5). The alias edge feeds the admin-authority closure, so this
2241
                  # is the load-bearing case: the cleanup can now drop the edge when its
2242
                  # declaration is invalidated, rather than letting admin authority outlive
2243
                  # a retraction until the next periodic full rebuild.
2244
                  ?alLink a npa:SpaceAliasLink ;
2245
                          npa:viaNanopub        ?np ;
2246
                          npa:aliasSpace        ?alias ;
2247
                          npa:canonicalSpaceRef ?canonRef ;
2248
                          npa:canonicalSpace    ?canonical .
2249
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2250
                  # alias edge alongside the ref-valued one, so pre-ref published queries
2251
                  # that resolve owl:sameAs by bare canonical IRI keep binding on a
2252
                  # mixed-version fleet. Internal alias-aware lookups (attachment tier)
2253
                  # join through npa:forSpaceRef, which is ref-valued, so this IRI-valued
2254
                  # object never satisfies them — it is inert internally, read-only for
2255
                  # legacy consumers. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2256
                  ?alias npa:sameAsSpace ?canonical .
2257
                } }
2258
                WHERE {
2259
                  # 1. Anchor: candidate alias declarations from the extraction graph.
2260
                  GRAPH <%4$s> {
2261
                    ?d a npa:SpaceAliasDeclaration ;
2262
                       npa:canonicalSpace ?canonical ;
2263
                       npa:aliasSpace     ?alias ;
2264
                       npa:pubkeyHash     ?pkh ;
2265
                       npa:viaNanopub     ?np .
2266
                  }
2267
                  # 2. Authority gate per canonical ref: ?canonRef is a ref of ?canonical
2268
                  #    whose admin set contains the declaration's publisher.
2269
                  GRAPH <%4$s> { ?canonRef npa:spaceIri ?canonical . }
2270
                  GRAPH <%3$s> {
2271
                    ?acct a npa:AccountState ;
2272
                          npa:pubkey ?pkh ;
2273
                          npa:agent  ?publisher .
2274
                    ?adminRI a gen:RoleInstantiation ;
2275
                             npa:inverseProperty gen:hasAdmin ;
2276
                             npa:forSpaceRef ?canonRef ;
2277
                             npa:forAgent ?publisher .
2278
                  }
2279
                  # 3. Anti-hijack: the alias IRI must have no admin who is not also an
2280
                  #    admin of this canonical ref (admins(alias) ⊆ admins(canonRef)).
2281
                  FILTER NOT EXISTS {
2282
                    GRAPH <%3$s> {
2283
                      ?aliasAdmin a gen:RoleInstantiation ;
2284
                                  npa:inverseProperty gen:hasAdmin ;
2285
                                  npa:forSpace ?alias ;
2286
                                  npa:forAgent ?otherAgent .
2287
                    }
2288
                    FILTER NOT EXISTS {
2289
                      GRAPH <%3$s> {
2290
                        ?canonAdmin a gen:RoleInstantiation ;
2291
                                    npa:inverseProperty gen:hasAdmin ;
2292
                                    npa:forSpaceRef ?canonRef ;
2293
                                    npa:forAgent ?otherAgent .
2294
                      }
2295
                    }
2296
                  }
2297
                  # 4. Invalidation filter on the declaration's nanopub.
2298
                  %6$s
2299
                  # 5. Load-number filter on bound ?np.
2300
                  GRAPH <%7$s> {
2301
                    ?np npa:hasLoadNumber ?ln .
2302
                    FILTER (?ln > %5$d)
2303
                  }
2304
                  # 6. Mint the per-(nanopub, alias→canonical ref) provenance link IRI and
2305
                  #    dedup on it (not on the bare edge), so every backing declaration records
2306
                  #    its own removable link; the convenience edges above are re-asserted
2307
                  #    idempotently.
2308
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/alias/",
2309
                                  MD5(CONCAT(STR(?np), "|", STR(?alias), "|", STR(?canonRef))))) AS ?alLink)
2310
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2311
                    ?alLink a npa:SpaceAliasLink .
2312
                  } }
2313
                }
2314
                """.formatted(
3✔
2315
                NPA.NAMESPACE,
2316
                GEN.NAMESPACE,
2317
                graph,
2318
                SpacesVocab.SPACES_GRAPH,
2319
                lastProcessed,
15✔
2320
                invalidationFilter("np"),
18✔
2321
                NPA.GRAPH);
2322
    }
2323

2324
    /**
2325
     * URL-prefix sub-space fallback admit pass. For every pair of {@code SpaceRef}
2326
     * aggregates where the child's {@code npa:hasIdPrefix} matches the parent's
2327
     * {@code npa:spaceIri}, emits convenience {@code <child> npa:isSubSpaceOf <parent>}
2328
     * and {@code <parent> npa:hasSubSpace <child>} direct triples plus a reified
2329
     * {@code npa:DerivedSubSpaceLink} tag carrying {@code npa:derivationKind
2330
     * npa:byUrlPrefix} so consumers can hide derived edges.
2331
     *
2332
     * <p>Per-child suppression: any validated {@code npa:SubSpaceDeclaration} on the
2333
     * child in {@code npass:<…>} suppresses every fallback edge for that child.
2334
     * Suppression checks the validated set (not raw extraction-graph declarations)
2335
     * so an unapproved or in-flight Mode B declaration doesn't silently hide both
2336
     * the URL-prefix fallback and the (still-invalid) explicit relation.
2337
     *
2338
     * <p>Run order: must run after {@link #subSpaceAdmitUpdate} commits in the
2339
     * same cycle so the suppression check sees this cycle's freshly-validated
2340
     * declarations.
2341
     *
2342
     * <p>No load-number filter: the fallback depends on which Spaces exist (parent
2343
     * + child {@code SpaceRef}s), not on which were just added. Always full-scan;
2344
     * the dedup {@code FILTER NOT EXISTS} on the tag IRI prevents re-insertion.
2345
     *
2346
     * <p>No invalidation handling: derived edges have no source nanopub. Two
2347
     * staleness modes: (a) child later gets first validated declaration → old
2348
     * derived edges stay sticky until the next periodic rebuild (same policy as
2349
     * admin-RI invalidation); (b) child loses last validated declaration → the
2350
     * regular fallback pass on the next cycle re-engages, adds derived edges
2351
     * incrementally, no rebuild needed.
2352
     */
2353
    static String subSpacePrefixFallbackUpdate(IRI graph) {
2354
        return """
48✔
2355
                PREFIX npa: <%1$s>
2356
                INSERT { GRAPH <%2$s> {
2357
                  ?childRef  npa:isSubSpaceOf ?parentRef .
2358
                  ?parentRef npa:hasSubSpace  ?childRef  .
2359
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2360
                  # derived sub-space edge alongside the ref-to-ref one, mirroring the
2361
                  # explicit sub-space pass, so pre-ref published queries keep binding on a
2362
                  # mixed-version fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2363
                  ?child  npa:isSubSpaceOf ?parent .
2364
                  ?parent npa:hasSubSpace  ?child  .
2365
                  ?tagIri a npa:DerivedSubSpaceLink ;
2366
                          npa:childSpace     ?child ;
2367
                          npa:parentSpace    ?parent ;
2368
                          # Ref endpoints too (issue #125 finding #5), so the sub-space
2369
                          # orphan-sweep recognizes a prefix-derived ref edge as backed and
2370
                          # never deletes it. Derived links have no source nanopub, so they
2371
                          # are never invalidation-deleted; the fallback self-heals each cycle.
2372
                          npa:childSpaceRef  ?childRef ;
2373
                          npa:parentSpaceRef ?parentRef ;
2374
                          npa:derivationKind npa:byUrlPrefix .
2375
                } }
2376
                WHERE {
2377
                  # 1. Anchor: child SpaceRef → its path-prefixes (extracted at load
2378
                  #    time from the Space IRI; see SpacesExtractor.enumerateIdPrefixes).
2379
                  GRAPH <%3$s> {
2380
                    ?childRef  npa:spaceIri    ?child ;
2381
                               npa:hasIdPrefix ?parent .
2382
                    # 2. Parent SpaceRef must exist for the same IRI as the prefix.
2383
                    ?parentRef npa:spaceIri    ?parent .
2384
                  }
2385
                  # 3. Suppress fallback for any child that has a validated declaration
2386
                  #    in this state graph. Per-child IRI, all-or-nothing.
2387
                  FILTER NOT EXISTS {
2388
                    GRAPH <%2$s> {
2389
                      ?d a npa:SubSpaceDeclaration ;
2390
                         npa:childSpace ?child .
2391
                    }
2392
                  }
2393
                  # 4. Mint a deterministic tag IRI per (child ref, parent ref) — the edge
2394
                  #    is emitted ref-to-ref, so the tag and dedup are per ref-pair.
2395
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/derivedlink/",
2396
                                  MD5(CONCAT(STR(?childRef), "|", STR(?parentRef))))) AS ?tagIri)
2397
                  # 5. Dedup: don't re-insert if this tag is already present.
2398
                  FILTER NOT EXISTS {
2399
                    GRAPH <%2$s> {
2400
                      ?tagIri a npa:DerivedSubSpaceLink .
2401
                    }
2402
                  }
2403
                }
2404
                """.formatted(
3✔
2405
                NPA.NAMESPACE,
2406
                graph,
2407
                SpacesVocab.SPACES_GRAPH);
2408
    }
2409

2410
    /**
2411
     * Reflexive governing-space-ref pass (issue #130). For every {@code SpaceRef}
2412
     * aggregate {@code ?spaceRef} (identified by {@code npa:spaceIri ?space} in the
2413
     * extraction graph), emits {@code <space> npa:hasGoverningSpaceRef <spaceRef>} into
2414
     * the space-state graph — the space pointing at its own ref through the same predicate
2415
     * a maintained resource uses to point at its maintaining space's ref (emitted in
2416
     * {@link #maintainedResourceAdmitUpdate}).
2417
     *
2418
     * <p>This removes the zero-hop special case from consumer authority gates: instead of
2419
     * {@code ?resource npa:isMaintainedBy? ?space} (a bare-IRI optional path that breaks
2420
     * once the hop is ref-valued), a consumer does a single mandatory
2421
     * {@code ?resource npa:hasGoverningSpaceRef ?spaceRef} that binds whether the resource
2422
     * is a maintained resource or a space itself. A space IRI claimed by several refs emits
2423
     * one edge per ref — the non-ref consumer variant's merged-across-refs behaviour falls
2424
     * out naturally; the ref variant pins {@code ?passedRef}.
2425
     *
2426
     * <p>Self-healing, like {@link #subSpacePrefixFallbackUpdate}: the edge has no source
2427
     * nanopub (it follows purely from a {@code SpaceRef} existing), so there is no
2428
     * invalidation handling and no load-number filter — always full-scan, with the dedup
2429
     * {@code FILTER NOT EXISTS} on the edge preventing re-insertion. A {@code SpaceRef}
2430
     * disappearing is itself a structural-rebuild event, which clears its reflexive edge.
2431
     */
2432
    static String governingSpaceRefReflexiveUpdate(IRI graph) {
2433
        return """
48✔
2434
                PREFIX npa: <%1$s>
2435
                INSERT { GRAPH <%2$s> {
2436
                  ?space npa:hasGoverningSpaceRef ?spaceRef .
2437
                } }
2438
                WHERE {
2439
                  GRAPH <%3$s> { ?spaceRef npa:spaceIri ?space . }
2440
                  FILTER NOT EXISTS { GRAPH <%2$s> {
2441
                    ?space npa:hasGoverningSpaceRef ?spaceRef .
2442
                  } }
2443
                }
2444
                """.formatted(
3✔
2445
                NPA.NAMESPACE,
2446
                graph,
2447
                SpacesVocab.SPACES_GRAPH);
2448
    }
2449

2450
    // ---------------- Invalidation templates (incremental cycle) ----------------
2451

2452
    /**
2453
     * WHERE clause shared by the admin-RI invalidation ASK precheck and the
2454
     * matching DELETE. Identifies admin-tier {@code gen:RoleInstantiation} rows
2455
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
2456
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
2457
     * has a load number in {@code (lastProcessed, ∞)}.
2458
     */
2459
    static String adminInvalidationCheckWhere(IRI graph, long lastProcessed) {
2460
        return String.format("""
60✔
2461
                  GRAPH <%1$s> {
2462
                    ?ri a gen:RoleInstantiation ;
2463
                        npa:inverseProperty gen:hasAdmin ;
2464
                        npa:viaNanopub ?np .
2465
                  }
2466
                  GRAPH <%2$s> {
2467
                    ?invNp <%3$s> ?np ;
2468
                           npa:hasLoadNumber ?ln .
2469
                    FILTER (?ln > %4$d)
2470
                    %5$s
2471
                  }
2472
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2473
                samePublisherClause("invNp", "np"));
6✔
2474
    }
2475

2476
    /** DELETE template for admin-tier RoleInstantiations whose source nanopub was invalidated. */
2477
    static String adminInvalidationDelete(IRI graph, long lastProcessed) {
2478
        return String.format("""
63✔
2479
                PREFIX npa: <%1$s>
2480
                PREFIX gen: <%2$s>
2481
                DELETE { GRAPH <%3$s> {
2482
                  ?ri ?p ?o .
2483
                } }
2484
                WHERE {
2485
                  GRAPH <%3$s> { ?ri ?p ?o . }
2486
                %4$s
2487
                }
2488
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2489
                adminInvalidationCheckWhere(graph, lastProcessed));
6✔
2490
    }
2491

2492
    /** WHERE clause for RoleAssignment invalidation. */
2493
    static String roleAssignmentInvalidationCheckWhere(IRI graph, long lastProcessed) {
2494
        return String.format("""
60✔
2495
                  GRAPH <%1$s> {
2496
                    ?ra a gen:RoleAssignment ;
2497
                        npa:viaNanopub ?np .
2498
                  }
2499
                  GRAPH <%2$s> {
2500
                    ?invNp <%3$s> ?np ;
2501
                           npa:hasLoadNumber ?ln .
2502
                    FILTER (?ln > %4$d)
2503
                    %5$s
2504
                  }
2505
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2506
                samePublisherClause("invNp", "np"));
6✔
2507
    }
2508

2509
    /** DELETE template for RoleAssignments whose source nanopub was invalidated. */
2510
    static String roleAssignmentInvalidationDelete(IRI graph, long lastProcessed) {
2511
        return String.format("""
63✔
2512
                PREFIX npa: <%1$s>
2513
                PREFIX gen: <%2$s>
2514
                DELETE { GRAPH <%3$s> {
2515
                  ?ra ?p ?o .
2516
                } }
2517
                WHERE {
2518
                  GRAPH <%3$s> { ?ra ?p ?o . }
2519
                %4$s
2520
                }
2521
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2522
                roleAssignmentInvalidationCheckWhere(graph, lastProcessed));
6✔
2523
    }
2524

2525
    /**
2526
     * DELETE template for non-admin (leaf-tier) RoleInstantiations whose source
2527
     * nanopub was invalidated. Identified as {@code gen:RoleInstantiation} rows
2528
     * lacking the admin-pinning {@code npa:inverseProperty gen:hasAdmin} triple.
2529
     * No flag is set; leaf-tier removals are recoverable on the next cycle.
2530
     */
2531
    static String leafTierInvalidationDelete(IRI graph, long lastProcessed) {
2532
        return String.format("""
84✔
2533
                PREFIX npa: <%1$s>
2534
                PREFIX gen: <%2$s>
2535
                DELETE { GRAPH <%3$s> {
2536
                  ?ri ?p ?o .
2537
                } }
2538
                WHERE {
2539
                  GRAPH <%3$s> {
2540
                    ?ri a gen:RoleInstantiation ;
2541
                        npa:viaNanopub ?np .
2542
                    FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
2543
                    ?ri ?p ?o .
2544
                  }
2545
                  GRAPH <%4$s> {
2546
                    ?invNp <%5$s> ?np ;
2547
                           npa:hasLoadNumber ?ln .
2548
                    FILTER (?ln > %6$d)
2549
                    %7$s
2550
                  }
2551
                }
2552
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2553
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2554
                samePublisherClause("invNp", "np"));
6✔
2555
    }
2556

2557
    /**
2558
     * WHERE clause shared by the sub-space invalidation ASK precheck and the
2559
     * matching DELETE. Identifies validated {@code npa:SubSpaceDeclaration} rows
2560
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
2561
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
2562
     * has a load number in {@code (lastProcessed, ∞)}.
2563
     */
2564
    static String subSpaceInvalidationCheckWhere(IRI graph, long lastProcessed) {
2565
        return String.format("""
60✔
2566
                  GRAPH <%1$s> {
2567
                    ?d a npa:SubSpaceDeclaration ;
2568
                       npa:viaNanopub ?np .
2569
                  }
2570
                  GRAPH <%2$s> {
2571
                    ?invNp <%3$s> ?np ;
2572
                           npa:hasLoadNumber ?ln .
2573
                    FILTER (?ln > %4$d)
2574
                    %5$s
2575
                  }
2576
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2577
                samePublisherClause("invNp", "np"));
6✔
2578
    }
2579

2580
    /**
2581
     * DELETE template for validated {@code npa:SubSpaceDeclaration} rows whose
2582
     * source nanopub was invalidated. Removes the per-declaration row by subject;
2583
     * the convenience direct triples ({@code <child> npa:isSubSpaceOf <parent>}
2584
     * and inverse) are then dropped by {@link #subSpaceConvenienceEdgeCleanup} in the
2585
     * same cycle (issue #125 finding #5) once no surviving link backs them.
2586
     */
2587
    static String subSpaceInvalidationDelete(IRI graph, long lastProcessed) {
2588
        return String.format("""
63✔
2589
                PREFIX npa: <%1$s>
2590
                PREFIX gen: <%2$s>
2591
                DELETE { GRAPH <%3$s> {
2592
                  ?d ?p ?o .
2593
                } }
2594
                WHERE {
2595
                  GRAPH <%3$s> { ?d ?p ?o . }
2596
                %4$s
2597
                }
2598
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2599
                subSpaceInvalidationCheckWhere(graph, lastProcessed));
6✔
2600
    }
2601

2602
    /**
2603
     * DELETE template for validated {@code npa:MaintainedResourceDeclaration} rows
2604
     * whose source nanopub was invalidated. Removes the per-declaration row by
2605
     * subject; the convenience direct triples ({@code <r> npa:isMaintainedBy <s>}
2606
     * and inverse) are then dropped by {@link #maintainedResourceConvenienceEdgeCleanup}
2607
     * in the same cycle (issue #125 finding #5). No structural-rebuild flag —
2608
     * maintained-resource is a leaf relation, no downstream consumers depend on its
2609
     * closure, so the prompt edge cleanup fully resolves its invalidation.
2610
     */
2611
    static String maintainedResourceInvalidationDelete(IRI graph, long lastProcessed) {
2612
        return String.format("""
84✔
2613
                PREFIX npa: <%1$s>
2614
                PREFIX gen: <%2$s>
2615
                DELETE { GRAPH <%3$s> {
2616
                  ?d ?p ?o .
2617
                } }
2618
                WHERE {
2619
                  GRAPH <%3$s> {
2620
                    ?d a npa:MaintainedResourceDeclaration ;
2621
                       npa:viaNanopub ?np .
2622
                    ?d ?p ?o .
2623
                  }
2624
                  GRAPH <%4$s> {
2625
                    ?invNp <%5$s> ?np ;
2626
                           npa:hasLoadNumber ?ln .
2627
                    FILTER (?ln > %6$d)
2628
                    %7$s
2629
                  }
2630
                }
2631
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2632
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2633
                samePublisherClause("invNp", "np"));
6✔
2634
    }
2635

2636
    /**
2637
     * WHERE clause shared by the alias invalidation ASK precheck and the matching
2638
     * DELETE. Identifies validated {@code npa:SpaceAliasDeclaration} rows in the
2639
     * space-state graph whose {@code npa:viaNanopub} is the target of an
2640
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
2641
     * load number in {@code (lastProcessed, ∞)}.
2642
     */
2643
    static String aliasInvalidationCheckWhere(IRI graph, long lastProcessed) {
2644
        return String.format("""
60✔
2645
                  GRAPH <%1$s> {
2646
                    ?d a npa:SpaceAliasDeclaration ;
2647
                       npa:viaNanopub ?np .
2648
                  }
2649
                  GRAPH <%2$s> {
2650
                    ?invNp <%3$s> ?np ;
2651
                           npa:hasLoadNumber ?ln .
2652
                    FILTER (?ln > %4$d)
2653
                    %5$s
2654
                  }
2655
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2656
                samePublisherClause("invNp", "np"));
6✔
2657
    }
2658

2659
    /**
2660
     * DELETE template for validated {@code npa:SpaceAliasDeclaration} rows whose
2661
     * source nanopub was invalidated. Removes the per-declaration row by subject; the
2662
     * convenience {@code <alias> npa:sameAsSpace <canonical>} edge is then dropped by
2663
     * {@link #aliasConvenienceEdgeCleanup} in the same cycle (issue #125 finding #5),
2664
     * so an alias can no longer grant admin authority after its declaration is retracted.
2665
     * The alias feeds the authority closure, so this kind is still structural and flips
2666
     * {@code npa:needsFullRebuild} to bound any rows already derived through the edge.
2667
     */
2668
    static String aliasInvalidationDelete(IRI graph, long lastProcessed) {
2669
        return String.format("""
63✔
2670
                PREFIX npa: <%1$s>
2671
                PREFIX gen: <%2$s>
2672
                DELETE { GRAPH <%3$s> {
2673
                  ?d ?p ?o .
2674
                } }
2675
                WHERE {
2676
                  GRAPH <%3$s> { ?d ?p ?o . }
2677
                %4$s
2678
                }
2679
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2680
                aliasInvalidationCheckWhere(graph, lastProcessed));
6✔
2681
    }
2682

2683
    /**
2684
     * WHERE clause shared by the maintained-resource invalidation ASK precheck and the
2685
     * matching cleanup. Identifies validated {@code npa:MaintainedResourceDeclaration}
2686
     * rows in the space-state graph whose {@code npa:viaNanopub} is the target of an
2687
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
2688
     * load number in {@code (lastProcessed, ∞)}.
2689
     */
2690
    static String maintainedResourceInvalidationCheckWhere(IRI graph, long lastProcessed) {
2691
        return String.format("""
60✔
2692
                  GRAPH <%1$s> {
2693
                    ?d a npa:MaintainedResourceDeclaration ;
2694
                       npa:viaNanopub ?np .
2695
                  }
2696
                  GRAPH <%2$s> {
2697
                    ?invNp <%3$s> ?np ;
2698
                           npa:hasLoadNumber ?ln .
2699
                    FILTER (?ln > %4$d)
2700
                    %5$s
2701
                  }
2702
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2703
                samePublisherClause("invNp", "np"));
6✔
2704
    }
2705

2706
    /**
2707
     * Convenience-edge cleanup for invalidated sub-space declarations (issue #125
2708
     * finding #5). Run after {@link #subSpaceInvalidationDelete} (which removes the
2709
     * {@code npa:SubSpaceDeclaration} rows). Two phases as one multi-operation update:
2710
     * <ol>
2711
     *   <li>delete every {@code npa:SubSpaceLink} provenance link whose
2712
     *       {@code npa:viaNanopub} was invalidated (same {@code npx:invalidates} +
2713
     *       same-publisher gate as the declaration delete);</li>
2714
     *   <li>orphan-sweep: delete the convenience {@code npa:isSubSpaceOf} /
2715
     *       {@code npa:hasSubSpace} edges (both ref- and IRI-valued) that no surviving
2716
     *       link backs — neither a {@code npa:SubSpaceLink} (explicit declaration) nor a
2717
     *       {@code npa:DerivedSubSpaceLink} (URL-prefix fallback).</li>
2718
     * </ol>
2719
     * Edges backed by another surviving declaration or by the URL-prefix fallback are
2720
     * kept. The {@code npa:needsFullRebuild} flag still fires for the structural kind, so
2721
     * downstream rows derived through a removed edge remain rebuild-bounded; this only
2722
     * stops the convenience edges themselves from going sticky.
2723
     */
2724
    static String subSpaceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2725
        return String.format("""
72✔
2726
                PREFIX npa: <%1$s>
2727
                # 1. Drop sub-space provenance links whose source nanopub was invalidated.
2728
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2729
                WHERE {
2730
                  GRAPH <%2$s> {
2731
                    ?l a npa:SubSpaceLink ;
2732
                       npa:viaNanopub ?np .
2733
                    ?l ?p ?o .
2734
                  }
2735
                  GRAPH <%3$s> {
2736
                    ?invNp <%4$s> ?np ;
2737
                           npa:hasLoadNumber ?ln .
2738
                    FILTER (?ln > %5$d)
2739
                    %6$s
2740
                  }
2741
                } ;
2742
                # 2. Orphan-sweep isSubSpaceOf edges (ref- and IRI-valued) with no backing link.
2743
                DELETE { GRAPH <%2$s> { ?c npa:isSubSpaceOf ?p . } }
2744
                WHERE {
2745
                  GRAPH <%2$s> {
2746
                    ?c npa:isSubSpaceOf ?p .
2747
                    FILTER NOT EXISTS {
2748
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2749
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2750
                        UNION
2751
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2752
                    }
2753
                  }
2754
                } ;
2755
                # 3. Orphan-sweep the inverse hasSubSpace edges symmetrically.
2756
                DELETE { GRAPH <%2$s> { ?p npa:hasSubSpace ?c . } }
2757
                WHERE {
2758
                  GRAPH <%2$s> {
2759
                    ?p npa:hasSubSpace ?c .
2760
                    FILTER NOT EXISTS {
2761
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2762
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2763
                        UNION
2764
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2765
                    }
2766
                  }
2767
                }
2768
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2769
                samePublisherClause("invNp", "np"));
6✔
2770
    }
2771

2772
    /**
2773
     * Convenience-edge cleanup for invalidated maintained-resource declarations (issue
2774
     * #125 finding #5). Run after {@link #maintainedResourceInvalidationDelete}. Deletes
2775
     * the {@code npa:MaintainedResourceLink} provenance links whose source nanopub was
2776
     * invalidated, then orphan-sweeps the {@code npa:isMaintainedBy} /
2777
     * {@code npa:hasMaintainedResource} edges (ref- and IRI-valued) that no surviving link
2778
     * backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase structure.
2779
     */
2780
    static String maintainedResourceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2781
        return String.format("""
72✔
2782
                PREFIX npa: <%1$s>
2783
                # 1. Drop maintained-resource provenance links whose source nanopub was invalidated.
2784
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2785
                WHERE {
2786
                  GRAPH <%2$s> {
2787
                    ?l a npa:MaintainedResourceLink ;
2788
                       npa:viaNanopub ?np .
2789
                    ?l ?p ?o .
2790
                  }
2791
                  GRAPH <%3$s> {
2792
                    ?invNp <%4$s> ?np ;
2793
                           npa:hasLoadNumber ?ln .
2794
                    FILTER (?ln > %5$d)
2795
                    %6$s
2796
                  }
2797
                } ;
2798
                # 2. Orphan-sweep isMaintainedBy edges (ref- and IRI-valued) with no backing link.
2799
                DELETE { GRAPH <%2$s> { ?r npa:isMaintainedBy ?o . } }
2800
                WHERE {
2801
                  GRAPH <%2$s> {
2802
                    ?r npa:isMaintainedBy ?o .
2803
                    FILTER NOT EXISTS {
2804
                      ?l a npa:MaintainedResourceLink ;
2805
                         npa:resourceIri ?r .
2806
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2807
                    }
2808
                  }
2809
                } ;
2810
                # 3. Orphan-sweep the inverse hasMaintainedResource edges symmetrically.
2811
                DELETE { GRAPH <%2$s> { ?o npa:hasMaintainedResource ?r . } }
2812
                WHERE {
2813
                  GRAPH <%2$s> {
2814
                    ?o npa:hasMaintainedResource ?r .
2815
                    FILTER NOT EXISTS {
2816
                      ?l a npa:MaintainedResourceLink ;
2817
                         npa:resourceIri ?r .
2818
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2819
                    }
2820
                  }
2821
                } ;
2822
                # 4. Orphan-sweep the maintained arm of hasGoverningSpaceRef (issue #130).
2823
                #    Only the ref-valued maintained edge is removed here — it is backed by a
2824
                #    MaintainedResourceLink. The reflexive space self-edge (subject = a space
2825
                #    IRI that has its own SpaceRef) is NOT a maintained edge and is left to the
2826
                #    self-healing reflexive pass, so the guard keeps any ?r that is itself a space.
2827
                DELETE { GRAPH <%2$s> { ?r npa:hasGoverningSpaceRef ?o . } }
2828
                WHERE {
2829
                  GRAPH <%2$s> {
2830
                    ?r npa:hasGoverningSpaceRef ?o .
2831
                    FILTER NOT EXISTS {
2832
                      ?l a npa:MaintainedResourceLink ;
2833
                         npa:resourceIri ?r ;
2834
                         npa:maintainerSpaceRef ?o .
2835
                    }
2836
                    FILTER NOT EXISTS { GRAPH <%7$s> { ?o npa:spaceIri ?r . } }
2837
                  }
2838
                }
2839
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2840
                samePublisherClause("invNp", "np"), SpacesVocab.SPACES_GRAPH);
18✔
2841
    }
2842

2843
    /**
2844
     * Convenience-edge cleanup for invalidated space-alias declarations (issue #125
2845
     * finding #5 — the load-bearing case, since the alias edge feeds the admin-authority
2846
     * closure). Run after {@link #aliasInvalidationDelete}. Deletes the
2847
     * {@code npa:SpaceAliasLink} provenance links whose source nanopub was invalidated,
2848
     * then orphan-sweeps the {@code npa:sameAsSpace} edges (ref- and IRI-valued) that no
2849
     * surviving link backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase
2850
     * structure.
2851
     */
2852
    static String aliasConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2853
        return String.format("""
72✔
2854
                PREFIX npa: <%1$s>
2855
                # 1. Drop alias provenance links whose source nanopub was invalidated.
2856
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2857
                WHERE {
2858
                  GRAPH <%2$s> {
2859
                    ?l a npa:SpaceAliasLink ;
2860
                       npa:viaNanopub ?np .
2861
                    ?l ?p ?o .
2862
                  }
2863
                  GRAPH <%3$s> {
2864
                    ?invNp <%4$s> ?np ;
2865
                           npa:hasLoadNumber ?ln .
2866
                    FILTER (?ln > %5$d)
2867
                    %6$s
2868
                  }
2869
                } ;
2870
                # 2. Orphan-sweep sameAsSpace edges (ref- and IRI-valued) with no backing link.
2871
                DELETE { GRAPH <%2$s> { ?alias npa:sameAsSpace ?o . } }
2872
                WHERE {
2873
                  GRAPH <%2$s> {
2874
                    ?alias npa:sameAsSpace ?o .
2875
                    FILTER NOT EXISTS {
2876
                      ?l a npa:SpaceAliasLink ;
2877
                         npa:aliasSpace ?alias .
2878
                      { ?l npa:canonicalSpaceRef ?o } UNION { ?l npa:canonicalSpace ?o }
2879
                    }
2880
                  }
2881
                }
2882
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2883
                samePublisherClause("invNp", "np"));
6✔
2884
    }
2885

2886
    /**
2887
     * WHERE clause shared by the preset-deactivation ASK precheck and the matching DELETE
2888
     * (Nanodash issue #302). Binds {@code ?ra} = a materialized preset-derived
2889
     * {@code gen:RoleAssignment} ({@code npa:derivedFromPreset}) for which a <em>newer,
2890
     * admin-authored</em> same-{@code (preset, resource)} assignment exists by
2891
     * {@code dct:created} (load number in {@code (lastProcessed, ∞)}). This is NOT an
2892
     * {@code npx:invalidates} check — preset activation is latest-wins by timestamp.
2893
     *
2894
     * <p>Authorization-scoped (anti-hijack, design doc §3/§4.4): the newer assignment's
2895
     * publisher must itself be a validated admin of the row's {@code npa:forSpaceRef}, so an
2896
     * unauthorized key's newer assignment can neither delete nor shadow an admin's
2897
     * materialized role. {@code dct:created} is written as a full IRI (not a {@code dct:}
2898
     * prefix) because {@link #wouldInvalidate}'s ASK wrapper only declares {@code npa:} /
2899
     * {@code gen:}.
2900
     */
2901
    static String presetDeactivationCheckWhere(IRI graph, long lastProcessed) {
2902
        return String.format("""
60✔
2903
                  GRAPH <%1$s> {
2904
                    ?ra a gen:RoleAssignment ;
2905
                        npa:derivedFromPreset ?assignNp ;
2906
                        npa:forSpaceRef ?targetRef .
2907
                  }
2908
                  GRAPH <%2$s> {
2909
                    ?pa a npa:PresetAssignment ;
2910
                        npa:viaNanopub  ?assignNp ;
2911
                        npa:ofPreset    ?preset ;
2912
                        npa:forResource ?resource ;
2913
                        <http://purl.org/dc/terms/created> ?created .
2914
                    ?paNewer a npa:PresetAssignment ;
2915
                             npa:ofPreset    ?preset ;
2916
                             npa:forResource ?resource ;
2917
                             npa:pubkeyHash  ?pkhNewer ;
2918
                             npa:viaNanopub  ?assignNpNewer ;
2919
                             <http://purl.org/dc/terms/created> ?createdNewer .
2920
                    FILTER (?createdNewer > ?created
2921
                            || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
2922
                  }
2923
                  GRAPH <%3$s> {
2924
                    ?assignNpNewer npa:hasLoadNumber ?lnNewer .
2925
                    FILTER (?lnNewer > %4$d)
2926
                  }
2927
                  GRAPH <%1$s> {
2928
                    ?acctNewer a npa:AccountState ;
2929
                               npa:agent  ?publisherNewer ;
2930
                               npa:pubkey ?pkhNewer .
2931
                    ?adminRINewer a gen:RoleInstantiation ;
2932
                                  npa:forSpaceRef ?targetRef ;
2933
                                  npa:inverseProperty gen:hasAdmin ;
2934
                                  npa:forAgent ?publisherNewer .
2935
                  }
2936
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
2937
    }
2938

2939
    /**
2940
     * DELETE template for preset-derived {@code gen:RoleAssignment} rows superseded by a
2941
     * newer admin-authored same-pair assignment (issue #302). Removes the whole row by
2942
     * subject; scoped via {@code npa:derivedFromPreset} so directly-published attachments
2943
     * are never touched. The {@link #presetAttachmentValidationUpdate} re-INSERT in the
2944
     * same cycle re-materializes the pair iff the newest assignment is still active.
2945
     */
2946
    static String presetDeactivationDelete(IRI graph, long lastProcessed) {
2947
        return String.format("""
63✔
2948
                PREFIX npa: <%1$s>
2949
                PREFIX gen: <%2$s>
2950
                DELETE { GRAPH <%3$s> {
2951
                  ?ra ?p ?o .
2952
                } }
2953
                WHERE {
2954
                  GRAPH <%3$s> { ?ra ?p ?o . }
2955
                %4$s
2956
                }
2957
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2958
                presetDeactivationCheckWhere(graph, lastProcessed));
6✔
2959
    }
2960

2961
    /**
2962
     * WHERE clause matching a materialized <em>non-admin</em> {@code gen:RoleInstantiation}
2963
     * row whose {@code (forSpaceRef, forAgent, gen:hasRole)} key is shadowed by a newer
2964
     * authorized {@code npa:RoleRevocation} (issue #129). The grant timestamp comes from the
2965
     * originating instantiation in the extraction graph (the materialized row carries no
2966
     * {@code dct:created}); the revocation nanopub's load number must be in
2967
     * {@code (lastProcessed, ∞)} so only revocations new in this cycle trigger a delete.
2968
     * Authorization is keyed on the row's bound {@code ?tier} (the matrix: a strictly-higher
2969
     * tier in the ref, or self). Not an {@code npx:invalidates} check.
2970
     */
2971
    static String roleRevocationCheckWhere(IRI graph, long lastProcessed, IRI targetTier) {
2972
        return String.format("""
60✔
2973
                  GRAPH <%1$s> {
2974
                    ?ri2 a gen:RoleInstantiation ;
2975
                         npa:forSpaceRef ?spaceRef ;
2976
                         npa:forAgent    ?agent ;
2977
                         gen:hasRole     ?role ;
2978
                         npa:hasRoleType <%7$s> ;
2979
                         npa:viaNanopub  ?np .
2980
                  }
2981
                  OPTIONAL { GRAPH <%2$s> {
2982
                    ?riSrc npa:viaNanopub ?np ;
2983
                           <http://purl.org/dc/terms/created> ?candCreatedRaw .
2984
                  } }
2985
                  BIND(COALESCE(?candCreatedRaw, %5$s) AS ?candCreated)
2986
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
2987
                  UNION
2988
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
2989
                  GRAPH <%2$s> {
2990
                    ?rev a npa:RoleRevocation ;
2991
                         npa:forSpace    ?revSpace ;
2992
                         npa:forAgent    ?agent ;
2993
                         npa:revokedRole ?role ;
2994
                         npa:pubkeyHash  ?revPkh ;
2995
                         npa:viaNanopub  ?revNp .
2996
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
2997
                  }
2998
                  BIND(COALESCE(?revCreatedRaw, %5$s) AS ?revCreated)
2999
                  GRAPH <%3$s> {
3000
                    ?revNp npa:hasLoadNumber ?lnRev .
3001
                    FILTER (?lnRev > %4$d)
3002
                  }
3003
                  FILTER (?revCreated > ?candCreated
3004
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri2)))
3005
                  { %6$s }
3006
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed,
30✔
3007
                EPOCH_DT, revocationAuthorityArmsForTier(graph, targetTier), targetTier);
18✔
3008
    }
3009

3010
    /**
3011
     * DELETE template removing a non-admin {@code gen:RoleInstantiation} row of {@code
3012
     * targetTier} shadowed by a newer authorized revocation (issue #129). Removes the whole
3013
     * row by subject. Run once per non-admin tier (maintainer/member/observer) so the
3014
     * authorization arms are the compile-time set for that tier — matching the inline
3015
     * suppression filter, no runtime {@code ?tier} (see {@link #revocationAuthorityArmsForTier}).
3016
     * Caller sets {@code needsFullRebuild} (a revoked maintainer/member is a sub-granting
3017
     * authority).
3018
     */
3019
    static String roleRevocationDelete(IRI graph, long lastProcessed, IRI targetTier) {
3020
        return String.format("""
66✔
3021
                PREFIX npa: <%1$s>
3022
                PREFIX gen: <%2$s>
3023
                DELETE { GRAPH <%3$s> {
3024
                  ?ri2 ?p ?o .
3025
                } }
3026
                WHERE {
3027
                  GRAPH <%3$s> { ?ri2 ?p ?o . }
3028
                %4$s
3029
                }
3030
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3031
                roleRevocationCheckWhere(graph, lastProcessed, targetTier));
6✔
3032
    }
3033

3034
    /**
3035
     * WHERE clause matching a materialized <em>admin</em> {@code gen:RoleInstantiation} row
3036
     * whose {@code (forSpaceRef, forAgent)} key is shadowed by a newer authorized admin
3037
     * {@code npa:RoleRevocation} ({@code revokedRole = gen:AdminRole}), authorized by an
3038
     * admin of the ref or by the agent itself. <b>Root admins are exempt</b> (constitutional,
3039
     * issue #129/#110): the nested {@code FILTER NOT EXISTS} on {@code npa:hasRootAdmin}
3040
     * makes the revocation inert. The revocation nanopub's load number must be in
3041
     * {@code (lastProcessed, ∞)}.
3042
     */
3043
    static String adminRevocationCheckWhere(IRI graph, long lastProcessed) {
3044
        return String.format("""
60✔
3045
                  GRAPH <%1$s> {
3046
                    ?sri a gen:RoleInstantiation ;
3047
                         npa:forSpaceRef     ?spaceRef ;
3048
                         npa:inverseProperty gen:hasAdmin ;
3049
                         npa:forAgent        ?agent ;
3050
                         npa:viaNanopub      ?np .
3051
                  }
3052
                  FILTER NOT EXISTS { GRAPH <%2$s> {
3053
                    ?rootDef a npa:SpaceDefinition ;
3054
                             npa:forSpaceRef  ?spaceRef ;
3055
                             npa:hasRootAdmin ?agent .
3056
                  } }
3057
                  OPTIONAL { GRAPH <%2$s> {
3058
                    ?riSrc npa:viaNanopub ?np ;
3059
                           <http://purl.org/dc/terms/created> ?candCreatedRaw .
3060
                  } }
3061
                  BIND(COALESCE(?candCreatedRaw, %5$s) AS ?candCreated)
3062
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
3063
                  UNION
3064
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
3065
                  GRAPH <%2$s> {
3066
                    ?rev a npa:RoleRevocation ;
3067
                         npa:forSpace    ?revSpace ;
3068
                         npa:forAgent    ?agent ;
3069
                         npa:revokedRole gen:AdminRole ;
3070
                         npa:pubkeyHash  ?revPkh ;
3071
                         npa:viaNanopub  ?revNp .
3072
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
3073
                  }
3074
                  BIND(COALESCE(?revCreatedRaw, %5$s) AS ?revCreated)
3075
                  GRAPH <%3$s> {
3076
                    ?revNp npa:hasLoadNumber ?lnRev .
3077
                    FILTER (?lnRev > %4$d)
3078
                  }
3079
                  FILTER (?revCreated > ?candCreated
3080
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?sri)))
3081
                  { %6$s }
3082
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed, EPOCH_DT,
27✔
3083
                "{ " + revokerAdminGraphBlock(graph) + " }\nUNION\n{ "
6✔
3084
                        + revokerSelfGraphBlock(graph) + " }");
9✔
3085
    }
3086

3087
    /**
3088
     * DELETE template removing an admin {@code gen:RoleInstantiation} row shadowed by a newer
3089
     * authorized admin revocation (issue #129). Removes the whole row by subject.
3090
     * <b>Structural</b> — admin RIs feed every downstream tier — so the caller sets
3091
     * {@code npa:needsFullRebuild} (mirrors {@code adminInvalidationDelete}). The
3092
     * {@code adminTierUpdate} inline suppression filter prevents re-materialization.
3093
     */
3094
    static String adminRevocationDelete(IRI graph, long lastProcessed) {
3095
        return String.format("""
63✔
3096
                PREFIX npa: <%1$s>
3097
                PREFIX gen: <%2$s>
3098
                DELETE { GRAPH <%3$s> {
3099
                  ?sri ?p ?o .
3100
                } }
3101
                WHERE {
3102
                  GRAPH <%3$s> { ?sri ?p ?o . }
3103
                %4$s
3104
                }
3105
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3106
                adminRevocationCheckWhere(graph, lastProcessed));
6✔
3107
    }
3108

3109
    /**
3110
     * WHERE clause matching a materialized {@code gen:RoleAssignment} row (direct
3111
     * <em>or</em> preset-derived) whose {@code (forSpaceRef, gen:hasRole)} key is shadowed by
3112
     * a newer admin-authored {@code npa:RoleDetachment} (issue #129). The attachment
3113
     * timestamp comes from whichever extraction row shares the materialized row's
3114
     * {@code npa:viaNanopub} (a {@code RoleAssignment} for direct attachments, a
3115
     * {@code PresetAssignment} for preset-derived ones). The detachment nanopub's load number
3116
     * must be in {@code (lastProcessed, ∞)}; authority = admin of the ref.
3117
     */
3118
    static String roleDetachmentCheckWhere(IRI graph, long lastProcessed) {
3119
        return String.format("""
60✔
3120
                  GRAPH <%1$s> {
3121
                    ?ra2 a gen:RoleAssignment ;
3122
                         npa:forSpaceRef ?targetRef ;
3123
                         gen:hasRole     ?role ;
3124
                         npa:viaNanopub  ?np .
3125
                  }
3126
                  OPTIONAL { GRAPH <%2$s> {
3127
                    ?attSrc npa:viaNanopub ?np ;
3128
                            <http://purl.org/dc/terms/created> ?attCreatedRaw .
3129
                  } }
3130
                  BIND(COALESCE(?attCreatedRaw, %5$s) AS ?attCreated)
3131
                  { GRAPH <%2$s> { ?targetRef npa:spaceIri ?detSpace . } }
3132
                  UNION
3133
                  { GRAPH <%1$s> { ?detSpace npa:sameAsSpace ?targetRef . } }
3134
                  GRAPH <%2$s> {
3135
                    ?det a npa:RoleDetachment ;
3136
                         npa:forSpace    ?detSpace ;
3137
                         npa:revokedRole ?role ;
3138
                         npa:pubkeyHash  ?detPkh ;
3139
                         npa:viaNanopub  ?detNp .
3140
                    OPTIONAL { ?det <http://purl.org/dc/terms/created> ?detCreatedRaw . }
3141
                  }
3142
                  BIND(COALESCE(?detCreatedRaw, %5$s) AS ?detCreated)
3143
                  GRAPH <%3$s> {
3144
                    ?detNp npa:hasLoadNumber ?lnDet .
3145
                    FILTER (?lnDet > %4$d)
3146
                  }
3147
                  FILTER (?detCreated > ?attCreated
3148
                          || (?detCreated = ?attCreated && STR(?det) > STR(?ra2)))
3149
                  GRAPH <%1$s> {
3150
                    ?detAcct a npa:AccountState ; npa:pubkey ?detPkh ; npa:agent ?detAgent .
3151
                    ?detAdminRI a gen:RoleInstantiation ;
3152
                                npa:forSpaceRef ?targetRef ;
3153
                                npa:inverseProperty gen:hasAdmin ;
3154
                                npa:forAgent ?detAgent .
3155
                  }
3156
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed, EPOCH_DT);
18✔
3157
    }
3158

3159
    /**
3160
     * DELETE template removing a {@code gen:RoleAssignment} row (direct or preset-derived)
3161
     * shadowed by a newer admin-authored {@code gen:detachedRole} (issue #129). Removes the
3162
     * whole row by subject. <b>Structural</b> — instantiations anchored on the removed
3163
     * attachment are bounded by the periodic full rebuild (the cascade), so the caller sets
3164
     * {@code npa:needsFullRebuild}. The attachment-tier inline filters prevent
3165
     * re-materialization until a newer attachment / preset assignment out-ranks the detach
3166
     * (non-sticky).
3167
     */
3168
    static String roleDetachmentDelete(IRI graph, long lastProcessed) {
3169
        return String.format("""
63✔
3170
                PREFIX npa: <%1$s>
3171
                PREFIX gen: <%2$s>
3172
                DELETE { GRAPH <%3$s> {
3173
                  ?ra2 ?p ?o .
3174
                } }
3175
                WHERE {
3176
                  GRAPH <%3$s> { ?ra2 ?p ?o . }
3177
                %4$s
3178
                }
3179
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3180
                roleDetachmentCheckWhere(graph, lastProcessed));
6✔
3181
    }
3182

3183
    /**
3184
     * DELETE template for ref-scoped preset-assignment stamps ({@link
3185
     * #presetAssignmentRefStampUpdate}) whose underlying assignment nanopub was
3186
     * hard-retracted (issue #122). Removes the whole row by subject; scoped to
3187
     * state-graph {@code npa:PresetAssignment} rows that carry {@code npa:forSpaceRef}
3188
     * (the IRI-keyed extraction rows never do), so it can never touch them.
3189
     *
3190
     * <p>Leaf delete — no structural flag: nothing downstream derives from a listing
3191
     * stamp, so a stale row only mis-displays a retracted assignment until this cycle's
3192
     * delete runs. Admin-grant revocation is bounded by the periodic full rebuild (same
3193
     * sticky-convenience policy as the alias / sub-space declaration edges). A
3194
     * <em>deactivation</em> needs no delete here: it is represented as a newer
3195
     * admin-authored stamp with {@code npa:isActivated false}, resolved by the consumer's
3196
     * latest-wins.
3197
     */
3198
    static String presetAssignmentRefInvalidationDelete(IRI graph, long lastProcessed) {
3199
        return String.format("""
84✔
3200
                PREFIX npa: <%1$s>
3201
                PREFIX gen: <%2$s>
3202
                DELETE { GRAPH <%3$s> {
3203
                  ?paRef ?p ?o .
3204
                } }
3205
                WHERE {
3206
                  GRAPH <%3$s> {
3207
                    ?paRef a npa:PresetAssignment ;
3208
                           npa:forSpaceRef ?targetRef ;
3209
                           npa:viaNanopub  ?assignNp .
3210
                    ?paRef ?p ?o .
3211
                  }
3212
                  GRAPH <%4$s> {
3213
                    ?invNp <%5$s> ?assignNp ;
3214
                           npa:hasLoadNumber ?ln .
3215
                    FILTER (?ln > %6$d)
3216
                    %7$s
3217
                  }
3218
                }
3219
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3220
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
3221
                samePublisherClause("invNp", "assignNp"));
6✔
3222
    }
3223

3224
    /** Wraps an ASK by joining the shared prefixes. */
3225
    private boolean wouldInvalidate(IRI graph, long lastProcessed,
3226
                                    boolean adminPinned, String whereClause) {
3227
        // adminPinned is informational only — kept to make call sites read clearly;
3228
        // the WHERE clause already encodes the kind via its own type predicates.
3229
        String ask = String.format("""
51✔
3230
                PREFIX npa: <%1$s>
3231
                PREFIX gen: <%2$s>
3232
                ASK { %3$s }
3233
                """, NPA.NAMESPACE, GEN.NAMESPACE, whereClause);
3234
        return runAsk(ask);
12✔
3235
    }
3236

3237
    private boolean runAsk(String sparql) {
3238
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3239
            return conn.prepareBooleanQuery(QueryLanguage.SPARQL, sparql).evaluate();
24✔
3240
        }
3241
    }
3242

3243
    private void executeUpdate(String sparqlUpdate) {
3244
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3245
            conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
15✔
3246
        }
3247
    }
3✔
3248

3249
    // ---------------- Mirror step ----------------
3250

3251
    /**
3252
     * Copies trust-approved {@code npa:AccountState} rows from {@code npat:<T>}
3253
     * in the {@code trust} repo into {@code newGraph} in the {@code spaces} repo,
3254
     * inside one spaces-side serializable transaction.
3255
     *
3256
     * @return number of rows mirrored (useful for metrics / logging)
3257
     */
3258
    /**
3259
     * Whether the given trust state's graph holds anything at all.
3260
     *
3261
     * <p>Used by {@link #runFullBuild} to tell a build that read nothing because the store
3262
     * would not answer from a build that read nothing because there is nothing to read. Only
3263
     * the first is a reason to withhold the result; withholding the second would freeze a
3264
     * stale space state in place, and stale trust data is over-permissive.
3265
     *
3266
     * <p>Throws rather than guessing if the trust repo cannot be read — {@link #runFullBuild}
3267
     * then aborts without publishing or dropping anything, which is the safe direction.
3268
     *
3269
     * @param trustStateHash the trust state hash
3270
     * @return true if the trust state graph contains at least one triple
3271
     */
3272
    boolean trustStateHasContent(String trustStateHash) {
3273
        IRI trustStateIri = NPAT.forHash(trustStateHash);
×
3274
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
3275
            String query = String.format("ASK { GRAPH <%s> { ?s ?p ?o } }", trustStateIri);
×
3276
            return conn.prepareBooleanQuery(QueryLanguage.SPARQL, query).evaluate();
×
3277
        } catch (Exception ex) {
×
3278
            throw new SpaceStateUnavailableException(
×
3279
                    "failed to read trust state graph " + trustStateIri, ex);
3280
        }
3281
    }
3282

3283
    int mirrorTrustState(String trustStateHash, IRI newGraph) {
3284
        IRI trustStateIri = NPAT.forHash(trustStateHash);
9✔
3285
        int count = 0;
6✔
3286
        try (RepositoryConnection trustConn = TripleStore.get().getRepoConnection(TRUST_REPO);
12✔
3287
             RepositoryConnection spacesConn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3288
            trustConn.begin(IsolationLevels.READ_COMMITTED);
9✔
3289
            // Append-only writes into the not-yet-published newGraph (the current-state
3290
            // pointer is swapped to it only after the build completes), and all spaces
3291
            // writers serialise via this class's synchronized methods; see
3292
            // NanopubLoader#repoWriteLocks for why SERIALIZABLE is avoided.
3293
            spacesConn.begin(IsolationLevels.READ_COMMITTED);
9✔
3294
            // Walk rdf:type triples in the trust state's graph; for each AccountState,
3295
            // check status and copy the approved ones verbatim (minus status-specific
3296
            // detail triples, which we don't need for validation).
3297
            try (RepositoryResult<Statement> typeRows = trustConn.getStatements(
36✔
3298
                    null, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri)) {
3299
                while (typeRows.hasNext()) {
9✔
3300
                    Statement st = typeRows.next();
12✔
3301
                    if (!(st.getSubject() instanceof IRI accountStateIri)) continue;
27!
3302
                    Value status = trustConn.getStatements(accountStateIri, NPA_TRUST_STATUS, null, trustStateIri)
33✔
3303
                            .stream().findFirst().map(Statement::getObject).orElse(null);
24✔
3304
                    if (!(status instanceof IRI statusIri) || !APPROVED_SET.contains(statusIri)) continue;
33!
3305
                    Value agent = trustConn.getStatements(accountStateIri, NPA_AGENT, null, trustStateIri)
33✔
3306
                            .stream().findFirst().map(Statement::getObject).orElse(null);
24✔
3307
                    Value pubkey = trustConn.getStatements(accountStateIri, NPA_PUBKEY, null, trustStateIri)
33✔
3308
                            .stream().findFirst().map(Statement::getObject).orElse(null);
24✔
3309
                    if (agent == null || pubkey == null) {
12✔
3310
                        logger.warn("AuthorityResolver.mirror: account {} missing agent or pubkey; skipping",
12✔
3311
                                accountStateIri);
3312
                        continue;
3✔
3313
                    }
3314
                    spacesConn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, newGraph);
33✔
3315
                    spacesConn.add(accountStateIri, NPA_AGENT, agent, newGraph);
33✔
3316
                    spacesConn.add(accountStateIri, NPA_PUBKEY, pubkey, newGraph);
33✔
3317
                    spacesConn.add(accountStateIri, NPA_TRUST_STATUS, statusIri, newGraph);
33✔
3318
                    // Mirror the authorizing introduction provenance when present (issue #125
3319
                    // finding #4). Optional: absent for snapshots from registries that predate
3320
                    // nanopub-registry#117/#118, so consumers (e.g. get-space-members-ref) must
3321
                    // treat npa:viaNanopub on an AccountState as best-effort, not guaranteed.
3322
                    Value viaNanopub = trustConn.getStatements(accountStateIri, NPA_VIA_NANOPUB, null, trustStateIri)
33✔
3323
                            .stream().findFirst().map(Statement::getObject).orElse(null);
24✔
3324
                    if (viaNanopub != null) {
6✔
3325
                        spacesConn.add(accountStateIri, NPA_VIA_NANOPUB, viaNanopub, newGraph);
33✔
3326
                    }
3327
                    count++;
3✔
3328
                }
3✔
3329
            }
3330
            // Mirror canonical foaf:name triples for approved agents. The trust
3331
            // loader emits one per agent (across approved keys, MAX(ratio) wins).
3332
            // Copying them into the space-state graph means consumers reading
3333
            // ?agent foaf:name ?n inside the state graph hit local data, with no
3334
            // cross-repo SERVICE.
3335
            try (RepositoryResult<Statement> nameRows = trustConn.getStatements(
36✔
3336
                    null, FOAF.NAME, null, trustStateIri)) {
3337
                while (nameRows.hasNext()) {
9✔
3338
                    Statement st = nameRows.next();
12✔
3339
                    spacesConn.add(st.getSubject(), st.getPredicate(), st.getObject(), newGraph);
42✔
3340
                }
3✔
3341
            }
3342
            spacesConn.commit();
6✔
3343
            trustConn.commit();
6✔
3344
        }
3345
        return count;
6✔
3346
    }
3347

3348
    // ---------------- Pointer + counter helpers ----------------
3349

3350
    /**
3351
     * Reads the current {@code npa:hasCurrentSpaceState} pointer from the
3352
     * {@code npa:graph} admin graph of the {@code spaces} repo. Returns
3353
     * {@code null} if no pointer exists yet.
3354
     */
3355
    IRI getCurrentSpaceStateGraph() {
3356
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3357
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18✔
3358
                    SpacesVocab.HAS_CURRENT_SPACE_STATE);
3359
            return (v instanceof IRI iri) ? iri : null;
36✔
3360
        } catch (Exception ex) {
3✔
3361
            throw new SpaceStateUnavailableException("failed to read hasCurrentSpaceState pointer", ex);
18✔
3362
        }
3363
    }
3364

3365
    long getCurrentLoadCounter() {
3366
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3367
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18✔
3368
                    SpacesVocab.CURRENT_LOAD_COUNTER);
3369
            if (v == null) return 0;
18✔
3370
            try {
3371
                return Long.parseLong(v.stringValue());
18✔
3372
            } catch (NumberFormatException ex) {
3✔
3373
                // Was "return 0", which would name the new graph <hash>_0 and make it
3374
                // differ from the real current graph — so the build proceeded and then
3375
                // dropped the good one. Corrupt bookkeeping must stop the build.
3376
                throw new SpaceStateUnavailableException("non-numeric currentLoadCounter: " + v, ex);
24✔
3377
            }
3378
        } catch (SpaceStateUnavailableException ex) {
15!
3379
            throw ex;
6✔
3380
        } catch (Exception ex) {
3✔
3381
            throw new SpaceStateUnavailableException("failed to read currentLoadCounter", ex);
18✔
3382
        }
3383
    }
3384

3385
    /**
3386
     * Atomic pointer flip: a single SPARQL {@code DELETE … INSERT … WHERE}
3387
     * replaces the old pointer with the new one in one statement, so readers
3388
     * never see a zero-pointer window.
3389
     */
3390
    void flipPointer(IRI newGraph) {
3391
        String update = String.format("""
135✔
3392
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3393
                INSERT { GRAPH <%s> { <%s> <%s> <%s> } }
3394
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3395
                """,
3396
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE,
3397
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE, newGraph,
3398
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE);
3399
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3400
            conn.begin(IsolationLevels.SNAPSHOT);
9✔
3401
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
15✔
3402
            conn.commit();
6✔
3403
        }
3404
    }
3✔
3405

3406
    void writeProcessedUpTo(IRI graph, long loadCounter) {
3407
        String update = String.format("""
96✔
3408
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3409
                INSERT { GRAPH <%s> { <%s> <%s> "%d"^^<http://www.w3.org/2001/XMLSchema#long> } }
3410
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3411
                """,
3412
                graph, graph, SpacesVocab.PROCESSED_UP_TO,
3413
                graph, graph, SpacesVocab.PROCESSED_UP_TO, loadCounter,
42✔
3414
                graph, graph, SpacesVocab.PROCESSED_UP_TO);
3415
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3416
            conn.begin(IsolationLevels.SNAPSHOT);
9✔
3417
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
15✔
3418
            conn.commit();
6✔
3419
        }
3420
    }
3✔
3421

3422
    /**
3423
     * Rewrites the {@link SpacesVocab#STATE_TRIPLE_COUNT} integrity stamp so it
3424
     * equals the graph's actual triple count (stamp triple included). Runs as a
3425
     * single transaction — delete old stamp, count, insert new stamp — so the
3426
     * stamp is either consistent with the content it was measured against or
3427
     * absent, never half-updated. Called after every mutation of a space-state
3428
     * graph; {@link #tick()} verifies it and rebuilds on mismatch.
3429
     */
3430
    void writeStateTripleCount(IRI graph) {
3431
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3432
            conn.begin(IsolationLevels.SNAPSHOT);
9✔
3433
            conn.prepareUpdate(QueryLanguage.SPARQL, String.format(
57✔
3434
                    "DELETE WHERE { GRAPH <%s> { <%s> <%s> ?old } }",
3435
                    graph, graph, SpacesVocab.STATE_TRIPLE_COUNT)).execute();
3✔
3436
            long withoutStamp;
3437
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, String.format(
33✔
3438
                    "SELECT (COUNT(*) AS ?n) WHERE { GRAPH <%s> { ?s ?p ?o } }", graph)).evaluate()) {
6✔
3439
                withoutStamp = Long.parseLong(r.next().getBinding("n").getValue().stringValue());
27✔
3440
            }
3441
            // +1 for the stamp triple itself, so a plain count of the graph matches the stamp.
3442
            conn.prepareUpdate(QueryLanguage.SPARQL, String.format(
72✔
3443
                    "INSERT DATA { GRAPH <%s> { <%s> <%s> \"%d\"^^<http://www.w3.org/2001/XMLSchema#long> } }",
3444
                    graph, graph, SpacesVocab.STATE_TRIPLE_COUNT, withoutStamp + 1)).execute();
9✔
3445
            conn.commit();
6✔
3446
        }
3447
    }
3✔
3448

3449
    /**
3450
     * Reads the {@link SpacesVocab#STATE_TRIPLE_COUNT} stamp from the given
3451
     * space-state graph. Returns {@code -1} if absent (graph published by a
3452
     * pre-stamp version; it becomes verifiable at its next mutation). Throws on
3453
     * read failure — the same absent-vs-error distinction as
3454
     * {@link #readProcessedUpTo}: a timed-out read must not look like damage.
3455
     */
3456
    long readStateTripleCount(IRI graph) {
3457
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3458
            String query = String.format(
51✔
3459
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
3460
                    graph, graph, SpacesVocab.STATE_TRIPLE_COUNT);
3461
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18✔
3462
                if (!r.hasNext()) return -1;
21✔
3463
                return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
33✔
3464
            }
12!
3465
        } catch (Exception ex) {
12!
3466
            throw new SpaceStateUnavailableException("failed to read stateTripleCount for " + graph, ex);
×
3467
        }
3468
    }
3469

3470
    /**
3471
     * Counts the triples in the given space-state graph. Throws on read failure
3472
     * rather than returning a sentinel, for the same reason as
3473
     * {@link #readStateTripleCount}.
3474
     */
3475
    long countStateGraphTriples(IRI graph) {
3476
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3477
            String query = String.format(
27✔
3478
                    "SELECT (COUNT(*) AS ?n) WHERE { GRAPH <%s> { ?s ?p ?o } }", graph);
3479
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18✔
3480
                return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
33✔
3481
            }
3482
        } catch (Exception ex) {
×
3483
            throw new SpaceStateUnavailableException("failed to count triples of " + graph, ex);
×
3484
        }
3485
    }
3486

3487
    /**
3488
     * Reads {@code processedUpTo} from the given space-state graph.
3489
     * Returns {@code -1} if absent (graph not fully built yet).
3490
     */
3491
    long readProcessedUpTo(IRI graph) {
3492
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3493
            String query = String.format(
51✔
3494
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
3495
                    graph, graph, SpacesVocab.PROCESSED_UP_TO);
3496
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18✔
3497
                if (!r.hasNext()) return -1;
21✔
3498
                BindingSet b = r.next();
12✔
3499
                return Long.parseLong(b.getBinding("n").getValue().stringValue());
27✔
3500
            }
12!
3501
        } catch (Exception ex) {
15!
3502
            // Must not collapse to -1: callers read -1 as "this graph was never
3503
            // finished" and rebuild from scratch. A timed-out read returning -1 would
3504
            // make a healthy state look damaged and trigger a destructive rebuild.
3505
            throw new SpaceStateUnavailableException("failed to read processedUpTo for " + graph, ex);
24✔
3506
        }
3507
    }
3508

3509
    /**
3510
     * Reads the {@code npa:needsFullRebuild} flag (boolean literal) from
3511
     * {@code npa:graph} in the {@code spaces} repo. Defaults to {@code false}
3512
     * when the triple is absent.
3513
     */
3514
    boolean readNeedsFullRebuild() {
3515
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3516
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18✔
3517
                    SpacesVocab.NEEDS_FULL_REBUILD);
3518
            return v != null && Boolean.parseBoolean(v.stringValue());
36✔
3519
        } catch (Exception ex) {
3✔
3520
            logger.warn("AuthorityResolver: failed to read needsFullRebuild: {}", ex.toString());
15✔
3521
            return false;
6✔
3522
        }
3523
    }
3524

3525
    void setNeedsFullRebuild() {
3526
        writeNeedsFullRebuild(true);
9✔
3527
    }
3✔
3528

3529
    void clearNeedsFullRebuild() {
3530
        writeNeedsFullRebuild(false);
9✔
3531
    }
3✔
3532

3533
    private void writeNeedsFullRebuild(boolean value) {
3534
        String update = String.format("""
96✔
3535
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3536
                INSERT { GRAPH <%s> { <%s> <%s> "%s"^^<http://www.w3.org/2001/XMLSchema#boolean> } }
3537
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3538
                """,
3539
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD,
3540
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD, value,
42✔
3541
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD);
3542
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3543
            conn.begin(IsolationLevels.SNAPSHOT);
9✔
3544
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
15✔
3545
            conn.commit();
6✔
3546
        }
3547
    }
3✔
3548

3549
    void dropGraph(IRI graph) {
3550
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3551
            conn.begin(IsolationLevels.SNAPSHOT);
9✔
3552
            conn.clear(graph);
24✔
3553
            conn.commit();
6✔
3554
            logger.info("AuthorityResolver: dropped old space-state graph {}", graph);
12✔
3555
        }
3556
    }
3✔
3557

3558
    // ---------------- Trust-repo pointer lookup (used by TrustStateRegistry's bootstrap) ----------------
3559

3560
    /**
3561
     * Queries the {@code trust} repo directly for the current trust-state hash.
3562
     * Prefer {@link TrustStateRegistry#getCurrentHash()} in normal operation —
3563
     * this helper exists for tests and diagnostics.
3564
     *
3565
     * @return the current trust-state hash, or empty if none is set
3566
     */
3567
    Optional<String> readTrustRepoCurrentHash() {
3568
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
12✔
3569
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18✔
3570
                    NPA_HAS_CURRENT_TRUST_STATE);
3571
            if (!(v instanceof IRI iri)) return Optional.empty();
33✔
3572
            String s = iri.stringValue();
9✔
3573
            if (!s.startsWith(NPAT.NAMESPACE)) return Optional.empty();
24✔
3574
            return Optional.of(s.substring(NPAT.NAMESPACE.length()));
24✔
3575
        } catch (Exception ex) {
27!
3576
            logger.warn("AuthorityResolver: failed to read trust-repo current pointer: {}", ex.toString());
15✔
3577
            return Optional.empty();
6✔
3578
        }
3579
    }
3580

3581
    private static String abbrev(String hash) {
3582
        return hash.length() > 12 ? hash.substring(0, 12) + "…" : hash;
33!
3583
    }
3584

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

© 2026 Coveralls, Inc