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

knowledgepixels / nanopub-query / 30982095510

05 Aug 2026 06:39AM UTC coverage: 61.617% (+1.7%) from 59.947%
30982095510

Pull #159

github

web-flow
Merge d6fbc66f2 into b478d344d
Pull Request #159: fix(spaces): never publish a space-state build made from failed reads

658 of 1218 branches covered (54.02%)

Branch coverage included in aggregate %.

1941 of 3000 relevant lines covered (64.7%)

9.79 hits per line

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

34.94
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;
6!
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
6!
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");
×
170
            runFullBuild(trustStateHash);
×
171
            return;
×
172
        }
173
        if (!currentGraphName.startsWith(trustStateHash + "_")) {
15!
174
            logger.info("AuthorityResolver.tick: trust-state flip detected (now {}); running full build",
×
175
                    abbrev(trustStateHash));
×
176
            runFullBuild(trustStateHash);
×
177
            return;
×
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
        runIncrementalCycle(currentGraph);
9✔
190
    }
3✔
191

192
    /**
193
     * Periodic worker. If {@code npa:needsFullRebuild} was raised by an
194
     * incremental cycle's structural DELETE, runs a from-scratch rebuild into
195
     * a fresh space-state graph (using the current trust-state hash and load
196
     * counter) and clears the flag. No-op when the flag is not set. Safe to
197
     * call concurrently with {@link #tick()} when both are scheduled on the
198
     * same single-threaded executor.
199
     */
200
    public void periodicRebuildTick() {
201
        if (!FeatureFlags.spacesEnabled()) return;
×
202
        if (!readNeedsFullRebuild()) return;
×
203
        String trustStateHash = TrustStateRegistry.get().getCurrentHash().orElse(null);
×
204
        if (trustStateHash == null) {
×
205
            logger.debug("AuthorityResolver.periodicRebuildTick: no current trust state — deferring");
×
206
            return;
×
207
        }
208
        logger.info("AuthorityResolver.periodicRebuildTick: needsFullRebuild flag set; rebuilding");
×
209
        runFullBuild(trustStateHash);
×
210
        clearNeedsFullRebuild();
×
211
    }
×
212

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

259
    // ---------------- Full build ----------------
260

261
    /**
262
     * Mutex-protected full build of the space-state graph for the given trust
263
     * state. Captures {@code M = currentLoadCounter}, mirrors trust-approved
264
     * rows, (PR 2b: runs per-tier UPDATE loops from scratch), stamps
265
     * {@code processedUpTo = M}, flips the pointer, drops the previous graph.
266
     */
267
    synchronized void runFullBuild(String trustStateHash) {
268
        long startNanos = System.nanoTime();
6✔
269
        long loadCounter = getCurrentLoadCounter();
9✔
270
        IRI newGraph = SpacesVocab.forSpaceState(trustStateHash, loadCounter);
12✔
271
        IRI oldGraph = getCurrentSpaceStateGraph();
9✔
272
        boolean rebuildInPlace = newGraph.equals(oldGraph);
12✔
273
        if (rebuildInPlace) {
6✔
274
            // "Already current" is only true if that graph was actually finished.
275
            // Without the processedUpTo check this early return was the second half
276
            // of the 2026-08-05 trap: once a damaged graph was published, the pointer
277
            // name still matched, so every subsequent full build returned here and
278
            // the instance could never repair itself.
279
            if (readProcessedUpTo(oldGraph) >= 0) {
18✔
280
                logger.debug("AuthorityResolver.runFullBuild: already current at {}", newGraph);
12✔
281
                return;
3✔
282
            }
283
            logger.warn("AuthorityResolver.runFullBuild: {} is the current graph but has no "
12✔
284
                    + "processedUpTo stamp; rebuilding it in place", newGraph);
285
            dropGraph(newGraph);
9✔
286
        }
287

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

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

295
        // 2b. Refuse to publish an empty build over a state we already have.
296
        //
297
        // On 2026-08-05 every source read inside this method timed out, so mirrored
298
        // and all twelve tier counts came back 0. The build then flipped the pointer
299
        // to that empty graph and dropped the previous one, which held 2730 triples
300
        // of live space state. Steps 4 and 5 are destructive and must not run on a
301
        // result that carries no data.
302
        //
303
        // Only guarded when a previous state exists: a genuinely empty first build on
304
        // a fresh instance has nothing to lose and must still be allowed to publish.
305
        long insertedTotal = totalInserted(counts);
9✔
306
        if (mirrored == 0 && insertedTotal == 0 && oldGraph != null) {
24!
307
            logger.error("AuthorityResolver.runFullBuild: build produced an empty state graph "
12✔
308
                    + "(mirrored=0, inserted=0) while {} holds the current state — refusing to "
309
                    + "flip the pointer or drop it. Almost always means the source reads failed; "
310
                    + "the next tick will retry.", oldGraph);
311
            if (!rebuildInPlace) {
6!
312
                dropGraph(newGraph);
9✔
313
            }
314
            return;
3✔
315
        }
316

317
        // 3. Stamp processedUpTo inside the new graph.
318
        writeProcessedUpTo(newGraph, loadCounter);
12✔
319

320
        // 4. Flip the current-space-state pointer.
321
        flipPointer(newGraph);
9✔
322

323
        // 5. Drop the old graph if a *different* one existed. Dropping it when
324
        //    rebuilding in place would delete what we just built.
325
        if (oldGraph != null && !rebuildInPlace) {
12✔
326
            dropGraph(oldGraph);
9✔
327
        }
328

329
        TierSubjectTotals totals = computeTierSubjectTotals(newGraph);
12✔
330
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
18✔
331
        lastSubjectTotals = totals;
9✔
332
        lastInsertedTriplesTotal = insertedTotal;
9✔
333
        lastFullBuildDurationMs = durationMs;
9✔
334
        lastProcessedUpToLag = 0L;
9✔
335
        logger.info("AuthorityResolver: full build complete — graph={} mirrored={} rows loadCounter={} "
36✔
336
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
337
                        + "(inserted-triples: admin={} alias={} preset-attachment={} preset-assignment-ref={} attachment={} maintainer={} member={} observer={} "
338
                        + "subspace={} subspace-prefix={} maintained-resource={} governing-space-ref={}) durationMs={}",
339
                newGraph, mirrored, loadCounter,
30✔
340
                totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(),
57✔
341
                counts.admin, counts.alias, counts.presetAttachment, counts.presetAssignmentRef, counts.attachment, counts.maintainer, counts.member, counts.observer,
144✔
342
                counts.subSpace, counts.subSpacePrefix, counts.maintainedResource, counts.governingSpaceRef,
69✔
343
                durationMs);
6✔
344
    }
3✔
345

346
    // ---------------- Incremental cycle ----------------
347

348
    /**
349
     * Single delta cycle on the current space-state graph. Bounded by
350
     * {@code (processedUpTo, currentLoadCounter]}; no-op if the range is empty.
351
     *
352
     * <p>Order:
353
     * <ol>
354
     *   <li>Apply invalidation DELETEs (admin RI, RoleAssignment, non-admin RI)
355
     *       and the RoleDeclaration ASK. Any DELETE on a structural kind sets
356
     *       {@code npa:needsFullRebuild} to bound the staleness from sticky
357
     *       downstream entries; the periodic worker turns that into a from-scratch
358
     *       rebuild on its next pass.</li>
359
     *   <li>Run per-tier INSERTs in the same order as the full build.</li>
360
     *   <li>Late-arrival sweep: if any structural row was added, re-run downstream
361
     *       tier INSERTs with {@code lastProcessed = -1} to catch candidates whose
362
     *       enabling event landed in this same cycle. Dedup filters protect
363
     *       against double-insert.</li>
364
     *   <li>Bump {@code processedUpTo} to {@code currentLoadCounter}.</li>
365
     * </ol>
366
     */
367
    synchronized void runIncrementalCycle(IRI graph) {
368
        long startNanos = System.nanoTime();
×
369
        long currentLoadCounter = getCurrentLoadCounter();
×
370
        long lastProcessed = readProcessedUpTo(graph);
×
371
        if (lastProcessed < 0) {
×
372
            // tick() now catches this first and rebuilds, so reaching here means a
373
            // direct caller. Still refuse to run a delta against a graph that was
374
            // never finished — the deltas would be layered onto missing base rows.
375
            logger.warn("AuthorityResolver.runIncrementalCycle: missing processedUpTo on {}; "
×
376
                    + "skipping (a full build is needed to repair this graph)", graph);
377
            return;
×
378
        }
379
        lastProcessedUpToLag = currentLoadCounter - lastProcessed;
×
380
        if (currentLoadCounter <= lastProcessed) {
×
381
            logger.debug("AuthorityResolver.runIncrementalCycle: caught up at load {} on {}",
×
382
                    currentLoadCounter, graph);
×
383
            return;
×
384
        }
385

386
        boolean structuralInvalidation = applyInvalidations(graph, lastProcessed);
×
387
        TierInsertedTriples counts = runAllTierLoops(graph, lastProcessed);
×
388
        boolean structuralAdds = (counts.admin > 0)
×
389
                || (counts.alias > 0)
390
                || (counts.presetAttachment > 0)
391
                || (counts.attachment > 0)
392
                || (counts.subSpace > 0)
393
                || newRoleDeclarationsArrived(lastProcessed)
×
394
                || newPresetAssignmentsArrived(lastProcessed);
×
395
        if (structuralAdds) {
×
396
            // Late-arrival sweep: leaf tiers (attachment/maintainer/member/observer)
397
            // can promote candidates whose enabling event arrived in this same cycle.
398
            // Sub-space admit is also re-run here for Mode-B late-arrival (a new
399
            // partner declaration can validate an older primary that the regular
400
            // pass's load-number filter excluded). The URL-prefix fallback also
401
            // re-runs so newly-orphaned children pick up derived edges. Skip the
402
            // admin tier — its only enabling event is the admin grant itself,
403
            // already handled by the regular pass.
404
            TierInsertedTriples lateCounts = runDownstreamWithoutLoadFilter(graph);
×
405
            counts.alias              += lateCounts.alias;
×
406
            counts.presetAttachment   += lateCounts.presetAttachment;
×
407
            counts.presetAssignmentRef += lateCounts.presetAssignmentRef;
×
408
            counts.attachment         += lateCounts.attachment;
×
409
            counts.maintainer         += lateCounts.maintainer;
×
410
            counts.member             += lateCounts.member;
×
411
            counts.observer           += lateCounts.observer;
×
412
            counts.subSpace           += lateCounts.subSpace;
×
413
            counts.subSpacePrefix     += lateCounts.subSpacePrefix;
×
414
            counts.governingSpaceRef  += lateCounts.governingSpaceRef;
×
415
            counts.maintainedResource += lateCounts.maintainedResource;
×
416
        }
417

418
        writeProcessedUpTo(graph, currentLoadCounter);
×
419

420
        TierSubjectTotals totals = computeTierSubjectTotals(graph);
×
421
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
×
422
        lastSubjectTotals = totals;
×
423
        lastInsertedTriplesTotal = (long) counts.admin + counts.alias + counts.presetAttachment
×
424
                + counts.presetAssignmentRef
425
                + counts.attachment + counts.maintainer + counts.member + counts.observer
426
                + counts.subSpace + counts.subSpacePrefix + counts.maintainedResource
427
                + counts.governingSpaceRef;
428
        lastIncrementalCycleDurationMs = durationMs;
×
429
        logger.info("AuthorityResolver: incremental cycle complete — graph={} delta=({}, {}] "
×
430
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
431
                        + "(inserted-triples: admin={} alias={} preset-attachment={} preset-assignment-ref={} attachment={} maintainer={} member={} observer={} "
432
                        + "subspace={} subspace-prefix={} maintained-resource={} governing-space-ref={}) "
433
                        + "structuralInvalidation={} structuralAdds={} durationMs={}",
434
                graph, lastProcessed, currentLoadCounter,
×
435
                totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(),
×
436
                counts.admin, counts.alias, counts.presetAttachment, counts.presetAssignmentRef, counts.attachment, counts.maintainer, counts.member, counts.observer,
×
437
                counts.subSpace, counts.subSpacePrefix, counts.maintainedResource, counts.governingSpaceRef,
×
438
                structuralInvalidation, structuralAdds, durationMs);
×
439
    }
×
440

441
    /**
442
     * Runs the four invalidation-DELETE / ASK steps. Sets {@code npa:needsFullRebuild}
443
     * when admin-RI, RoleAssignment, or RoleDeclaration invalidations matched (the
444
     * three structural kinds). Leaf-tier RI deletes don't set the flag.
445
     *
446
     * @return true iff at least one structural kind was invalidated
447
     */
448
    boolean applyInvalidations(IRI graph, long lastProcessed) {
449
        boolean structural = false;
×
450
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ true,
×
451
                            adminInvalidationCheckWhere(graph, lastProcessed))) {
×
452
            executeUpdate(adminInvalidationDelete(graph, lastProcessed));
×
453
            structural = true;
×
454
        }
455
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
456
                            roleAssignmentInvalidationCheckWhere(graph, lastProcessed))) {
×
457
            executeUpdate(roleAssignmentInvalidationDelete(graph, lastProcessed));
×
458
            structural = true;
×
459
        }
460
        // Role-declaration invalidation is deliberately NOT acted on (see
461
        // nonAdminTierUpdate): a role assignment is governed by the admin-validated
462
        // attachment, not by the declaration author's later supersession/retraction, so
463
        // an invalidated RD neither deletes rows nor triggers a rebuild.
464
        // Sub-space declarations are structural — invalidating one (Mode A) or one
465
        // of two co-declarations (Mode B) changes the validated parent/child
466
        // topology. The DELETE removes the per-declaration row; the convenience-edge
467
        // cleanup then drops the now-unbacked direct triples (issue #125 finding #5)
468
        // instead of leaving them sticky until the periodic rebuild. The structural
469
        // flag still fires so downstream rows derived through a removed edge stay
470
        // rebuild-bounded.
471
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
472
                            subSpaceInvalidationCheckWhere(graph, lastProcessed))) {
×
473
            executeUpdate(subSpaceInvalidationDelete(graph, lastProcessed));
×
474
            executeUpdate(subSpaceConvenienceEdgeCleanup(graph, lastProcessed));
×
475
            structural = true;
×
476
        }
477
        // Space-alias declarations are structural — invalidating one removes an
478
        // owl:sameAs edge that feeds the admin-authority closure (issue #113). The
479
        // DELETE removes the per-declaration row; the convenience-edge cleanup then
480
        // drops the now-unbacked npa:sameAsSpace edge (issue #125 finding #5 — the
481
        // load-bearing case), so admin authority can no longer outlive a retraction
482
        // until the next periodic rebuild.
483
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
484
                            aliasInvalidationCheckWhere(graph, lastProcessed))) {
×
485
            executeUpdate(aliasInvalidationDelete(graph, lastProcessed));
×
486
            executeUpdate(aliasConvenienceEdgeCleanup(graph, lastProcessed));
×
487
            structural = true;
×
488
        }
489
        // Preset-derived RoleAssignment removal (issue #302). NOT npx:invalidates: a newer
490
        // admin-authored same-(preset,resource) assignment supersedes by dct:created (a
491
        // gen:DeactivatedPresetAssignment, or any newer assignment that is no longer active).
492
        // Structural — sticky downstream non-admin RIs derived through a removed attachment
493
        // are bounded by the periodic full rebuild. The DELETE is scoped by
494
        // npa:derivedFromPreset so directly-published gen:hasRole attachments are never
495
        // touched; the §4.3 re-INSERT re-materializes only currently-active pairs in the same
496
        // cycle. See doc/design-preset-role-materialization.md §4.4.
497
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
498
                            presetDeactivationCheckWhere(graph, lastProcessed))) {
×
499
            executeUpdate(presetDeactivationDelete(graph, lastProcessed));
×
500
            structural = true;
×
501
        }
502
        // Admin role-instantiation revocation (issue #129). STRUCTURAL — admin RIs feed every
503
        // downstream tier, so a removed admin must bound the staleness via a full rebuild
504
        // (mirrors adminInvalidationDelete). Root admins are exempt inside the check-where.
505
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ true,
×
506
                            adminRevocationCheckWhere(graph, lastProcessed))) {
×
507
            executeUpdate(adminRevocationDelete(graph, lastProcessed));
×
508
            structural = true;
×
509
        }
510
        // Role detachment (issue #129). STRUCTURAL — removing a (ref, role) attachment
511
        // (direct or preset-derived) cascades to the instantiations anchored on it, bounded
512
        // by the periodic full rebuild. The attachment-tier inline filters then keep the
513
        // detached role suppressed until a newer attachment / preset assignment out-ranks it.
514
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
515
                            roleDetachmentCheckWhere(graph, lastProcessed))) {
×
516
            executeUpdate(roleDetachmentDelete(graph, lastProcessed));
×
517
            structural = true;
×
518
        }
519
        // Non-admin role-instantiation revocation (issue #129), run once per tier so the
520
        // authorization arms are the compile-time set for that tier (mirrors the inline
521
        // suppression filter). STRUCTURAL: a revoked maintainer or member is a sub-granting
522
        // authority — members/observers they granted are validated via the maint-pub /
523
        // member-pub arms of nonAdminTierUpdate, so removing the revoked agent's own RI must
524
        // schedule a full rebuild to re-evaluate (and drop) those now-unauthorized downstream
525
        // grants. The inline suppression filter prevents re-materialization on that rebuild.
526
        for (IRI revTier : List.of(GEN.MAINTAINER_ROLE, GEN.MEMBER_ROLE, GEN.OBSERVER_ROLE)) {
×
527
            if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
528
                                roleRevocationCheckWhere(graph, lastProcessed, revTier))) {
×
529
                executeUpdate(roleRevocationDelete(graph, lastProcessed, revTier));
×
530
                structural = true;
×
531
            }
532
        }
×
533
        // Leaf-tier RI deletes — no flag.
534
        executeUpdate(leafTierInvalidationDelete(graph, lastProcessed));
×
535
        // Ref-scoped preset-assignment listing stamps whose assignment nanopub was
536
        // hard-retracted (issue #122) — no flag (display leaf, nothing downstream).
537
        executeUpdate(presetAssignmentRefInvalidationDelete(graph, lastProcessed));
×
538
        // Maintained-resource declaration deletes — no flag (leaf relation, no
539
        // downstream caches to bound). The per-declaration delete removes the row; the
540
        // convenience-edge cleanup drops the now-unbacked isMaintainedBy edges (issue
541
        // #125 finding #5). Guarded so the orphan-sweep only scans when something was
542
        // actually invalidated (the delete itself was already a no-op otherwise).
543
        if (wouldInvalidate(graph, lastProcessed, /*adminPinned=*/ false,
×
544
                            maintainedResourceInvalidationCheckWhere(graph, lastProcessed))) {
×
545
            executeUpdate(maintainedResourceInvalidationDelete(graph, lastProcessed));
×
546
            executeUpdate(maintainedResourceConvenienceEdgeCleanup(graph, lastProcessed));
×
547
        }
548
        if (structural) setNeedsFullRebuild();
×
549
        return structural;
×
550
    }
551

552
    /**
553
     * Runs the four leaf tiers (attachment/maintainer/member/observer) with
554
     * {@code lastProcessed = -1} so the load-number filter on the candidate
555
     * side admits everything. Dedup filters in the tier templates prevent
556
     * double-insert. Used by the late-arrival sweep.
557
     */
558
    TierInsertedTriples runDownstreamWithoutLoadFilter(IRI graph) {
559
        TierInsertedTriples c = new TierInsertedTriples();
×
560
        // Alias late-arrival: catches alias declarations whose canonical admin grant
561
        // became valid only in this same cycle (the load-number filter on the
562
        // declaration's nanopub would otherwise exclude it). Runs first so the
563
        // attachment / role tiers below see this cycle's fresh npa:sameAsSpace edges.
564
        c.alias = runTierLabeled("alias(late)", graph, aliasAdmitUpdate(graph, -1));
×
565
        // Sub-space late-arrival: catches Mode-B candidates whose primary
566
        // declaration is older than lastProcessed but whose partner just landed.
567
        c.subSpace = runTierLabeled("subspace(late)", graph,
×
568
                subSpaceAdmitUpdate(graph, -1));
×
569
        // Maintained-resource late-arrival: catches declarations that landed
570
        // before the publisher's admin grant became valid in this state.
571
        c.maintainedResource = runTierLabeled("maintained-resource(late)", graph,
×
572
                maintainedResourceAdmitUpdate(graph, -1));
×
573
        // URL-prefix fallback: re-run after the late-arrival sub-space admit so
574
        // any newly-validated children get their fallback edges suppressed (for
575
        // future inserts) and any newly-orphaned children pick up fallback edges.
576
        c.subSpacePrefix = runTierLabeled("subspace-prefix(late)", graph,
×
577
                subSpacePrefixFallbackUpdate(graph));
×
578
        // Reflexive governing-space-ref late sweep (issue #130): catches refs whose
579
        // SpaceRef aggregate became visible only this cycle. Self-healing dedup.
580
        c.governingSpaceRef = runTierLabeled("governing-space-ref(late)", graph,
×
581
                governingSpaceRefReflexiveUpdate(graph));
×
582
        // Preset-attachment late-arrival: catches assignments whose preset declaration or
583
        // admin grant only became valid in this same cycle. Runs before attachment(late)
584
        // so the non-admin late tiers below see this cycle's fresh preset-derived RAs.
585
        c.presetAttachment = runTierLabeled("preset-attachment(late)", graph,
×
586
                presetAttachmentValidationUpdate(graph, -1));
×
587
        // Ref-scoped preset-assignment late stamp: catches assignments whose authorizing
588
        // admin grant only became valid this cycle (the load filter would skip the older
589
        // assignment nanopub). Mirrors the preset-attachment late sweep above.
590
        c.presetAssignmentRef = runTierLabeled("preset-assignment-ref(late)", graph,
×
591
                presetAssignmentRefStampUpdate(graph, -1));
×
592
        c.attachment = runTierLabeled("attachment(late)", graph,
×
593
                attachmentValidationUpdate(graph, -1));
×
594
        c.maintainer = runTierLabeled("maintainer(late)", graph,
×
595
                nonAdminTierUpdate(graph, -1, GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
×
596
        c.member = runTierLabeled("member(admin-pub,late)", graph,
×
597
                nonAdminTierUpdate(graph, -1, GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
×
598
        c.member += runTierLabeled("member(maint-pub,late)", graph,
×
599
                nonAdminTierUpdate(graph, -1,
×
600
                        GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
601
        c.observer = runTierLabeled("observer(admin-pub,late)", graph,
×
602
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
×
603
        c.observer += runTierLabeled("observer(maint-pub,late)", graph,
×
604
                nonAdminTierUpdate(graph, -1,
×
605
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
606
        c.observer += runTierLabeled("observer(member-pub,late)", graph,
×
607
                nonAdminTierUpdate(graph, -1,
×
608
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
×
609
        c.observer += runTierLabeled("observer(self,late)", graph,
×
610
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
×
611
        return c;
×
612
    }
613

614
    /**
615
     * Cheap ASK: did any new {@code npa:RoleDeclaration} extraction land in the
616
     * load-number delta {@code (lastProcessed, ∞)}? Used by the late-arrival
617
     * trigger so an RD that arrives in the same cycle as a matching candidate
618
     * still gets validated.
619
     */
620
    boolean newRoleDeclarationsArrived(long lastProcessed) {
621
        String ask = String.format("""
×
622
                PREFIX npa: <%1$s>
623
                ASK {
624
                  GRAPH <%2$s> {
625
                    ?rd a npa:RoleDeclaration ;
626
                        npa:viaNanopub ?np .
627
                  }
628
                  GRAPH <%3$s> {
629
                    ?np npa:hasLoadNumber ?ln .
630
                    FILTER (?ln > %4$d)
631
                  }
632
                }
633
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
×
634
        return runAsk(ask);
×
635
    }
636

637
    /**
638
     * Cheap ASK: did any new {@code npa:PresetAssignment} or {@code npa:PresetDeclaration}
639
     * extraction land in the load-number delta {@code (lastProcessed, ∞)}? Drives the
640
     * late-arrival re-run so a preset assignment that arrives in the same cycle as its
641
     * declaration (or admin grant) still materializes, and so an arriving newer assignment
642
     * triggers the deactivation/latest-wins re-evaluation.
643
     */
644
    boolean newPresetAssignmentsArrived(long lastProcessed) {
645
        String ask = String.format("""
×
646
                PREFIX npa: <%1$s>
647
                ASK {
648
                  GRAPH <%2$s> {
649
                    ?x a ?t ;
650
                       npa:viaNanopub ?np .
651
                    FILTER (?t = npa:PresetAssignment || ?t = npa:PresetDeclaration)
652
                  }
653
                  GRAPH <%3$s> {
654
                    ?np npa:hasLoadNumber ?ln .
655
                    FILTER (?ln > %4$d)
656
                  }
657
                }
658
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
×
659
        return runAsk(ask);
×
660
    }
661

662
    // ---------------- Tier UPDATE loops ----------------
663

664
    /**
665
     * Per-tier inserted-triple tallies for one build or cycle. Counts the sum
666
     * of {@code (graphSize_after - graphSize_before)} across all iterations of
667
     * each tier's fixed-point INSERT loop — i.e. inserted *triples*, not
668
     * distinct subjects (a single RoleInstantiation insert writes 4–5 triples).
669
     *
670
     * <p>Used internally by the {@link #runIncrementalCycle structuralAdds}
671
     * boolean check (we only care whether any tier inserted at all).
672
     * Not what the log lines report: see {@link TierSubjectTotals} +
673
     * {@link #computeTierSubjectTotals} for the distinct-subject totals
674
     * surfaced to operators.
675
     */
676
    /**
677
     * Total triples inserted across every tier. Used both for the metrics gauge and
678
     * for the empty-build guard in {@link #runFullBuild}, so the two can never
679
     * disagree about what "this build produced nothing" means.
680
     */
681
    static long totalInserted(TierInsertedTriples c) {
682
        return (long) c.admin + c.alias + c.presetAttachment + c.presetAssignmentRef
144✔
683
                + c.attachment + c.maintainer + c.member + c.observer
684
                + c.subSpace + c.subSpacePrefix + c.maintainedResource
685
                + c.governingSpaceRef;
686
    }
687

688
    static final class TierInsertedTriples {
9✔
689
        int admin;
690
        int alias;
691
        int presetAttachment;
692
        int presetAssignmentRef;
693
        int attachment;
694
        int maintainer;
695
        int member;
696
        int observer;
697
        int subSpace;
698
        int subSpacePrefix;
699
        int maintainedResource;
700
        int governingSpaceRef;
701
    }
702

703
    /**
704
     * Snapshot of distinct-subject totals in a space-state graph at a moment
705
     * in time. Independent of which tier-loop added each subject.
706
     */
707
    record TierSubjectTotals(long adminRIs, long attachmentRAs, long nonAdminRIs) {}
36✔
708

709
    /**
710
     * Runs the five tier loops in order: admin → {@code gen:hasRole} attachment
711
     * validation → maintainer → member → observer. Each loop iterates a SPARQL
712
     * INSERT to fixed point (no new triples added). Returns per-tier counts.
713
     *
714
     * @param graph         target space-state graph
715
     * @param lastProcessed load-number horizon; use {@code -1} for full build
716
     */
717
    TierInsertedTriples runAllTierLoops(IRI graph, long lastProcessed) {
718
        TierInsertedTriples c = new TierInsertedTriples();
×
719
        c.admin = runTierLabeled("admin", graph, adminTierUpdate(graph, lastProcessed));
×
720
        // Alias admit runs after the admin closure has settled (both the authority
721
        // gate and the anti-hijack check read the admin set) and before attachment /
722
        // role tiers (their alias-aware admin lookups consume the npa:sameAsSpace edge
723
        // this pass emits). See issue #113.
724
        c.alias = runTierLabeled("alias", graph, aliasAdmitUpdate(graph, lastProcessed));
×
725
        // Sub-space admit runs after admin closure has settled (Mode A + Mode B both
726
        // need the admin set). Independent of role tiers — order between subspace
727
        // and attachment / maintainer / member / observer doesn't matter.
728
        c.subSpace = runTierLabeled("subspace", graph, subSpaceAdmitUpdate(graph, lastProcessed));
×
729
        // Maintained-resource admit also depends only on the admin closure. Single
730
        // Mode A: publisher must be admin of the maintaining space. No co-declaration
731
        // partner, no URL-prefix fallback.
732
        c.maintainedResource = runTierLabeled("maintained-resource", graph,
×
733
                maintainedResourceAdmitUpdate(graph, lastProcessed));
×
734
        // URL-prefix sub-space fallback runs after the explicit-declaration admit
735
        // pass commits so the per-child suppression check sees this cycle's fresh
736
        // validations. No load filter — depends on which Spaces exist, not on
737
        // delta-arrivals; the dedup FILTER NOT EXISTS prevents re-insertion.
738
        c.subSpacePrefix = runTierLabeled("subspace-prefix", graph,
×
739
                subSpacePrefixFallbackUpdate(graph));
×
740
        // Reflexive governing-space-ref edges (issue #130). Self-healing, no load filter;
741
        // runs after the maintained-resource admit so a maintained resource that is itself
742
        // a space already has its maintained governing edge by now (the two are independent
743
        // anyway — different subjects/objects). Order vs. other tiers doesn't matter.
744
        c.governingSpaceRef = runTierLabeled("governing-space-ref", graph,
×
745
                governingSpaceRefReflexiveUpdate(graph));
×
746
        // Preset-attachment runs immediately before the regular attachment tier so the
747
        // gen:RoleAssignment rows it materializes (from active, admin-authored preset
748
        // assignments) are picked up by the downstream non-admin tiers in the same pass,
749
        // exactly like directly-published attachments. See
750
        // doc/design-preset-role-materialization.md.
751
        c.presetAttachment = runTierLabeled("preset-attachment", graph,
×
752
                presetAttachmentValidationUpdate(graph, lastProcessed));
×
753
        // Ref-scoped preset-assignment listing stamp (issue #122). Display-only leaf —
754
        // independent of the role tiers and of structuralAdds; order doesn't matter.
755
        c.presetAssignmentRef = runTierLabeled("preset-assignment-ref", graph,
×
756
                presetAssignmentRefStampUpdate(graph, lastProcessed));
×
757
        c.attachment = runTierLabeled("attachment", graph,
×
758
                attachmentValidationUpdate(graph, lastProcessed));
×
759
        c.maintainer = runTierLabeled("maintainer", graph, nonAdminTierUpdate(graph, lastProcessed,
×
760
                GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
761
        // Member tier: admin OR maintainer publisher — split into two simpler updates
762
        // so the query planner doesn't struggle with the UNION.
763
        c.member = runTierLabeled("member(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
764
                GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
765
        c.member += runTierLabeled("member(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
766
                GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
767
        // Observer tier: self-evidence OR a downward grant from any higher tier.
768
        // ObserverRole is the default tier when a role definition omits an
769
        // explicit subclass (see "Role types" in design-space-repositories.md), so
770
        // most "X assigned Y this role" nanopubs land here. Restricting the tier
771
        // to PUBLISHER_IS_SELF would silently drop those grants. The four
772
        // sub-loops mirror the trust-state's downward-only chain: admin grants
773
        // anything; maintainers and members grant observer; everyone may
774
        // self-attest.
775
        c.observer = runTierLabeled("observer(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
776
                GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
777
        c.observer += runTierLabeled("observer(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
778
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
×
779
        c.observer += runTierLabeled("observer(member-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
780
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
×
781
        c.observer += runTierLabeled("observer(self)", graph, nonAdminTierUpdate(graph, lastProcessed,
×
782
                GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
783
        return c;
×
784
    }
785

786
    /**
787
     * Builds a publisher constraint requiring the publisher to be a validated holder
788
     * of the given tier's role (maintainer or member) in the target space.
789
     * Owns its own AccountState resolution so ?publisher is bound through the
790
     * targeted (pkh → agent) lookup rather than enumerated.
791
     */
792
    private static String publisherIsTieredRole(IRI tierClass) {
793
        // Re-keyed on the assignment's ref (alias → canonical already resolved by the
794
        // attachment tier). Relies on materialized non-admin RIs carrying their role
795
        // property (npa:regularProperty / npa:inverseProperty) — supplied by the
796
        // enrichment in nonAdminTierUpdate; without it this constraint matched nothing.
797
        return """
×
798
                ?acct a npa:AccountState ;
799
                      npa:pubkey ?pkh ;
800
                      npa:agent  ?publisher .
801
                ?tierRI a gen:RoleInstantiation ;
802
                        npa:forSpaceRef ?spaceRef ;
803
                        npa:forAgent ?publisher .
804
                ?rdT a npa:RoleDeclaration ;
805
                     npa:hasRoleType <%1$s> .
806
                { ?tierRI npa:regularProperty ?predT . ?rdT gen:hasRegularProperty ?predT . }
807
                UNION
808
                { ?tierRI npa:inverseProperty ?predT . ?rdT gen:hasInverseProperty ?predT . }
809
                """.formatted(tierClass);
×
810
    }
811

812
    // ---------------- Role revocation / detachment (issue #129) ----------------
813

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

825
    /** Inner {@code GRAPH} block matching a revoker who is a validated admin of {@code ?spaceRef}. */
826
    private static String revokerAdminGraphBlock(IRI graph) {
827
        return String.format("""
27✔
828
                GRAPH <%1$s> {
829
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?revAgent .
830
                  ?revRI a gen:RoleInstantiation ;
831
                         npa:forSpaceRef ?spaceRef ;
832
                         npa:inverseProperty gen:hasAdmin ;
833
                         npa:forAgent ?revAgent .
834
                }""", graph);
835
    }
836

837
    /** Inner {@code GRAPH} block matching a revoker who holds {@code tier} in {@code ?spaceRef}. */
838
    private static String revokerTierGraphBlock(IRI graph, IRI tier) {
839
        return String.format("""
39✔
840
                GRAPH <%1$s> {
841
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?revAgent .
842
                  ?revRI a gen:RoleInstantiation ;
843
                         npa:forSpaceRef ?spaceRef ;
844
                         npa:forAgent ?revAgent ;
845
                         npa:hasRoleType <%2$s> .
846
                }""", graph, tier);
847
    }
848

849
    /** Inner {@code GRAPH} block matching a self-revoke: the revoker's key belongs to {@code ?agent}. */
850
    private static String revokerSelfGraphBlock(IRI graph) {
851
        return String.format("""
27✔
852
                GRAPH <%1$s> {
853
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?agent .
854
                }""", graph);
855
    }
856

857
    /**
858
     * Authorization arms for an instantiation revocation targeting a <em>compile-time</em>
859
     * tier — issue #129's matrix, the single arm builder used by BOTH the inline suppression
860
     * filter and the (per-tier-scoped) displacement DELETE, so the two paths can never
861
     * authorize different revokers and flip-flop a row. UNION of only the arms the matrix
862
     * permits for {@code targetTier}: admin of {@code ?spaceRef} (revokes any non-admin); a
863
     * maintainer (member/observer targets); a member (observer target); plus the assignee
864
     * itself (self-leave, any tier). A revoker must hold a tier strictly higher than the
865
     * target. Compile-time selection (no runtime {@code ?tier} variable) deliberately avoids
866
     * the SPARQL pitfall where a {@code FILTER} inside a {@code UNION} branch cannot see a
867
     * {@code ?tier} bound in the enclosing group.
868
     */
869
    private static String revocationAuthorityArmsForTier(IRI graph, IRI targetTier) {
870
        List<String> arms = new ArrayList<>();
12✔
871
        arms.add("{ " + revokerAdminGraphBlock(graph) + " }");
18✔
872
        if (GEN.MEMBER_ROLE.equals(targetTier) || GEN.OBSERVER_ROLE.equals(targetTier)) {
24✔
873
            arms.add("{ " + revokerTierGraphBlock(graph, GEN.MAINTAINER_ROLE) + " }");
21✔
874
        }
875
        if (GEN.OBSERVER_ROLE.equals(targetTier)) {
12✔
876
            arms.add("{ " + revokerTierGraphBlock(graph, GEN.MEMBER_ROLE) + " }");
21✔
877
        }
878
        arms.add("{ " + revokerSelfGraphBlock(graph) + " }");
18✔
879
        return String.join("\nUNION\n", arms);
12✔
880
    }
881

882
    /**
883
     * Inline suppression filter for {@code nonAdminTierUpdate}: rejects a candidate
884
     * instantiation ({@code ?ri}, created {@code ?candCreated}) whose {@code (space, agent,
885
     * role)} key has a newer authorized {@code npa:RoleRevocation}, using
886
     * {@link #revocationAuthorityArmsForTier} for {@code targetTier} (the loop tier) — the same
887
     * builder the displacement DELETE uses, so suppression and re-materialization always agree.
888
     * The revocation's named space is matched against any IRI denoting {@code ?spaceRef}
889
     * (canonical or validated {@code owl:sameAs} alias, issue #113), so an alias-named
890
     * revocation is not a silent no-op. Latest-wins by {@code dct:created} ({@link #EPOCH_DT}
891
     * fallback) with an {@code STR()} subject tiebreak. Not wrapped in {@code invalidationFilter}:
892
     * per issue #129 the only un-revoke path is a newer positive re-assignment.
893
     */
894
    private static String nonAdminRevocationSuppressionFilter(IRI graph, IRI targetTier) {
895
        return String.format("""
51✔
896
                FILTER NOT EXISTS {
897
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
898
                  UNION
899
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
900
                  GRAPH <%2$s> {
901
                    ?rev a npa:RoleRevocation ;
902
                         npa:forSpace    ?revSpace ;
903
                         npa:forAgent    ?agent ;
904
                         npa:revokedRole ?role ;
905
                         npa:pubkeyHash  ?revPkh .
906
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
907
                  }
908
                  BIND(COALESCE(?revCreatedRaw, %4$s) AS ?revCreated)
909
                  FILTER (?revCreated > ?candCreated
910
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri)))
911
                  { %3$s }
912
                }""", graph, SpacesVocab.SPACES_GRAPH,
913
                revocationAuthorityArmsForTier(graph, targetTier), EPOCH_DT);
18✔
914
    }
915

916
    /**
917
     * Inline suppression filter for {@code adminTierUpdate}: rejects an admin instantiation
918
     * ({@code ?ri}, created {@code ?candCreated}) whose {@code (ref, agent)} key has a newer
919
     * authorized admin {@code npa:RoleRevocation} ({@code revokedRole = gen:AdminRole}) —
920
     * authorized by an admin of the ref (admins revoke admins) or by the agent itself
921
     * (self-leave). <b>Root admins are exempt</b> (issue #129/#110): a nested
922
     * {@code FILTER NOT EXISTS} on {@code npa:hasRootAdmin} makes any revocation against a
923
     * root admin structurally inert, overriding self-leave. {@code gen:AdminRole} resolves
924
     * via the {@code gen:} prefix the admin-tier template declares.
925
     */
926
    private static String adminRevocationSuppressionFilter(IRI graph) {
927
        return String.format("""
48✔
928
                FILTER NOT EXISTS {
929
                  FILTER NOT EXISTS { GRAPH <%2$s> {
930
                    ?rootDef a npa:SpaceDefinition ;
931
                             npa:forSpaceRef  ?spaceRef ;
932
                             npa:hasRootAdmin ?agent .
933
                  } }
934
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
935
                  UNION
936
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
937
                  GRAPH <%2$s> {
938
                    ?rev a npa:RoleRevocation ;
939
                         npa:forSpace    ?revSpace ;
940
                         npa:forAgent    ?agent ;
941
                         npa:revokedRole gen:AdminRole ;
942
                         npa:pubkeyHash  ?revPkh .
943
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
944
                  }
945
                  BIND(COALESCE(?revCreatedRaw, %4$s) AS ?revCreated)
946
                  FILTER (?revCreated > ?candCreated
947
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri)))
948
                  { %3$s }
949
                }""", graph, SpacesVocab.SPACES_GRAPH,
950
                "{ " + revokerAdminGraphBlock(graph) + " }\nUNION\n{ "
6✔
951
                        + revokerSelfGraphBlock(graph) + " }",
21✔
952
                EPOCH_DT);
953
    }
954

955
    /**
956
     * Inline suppression filter for the attachment tiers ({@code attachmentValidationUpdate}
957
     * and {@code presetAttachmentValidationUpdate}): rejects a {@code (targetRef, role)}
958
     * attachment whose effective timestamp ({@code ?<createdVar>}) is out-ranked by a newer
959
     * admin-authored {@code npa:RoleDetachment} (issue #129). Authority = admin of
960
     * {@code ?targetRef} (matching who may attach). Non-sticky latest-wins: a newer
961
     * attachment / preset assignment naturally re-attaches because its timestamp beats the
962
     * detachment. The detachment's named space is matched against any IRI denoting
963
     * {@code ?targetRef} (canonical or {@code owl:sameAs} alias).
964
     *
965
     * @param createdVar     bare name of the attachment's effective-created variable
966
     * @param attachSubjVar  bare name of the attachment subject variable (for the STR tiebreak)
967
     */
968
    private static String roleDetachmentSuppressionFilter(IRI graph, String createdVar, String attachSubjVar) {
969
        return String.format("""
75✔
970
                FILTER NOT EXISTS {
971
                  { GRAPH <%2$s> { ?targetRef npa:spaceIri ?detSpace . } }
972
                  UNION
973
                  { GRAPH <%1$s> { ?detSpace npa:sameAsSpace ?targetRef . } }
974
                  GRAPH <%2$s> {
975
                    ?det a npa:RoleDetachment ;
976
                         npa:forSpace    ?detSpace ;
977
                         npa:revokedRole ?role ;
978
                         npa:pubkeyHash  ?detPkh .
979
                    OPTIONAL { ?det <http://purl.org/dc/terms/created> ?detCreatedRaw . }
980
                  }
981
                  BIND(COALESCE(?detCreatedRaw, %5$s) AS ?detCreated)
982
                  FILTER (?detCreated > ?%3$s
983
                          || (?detCreated = ?%3$s && STR(?det) > STR(?%4$s)))
984
                  GRAPH <%1$s> {
985
                    ?detAcct a npa:AccountState ; npa:pubkey ?detPkh ; npa:agent ?detAgent .
986
                    ?detAdminRI a gen:RoleInstantiation ;
987
                                npa:forSpaceRef ?targetRef ;
988
                                npa:inverseProperty gen:hasAdmin ;
989
                                npa:forAgent ?detAgent .
990
                  }
991
                }""", graph, SpacesVocab.SPACES_GRAPH, createdVar, attachSubjVar, EPOCH_DT);
992
    }
993

994
    /** Wraps {@link #runTierLoop} with tier-name context for logs/exceptions. */
995
    private int runTierLabeled(String tier, IRI graph, String sparqlUpdate) {
996
        try {
997
            return runTierLoop(graph, sparqlUpdate);
×
998
        } catch (RuntimeException ex) {
×
999
            logger.error("AuthorityResolver: tier={} failed with SPARQL UPDATE:\n{}\n", tier, sparqlUpdate, ex);
×
1000
            throw ex;
×
1001
        }
1002
    }
1003

1004
    /**
1005
     * Runs a single tier's INSERT to fixed point. Counts rows by probing
1006
     * graph size before/after each INSERT; stops when the size doesn't change.
1007
     *
1008
     * @return total number of triples inserted by this tier across all iterations
1009
     */
1010
    int runTierLoop(IRI graph, String sparqlUpdate) {
1011
        int total = 0;
×
1012
        long before = graphSize(graph);
×
1013
        while (true) {
1014
            // Note: no explicit transaction wrapping here. In tests we observed that
1015
            // HTTPRepository's RDF4J-transaction protocol silently no-op'd cross-graph
1016
            // SPARQL UPDATEs with UNION sub-patterns inside conn.begin()/commit(),
1017
            // while the same UPDATE POSTed directly to /statements applied correctly.
1018
            // A bare prepareUpdate().execute() takes the direct /statements path and
1019
            // runs the UPDATE atomically per SPARQL 1.1 semantics — which is all we
1020
            // need; there's nothing else to commit atomically alongside the UPDATE.
1021
            try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1022
                conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
1023
            }
1024
            long after = graphSize(graph);
×
1025
            long added = after - before;
×
1026
            if (added <= 0) break;
×
1027
            total += added;
×
1028
            before = after;
×
1029
        }
×
1030
        return total;
×
1031
    }
1032

1033
    private long graphSize(IRI graph) {
1034
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
1035
            return conn.size(graph);
×
1036
        }
1037
    }
1038

1039
    /**
1040
     * Distinct-subject totals in the given space-state graph, broken down by
1041
     * RoleInstantiation kind (admin-pinned vs not) and RoleAssignment.
1042
     * Three SELECT-COUNT queries — cheap, called once per build/cycle for
1043
     * the user-facing log line. Returns zeros on failure (logged) so a flaky
1044
     * count read can't wedge the cycle.
1045
     */
1046
    TierSubjectTotals computeTierSubjectTotals(IRI graph) {
1047
        long adminRIs       = countDistinctSubjects(graph, """
×
1048
                ?ri a gen:RoleInstantiation ; npa:inverseProperty gen:hasAdmin .
1049
                """, "ri");
1050
        long attachmentRAs  = countDistinctSubjects(graph, """
×
1051
                ?ra a gen:RoleAssignment .
1052
                """, "ra");
1053
        long nonAdminRIs    = countDistinctSubjects(graph, """
×
1054
                ?ri a gen:RoleInstantiation .
1055
                FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
1056
                """, "ri");
1057
        return new TierSubjectTotals(adminRIs, attachmentRAs, nonAdminRIs);
×
1058
    }
1059

1060
    private long countDistinctSubjects(IRI graph, String wherePattern, String varName) {
1061
        String query = String.format("""
×
1062
                PREFIX npa: <%1$s>
1063
                PREFIX gen: <%2$s>
1064
                SELECT (COUNT(DISTINCT ?%3$s) AS ?n) WHERE {
1065
                  GRAPH <%4$s> {
1066
                    %5$s
1067
                  }
1068
                }
1069
                """, NPA.NAMESPACE, GEN.NAMESPACE, varName, graph, wherePattern);
1070
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
×
1071
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
1072
            if (!r.hasNext()) return 0;
×
1073
            return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
×
1074
        } catch (Exception ex) {
×
1075
            logger.warn("AuthorityResolver: countDistinctSubjects on {} failed: {}",
×
1076
                    graph, ex.toString());
×
1077
            return 0;
×
1078
        }
1079
    }
1080

1081
    // ---------------- SPARQL templates ----------------
1082

1083
    /**
1084
     * Reusable invalidation filter on a bound nanopub-IRI variable. Pass the bare
1085
     * variable name (no leading {@code ?}); e.g. {@code invalidationFilter("np")}
1086
     * produces an outer-scoped {@code FILTER NOT EXISTS { GRAPH npa:graph
1087
     * { ?_inv_np npx:invalidates ?np . } }}.
1088
     *
1089
     * <p>Joins on the raw {@code npx:invalidates} triple in {@code npa:graph},
1090
     * which {@link com.knowledgepixels.query.NanopubLoader} writes into the
1091
     * spaces repo from two complementary directions, making the filter symmetric
1092
     * in load order:
1093
     * <ul>
1094
     *   <li>At the invalidator's own load: the loader's space-repo trigger fires
1095
     *       whenever the nanopub has either its own space-relevant extractions
1096
     *       OR an {@code npx:invalidates}/{@code npx:retracts}/{@code npx:supersedes}
1097
     *       triple, so a pure-retraction nanopub still lands its raw triple plus
1098
     *       {@code npa:hasLoadNumber} stamp in {@code npa:graph}.</li>
1099
     *   <li>At the invalidated target's load (when the invalidator landed
1100
     *       earlier): {@code NanopubLoader.getInvalidatingStatements} reads the
1101
     *       triple back from the meta repo and mirrors it into the target's own
1102
     *       write to the spaces repo.</li>
1103
     * </ul>
1104
     *
1105
     * <p>The earlier shape joined on a structured {@code npa:Invalidation} entry
1106
     * in {@code npa:spacesGraph} that was only emitted on the invalidator's side
1107
     * AND only when the invalidated target's meta had already loaded, leaving a
1108
     * window where a superseding nanopub loaded before its target produced no
1109
     * entry and the stale row was never filtered out (see also the matching
1110
     * change in the tier-specific {@code *InvalidationCheckWhere}/{@code
1111
     * *InvalidationDelete} templates below).
1112
     *
1113
     * <p>Important: this filter must be placed OUTSIDE the surrounding
1114
     * {@code GRAPH npa:spacesGraph { ... }} block, not nested inside it. When
1115
     * nested, RDF4J's planner couples the FILTER NOT EXISTS evaluation into the
1116
     * join order (per-row scan multiplied by the candidate set), which we
1117
     * measured turning a 39ms query into a 60s+ timeout on the live observer-tier
1118
     * data. Outside the GRAPH block, the planner defers the filter until
1119
     * {@code ?np}/{@code ?rdNp} are bound and does a targeted index lookup.
1120
     *
1121
     * <p>Variable names must match {@code [A-Za-z0-9_]+} per SPARQL grammar —
1122
     * embedding a {@code ?} inside {@code ?_inv_?np} would yield a parse error.
1123
     */
1124
    private static String invalidationFilter(String bareVarName) {
1125
        return "FILTER NOT EXISTS { GRAPH <" + NPA.GRAPH + "> {"
30✔
1126
                + " ?_inv_" + bareVarName
1127
                + " <" + NPX.INVALIDATES + "> ?" + bareVarName + " . "
1128
                + samePublisherClause("_inv_" + bareVarName, bareVarName)
6✔
1129
                + " } }";
1130
    }
1131

1132
    /**
1133
     * SPARQL triple pair (placed inside a {@code GRAPH npa:graph { ... }} block)
1134
     * requiring the invalidating nanopub and its target to share a signing public
1135
     * key — the self-retraction authority gate for issue #112. Without it, the
1136
     * materializer honors {@code npx:invalidates}/{@code retracts}/{@code supersedes}
1137
     * from <em>any</em> validly-signed nanopub, so any agent can erase another
1138
     * space's materialized state (griefing/DoS of the view — fail-closed, no
1139
     * privilege escalation, but real). Additions are already admin-gated; this is
1140
     * the symmetric gate on removals.
1141
     *
1142
     * <p>Both {@code npa:hasValidSignatureForPublicKeyHash} triples live in
1143
     * {@code npa:graph} of the spaces repo: the target via its own space-load, the
1144
     * invalidator via the symmetric retractor propagation in
1145
     * {@link com.knowledgepixels.query.NanopubLoader} (forward {@code
1146
     * loadInvalidateStatements} + reverse {@code loadInvalidatorIntoSpacesRepo}),
1147
     * so the join is populated regardless of load order.
1148
     *
1149
     * <p>"Same pubkey" is intentionally stricter than "same agent": a retraction
1150
     * signed by a different key the author owns (key rotation) is not honored, and
1151
     * cross-admin supersession is out of scope here (would need an admin-authority
1152
     * arm). The pubkey-bridge variable is suffixed with {@code targetVar} so two
1153
     * filters in one query (e.g. on {@code ?np} and {@code ?rdNp}) don't collide.
1154
     *
1155
     * @param invVar    invalidator nanopub variable name (no leading {@code ?})
1156
     * @param targetVar invalidated-target nanopub variable name (no leading {@code ?})
1157
     */
1158
    private static String samePublisherClause(String invVar, String targetVar) {
1159
        String pk = "?_invpk_" + targetVar;
9✔
1160
        return "?" + invVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " . "
30✔
1161
                + "?" + targetVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " .";
1162
    }
1163

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

1292
    /**
1293
     * Seed-survival filter for the admin tier (issue #110). The {@code hasRootAdmin}
1294
     * seed is anchored to the root NPID, which is the immutable space-ref identity, so
1295
     * it must survive supersession of the root <em>nanopub</em> by a continuation
1296
     * revision (a later definition re-roots to the same ref via
1297
     * {@code gen:hasRootDefinition} and so carries no {@code hasRootAdmin} of its own).
1298
     * The previous {@code invalidationFilter("defNp")} dropped the seed the moment the
1299
     * root revision was superseded, leaving the whole admin closure — and everything
1300
     * cascading from it — unmaterialized for any space whose definition had ever been
1301
     * updated.
1302
     *
1303
     * <p>Expressed positively: the seed survives iff the space ref still has at least
1304
     * one non-invalidated {@link SpacesVocab#SPACE_DEFINITION}. A fully-retracted ref
1305
     * (every definition invalidated) has no live definition, so the {@code FILTER
1306
     * EXISTS} fails and the seed correctly disappears. Anchored on the already-bound
1307
     * {@code ?spaceRef}, so it's a targeted lookup over that ref's (few) definitions.
1308
     */
1309
    private static String spaceRefAliveFilter() {
1310
        return """
33✔
1311
                FILTER EXISTS {
1312
                  GRAPH <%1$s> {
1313
                    ?liveDef a npa:SpaceDefinition ;
1314
                             npa:forSpaceRef ?spaceRef ;
1315
                             npa:viaNanopub  ?liveNp .
1316
                  }
1317
                  %2$s
1318
                }
1319
                """.formatted(SpacesVocab.SPACES_GRAPH, invalidationFilter("liveNp"));
9✔
1320
    }
1321

1322
    /**
1323
     * {@code gen:hasRole} attachment validation: an attachment is validated iff its
1324
     * publisher is already a validated admin of the target space. Adds
1325
     * {@code gen:RoleAssignment} rows to the space-state graph.
1326
     */
1327
    static String attachmentValidationUpdate(IRI graph, long lastProcessed) {
1328
        // Ref-keyed (see doc/design-spaceref-isolation.md). The attachment names a bare
1329
        // Space IRI; it is validated per-ref for every ref of that IRI whose admin set
1330
        // contains the publisher (direct), or — when the named IRI is an owl:sameAs alias
1331
        // — for the canonical ref it maps to (issue #113). ?targetRef is the ref the
1332
        // RoleAssignment attaches to; the inserted subject is minted per (?ra, ?targetRef)
1333
        // so one attachment validating into N refs yields N distinct rows.
1334
        // TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace (the attached IRI, possibly an
1335
        // alias) is kept so the non-admin tier can probe the IRI-keyed instantiations
1336
        // naming it, and so pre-ref read queries keep functioning on a mixed-version fleet.
1337
        return """
69✔
1338
                PREFIX npa:  <%1$s>
1339
                PREFIX gen:  <%2$s>
1340
                INSERT { GRAPH <%3$s> {
1341
                  ?ra2 a gen:RoleAssignment ;
1342
                       npa:forSpaceRef ?targetRef ;
1343
                       npa:forSpace ?space ;
1344
                       gen:hasRole  ?role ;
1345
                       npa:viaNanopub ?np .
1346
                } }
1347
                WHERE {
1348
                  GRAPH <%4$s> {
1349
                    ?ra a gen:RoleAssignment ;
1350
                        npa:forSpace ?space ;
1351
                        gen:hasRole  ?role ;
1352
                        npa:pubkeyHash ?pkh ;
1353
                        npa:viaNanopub ?np .
1354
                    # Attachment timestamp for the detachment latest-wins (issue #129).
1355
                    OPTIONAL { ?ra <http://purl.org/dc/terms/created> ?attCreatedRaw . }
1356
                  }
1357
                  BIND(COALESCE(?attCreatedRaw, %8$s) AS ?attCreated)
1358
                  GRAPH <%7$s> {
1359
                    ?np npa:hasLoadNumber ?ln .
1360
                    FILTER (?ln > %5$d)
1361
                  }
1362
                  GRAPH <%3$s> {
1363
                    ?acct a npa:AccountState ;
1364
                          npa:agent  ?publisher ;
1365
                          npa:pubkey ?pkh .
1366
                  }
1367
                  # Per-ref admin gate. ?targetRef = a ref of ?space the publisher admins
1368
                  # (direct), or the canonical ref ?space is an owl:sameAs alias of.
1369
                  {
1370
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?space . }
1371
                    GRAPH <%3$s> {
1372
                      ?adminRI a gen:RoleInstantiation ;
1373
                               npa:forSpaceRef ?targetRef ;
1374
                               npa:inverseProperty gen:hasAdmin ;
1375
                               npa:forAgent ?publisher .
1376
                    }
1377
                  }
1378
                  UNION
1379
                  {
1380
                    GRAPH <%3$s> {
1381
                      ?space npa:sameAsSpace ?targetRef .
1382
                      ?adminRI a gen:RoleInstantiation ;
1383
                               npa:forSpaceRef ?targetRef ;
1384
                               npa:inverseProperty gen:hasAdmin ;
1385
                               npa:forAgent ?publisher .
1386
                    }
1387
                  }
1388
                  BIND(IRI(CONCAT(STR(?ra), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?ra2)
1389
                  %6$s
1390
                  # Detachment latest-wins (issue #129): suppress if a newer admin-authored
1391
                  # gen:detachedRole out-ranks this (ref, role) attachment.
1392
                  %9$s
1393
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1394
                    ?existing a gen:RoleAssignment ;
1395
                              npa:forSpaceRef ?targetRef ;
1396
                              gen:hasRole  ?role .
1397
                  } }
1398
                }
1399
                """.formatted(
3✔
1400
                NPA.NAMESPACE,
1401
                GEN.NAMESPACE,
1402
                graph,
1403
                SpacesVocab.SPACES_GRAPH,
1404
                lastProcessed,
15✔
1405
                invalidationFilter("np"),
45✔
1406
                NPA.GRAPH,
1407
                EPOCH_DT,
1408
                roleDetachmentSuppressionFilter(graph, "attCreated", "ra"));
6✔
1409
    }
1410

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

1581
    /**
1582
     * Stamps a ref-scoped, admin-validated mirror of each {@code npa:PresetAssignment}
1583
     * into the state graph (issue #122). The publisher-agnostic extraction row
1584
     * ({@link SpacesExtractor#extractPresetAssignment}) is keyed only by
1585
     * {@code npa:forResource}, so a consumer listing a space's preset assignments by IRI
1586
     * sees the union across <em>all</em> refs claiming that IRI. This stamp adds
1587
     * {@code npa:forSpaceRef ?targetRef} so the "Assigned presets" listing is no longer
1588
     * merged across refs of the same IRI — the one remaining About-tab listing that still
1589
     * merged across refs (every other ref-scoped listing already has a {@code forSpaceRef}
1590
     * companion).
1591
     *
1592
     * <p>Faithful per-assignment mirror — deliberately <em>not</em> role-gated and
1593
     * <em>not</em> latest-wins-resolved, unlike {@link #presetAttachmentValidationUpdate}:
1594
     * <ul>
1595
     *   <li>No {@code npa:PresetDeclaration}/role join, so a preset that bundles only
1596
     *       <em>views</em> (no roles) is still listed.</li>
1597
     *   <li>Emits active <em>and</em> deactivated rows (carries {@code npa:isActivated})
1598
     *       so the listing can show state; a deactivation is just a newer admin-authored
1599
     *       row, so no {@code dct:created}-driven removal is needed here (contrast §4.4).</li>
1600
     *   <li>Latest-wins is deferred to the consumer query, which ranges only over these
1601
     *       admin-authored rows — so it is authorization-scoped for free (design §3): a
1602
     *       non-admin of the ref can never get a row stamped, so it cannot enter the
1603
     *       latest-wins race.</li>
1604
     * </ul>
1605
     *
1606
     * <p>Display-only leaf: nothing downstream derives from these rows (contrast the
1607
     * preset-derived {@code gen:RoleAssignment}), so the caller must <em>not</em> feed this
1608
     * tier's count into {@code structuralAdds}. The {@code npa:forSpaceRef} predicate also
1609
     * distinguishes a stamped row from the IRI-keyed extraction row (which never carries it),
1610
     * so {@link #presetAssignmentRefInvalidationDelete} can target exactly these rows.
1611
     * Reuses steps 1–4 of {@link #presetAttachmentValidationUpdate}; see
1612
     * doc/design-preset-role-materialization.md §3 and issue #122.
1613
     */
1614
    static String presetAssignmentRefStampUpdate(IRI graph, long lastProcessed) {
1615
        return """
69✔
1616
                PREFIX npa:  <%1$s>
1617
                PREFIX gen:  <%2$s>
1618
                INSERT { GRAPH <%3$s> {
1619
                  ?paRef a npa:PresetAssignment ;
1620
                         npa:ofPreset    ?preset ;
1621
                         npa:forResource ?resource ;
1622
                         npa:forSpaceRef ?targetRef ;
1623
                         npa:isActivated ?activated ;
1624
                         npa:viaNanopub  ?assignNp ;
1625
                         <http://purl.org/dc/terms/created> ?created .
1626
                } }
1627
                WHERE {
1628
                  # 1. Anchor: every assignment row (active or not) in the extraction graph.
1629
                  GRAPH <%4$s> {
1630
                    ?pa a npa:PresetAssignment ;
1631
                        npa:ofPreset    ?preset ;
1632
                        npa:forResource ?resource ;
1633
                        npa:isActivated ?activated ;
1634
                        npa:pubkeyHash  ?pkh ;
1635
                        npa:viaNanopub  ?assignNp ;
1636
                        <http://purl.org/dc/terms/created> ?created .
1637
                  }
1638
                  # 2. Load-number filter on the assignment nanopub (delta window).
1639
                  GRAPH <%6$s> {
1640
                    ?assignNp npa:hasLoadNumber ?ln .
1641
                    FILTER (?ln > %5$d)
1642
                  }
1643
                  # 3. Resolve publisher pkh -> agent via the mirrored trust-approved row.
1644
                  GRAPH <%3$s> {
1645
                    ?acct a npa:AccountState ;
1646
                          npa:agent  ?publisher ;
1647
                          npa:pubkey ?pkh .
1648
                  }
1649
                  # 4. Target must be a Space ref the publisher admins. ?targetRef = that ref;
1650
                  #    fan-out to N refs the publisher admins (per-ref isolation, consistent
1651
                  #    with the role materializer and design-spaceref-isolation.md). Direct,
1652
                  #    or the canonical ref ?resource is an owl:sameAs alias of (issue #113),
1653
                  #    so an assignment naming an alias is still listed under the canonical ref.
1654
                  {
1655
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?resource . }
1656
                    GRAPH <%3$s> {
1657
                      ?adminRI a gen:RoleInstantiation ;
1658
                               npa:forSpaceRef ?targetRef ;
1659
                               npa:inverseProperty gen:hasAdmin ;
1660
                               npa:forAgent ?publisher .
1661
                    }
1662
                  }
1663
                  UNION
1664
                  {
1665
                    GRAPH <%3$s> {
1666
                      ?resource npa:sameAsSpace ?targetRef .
1667
                      ?adminRI a gen:RoleInstantiation ;
1668
                               npa:forSpaceRef ?targetRef ;
1669
                               npa:inverseProperty gen:hasAdmin ;
1670
                               npa:forAgent ?publisher .
1671
                    }
1672
                  }
1673
                  # 5. Defensive: drop if the assignment nanopub itself was hard-retracted.
1674
                  %7$s
1675
                  # 6. Mint per (assignment, ref); dedup on the bound subject. No latest-wins
1676
                  #    here — a deactivation is just a newer admin-authored row, and the
1677
                  #    consumer resolves latest dct:created per (preset,resource) over these
1678
                  #    admin-authored rows (so the resolution is authorization-scoped).
1679
                  BIND(IRI(CONCAT(STR(?pa), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?paRef)
1680
                  FILTER NOT EXISTS { GRAPH <%3$s> { ?paRef a npa:PresetAssignment . } }
1681
                }
1682
                """.formatted(
3✔
1683
                NPA.NAMESPACE,
1684
                GEN.NAMESPACE,
1685
                graph,
1686
                SpacesVocab.SPACES_GRAPH,
1687
                lastProcessed,
27✔
1688
                NPA.GRAPH,
1689
                invalidationFilter("assignNp"));
6✔
1690
    }
1691

1692
    /**
1693
     * Non-admin tier publisher constraints (inserted as a SPARQL sub-pattern).
1694
     * Each constraint owns the AccountState (pkh → agent) lookup so the join
1695
     * variable is bound through a targeted pattern. The observer-self variant
1696
     * binds {@code npa:agent ?agent} directly — no separate {@code ?publisher}
1697
     * variable, no post-join equality filter — which lets the planner anchor
1698
     * the AccountState lookup on the already-bound {@code ?agent} instead of
1699
     * enumerating all approved publishers and filtering at the end.
1700
     */
1701
    static final String PUBLISHER_IS_ADMIN = """
1702
            ?acct a npa:AccountState ;
1703
                  npa:pubkey ?pkh ;
1704
                  npa:agent  ?publisher .
1705
            # Admin of the assignment's ref. The ref already resolves alias →
1706
            # canonical (the attachment tier bound ?spaceRef through the owl:sameAs
1707
            # alias edge for aliased IRIs, issue #113), so no alias arm is needed here.
1708
            ?adminRI a gen:RoleInstantiation ;
1709
                     npa:forSpaceRef ?spaceRef ;
1710
                     npa:inverseProperty gen:hasAdmin ;
1711
                     npa:forAgent ?publisher .
1712
            """;
1713

1714
    /** Observer self-evidence: the assignee's own pubkey signed the instantiation. */
1715
    static final String PUBLISHER_IS_SELF = """
1716
            ?acct a npa:AccountState ;
1717
                  npa:pubkey ?pkh ;
1718
                  npa:agent  ?agent .
1719
            """;
1720

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

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

2046
    /**
2047
     * Maintained-resource admit pass. Copies validated
2048
     * {@code npa:MaintainedResourceDeclaration} extraction rows into the space-state
2049
     * graph (preserving the {@code npamrd:} subject) and emits convenience
2050
     * {@code <r> npa:isMaintainedBy <s>} and {@code <s> npa:hasMaintainedResource <r>}
2051
     * direct triples. Single satisfaction mode:
2052
     * <ul>
2053
     *   <li>Mode A — the declaration's publisher is a validated admin of the
2054
     *       maintaining space.</li>
2055
     * </ul>
2056
     *
2057
     * <p>No Mode B because only one space is involved; the two-sides-must-be-covered
2058
     * concern that drives sub-space Mode B doesn't apply. Late-arrival is still
2059
     * possible (declaration lands before the publisher's admin grant becomes valid):
2060
     * the load-number filter on {@code ?np} excludes the candidate, and the
2061
     * late-arrival sweep ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass
2062
     * without the load filter and catches it.
2063
     */
2064
    static String maintainedResourceAdmitUpdate(IRI graph, long lastProcessed) {
2065
        return """
69✔
2066
                PREFIX npa: <%1$s>
2067
                PREFIX gen: <%2$s>
2068
                INSERT { GRAPH <%3$s> {
2069
                  ?d a npa:MaintainedResourceDeclaration ;
2070
                     npa:resourceIri     ?r ;
2071
                     npa:maintainerSpace ?s ;
2072
                     npa:viaNanopub      ?np .
2073
                  ?r npa:isMaintainedBy        ?sRef .
2074
                  ?sRef npa:hasMaintainedResource ?r .
2075
                  # Uniform ref-valued resource→governing-space-ref edge (issue #130). The
2076
                  # same predicate the reflexive space self-edge uses, so a single consumer
2077
                  # hop covers both "resource maintained by space S" and "resource IS a space".
2078
                  # Backed by the same MaintainedResourceLink below, so the invalidation
2079
                  # cleanup sweeps it alongside isMaintainedBy.
2080
                  ?r npa:hasGoverningSpaceRef  ?sRef .
2081
                  # Reified per-(nanopub, resource→ref) provenance link (issue #125 finding
2082
                  # #5): lets the invalidation cleanup drop the convenience edges below once
2083
                  # no surviving link backs them, instead of leaving them sticky.
2084
                  ?mrLink a npa:MaintainedResourceLink ;
2085
                          npa:viaNanopub         ?np ;
2086
                          npa:resourceIri        ?r ;
2087
                          npa:maintainerSpaceRef ?sRef ;
2088
                          npa:maintainerSpace    ?s .
2089
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2090
                  # maintained-resource edge alongside the resource→ref one, so pre-ref
2091
                  # published queries (e.g. get-view-displays' maintained hop) keep binding
2092
                  # on a mixed-version fleet. This is the edge whose absence broke 1.15.0 —
2093
                  # see doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2094
                  ?r npa:isMaintainedBy        ?s .
2095
                  ?s npa:hasMaintainedResource ?r .
2096
                } }
2097
                WHERE {
2098
                  # 1. Anchor: candidate declarations from the extraction graph.
2099
                  GRAPH <%4$s> {
2100
                    ?d a npa:MaintainedResourceDeclaration ;
2101
                       npa:resourceIri     ?r ;
2102
                       npa:maintainerSpace ?s ;
2103
                       npa:pubkeyHash      ?pkh ;
2104
                       npa:viaNanopub      ?np .
2105
                  }
2106
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
2107
                  GRAPH <%3$s> {
2108
                    ?acct a npa:AccountState ;
2109
                          npa:pubkey ?pkh ;
2110
                          npa:agent  ?publisher .
2111
                    # 3. Authority gate (Mode A only): publisher is admin of a ref of the
2112
                    #    maintaining space. ?sRef = that ref (resource → ref edge).
2113
                    ?riA a gen:RoleInstantiation ;
2114
                         npa:inverseProperty gen:hasAdmin ;
2115
                         npa:forSpace ?s ;
2116
                         npa:forSpaceRef ?sRef ;
2117
                         npa:forAgent ?publisher .
2118
                  }
2119
                  # 4. Invalidation filter on the declaration's nanopub.
2120
                  %6$s
2121
                  # 5. Load-number filter on bound ?np.
2122
                  GRAPH <%7$s> {
2123
                    ?np npa:hasLoadNumber ?ln .
2124
                    FILTER (?ln > %5$d)
2125
                  }
2126
                  # 6. Mint the per-(nanopub, resource→ref) provenance link IRI and dedup on
2127
                  #    it (not on the bare edge), so every backing declaration records its own
2128
                  #    removable link; the convenience edges above are re-asserted idempotently.
2129
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/maintained/",
2130
                                  MD5(CONCAT(STR(?np), "|", STR(?r), "|", STR(?sRef))))) AS ?mrLink)
2131
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2132
                    ?mrLink a npa:MaintainedResourceLink .
2133
                  } }
2134
                }
2135
                """.formatted(
3✔
2136
                NPA.NAMESPACE,
2137
                GEN.NAMESPACE,
2138
                graph,
2139
                SpacesVocab.SPACES_GRAPH,
2140
                lastProcessed,
15✔
2141
                invalidationFilter("np"),
18✔
2142
                NPA.GRAPH);
2143
    }
2144

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

2277
    /**
2278
     * URL-prefix sub-space fallback admit pass. For every pair of {@code SpaceRef}
2279
     * aggregates where the child's {@code npa:hasIdPrefix} matches the parent's
2280
     * {@code npa:spaceIri}, emits convenience {@code <child> npa:isSubSpaceOf <parent>}
2281
     * and {@code <parent> npa:hasSubSpace <child>} direct triples plus a reified
2282
     * {@code npa:DerivedSubSpaceLink} tag carrying {@code npa:derivationKind
2283
     * npa:byUrlPrefix} so consumers can hide derived edges.
2284
     *
2285
     * <p>Per-child suppression: any validated {@code npa:SubSpaceDeclaration} on the
2286
     * child in {@code npass:<…>} suppresses every fallback edge for that child.
2287
     * Suppression checks the validated set (not raw extraction-graph declarations)
2288
     * so an unapproved or in-flight Mode B declaration doesn't silently hide both
2289
     * the URL-prefix fallback and the (still-invalid) explicit relation.
2290
     *
2291
     * <p>Run order: must run after {@link #subSpaceAdmitUpdate} commits in the
2292
     * same cycle so the suppression check sees this cycle's freshly-validated
2293
     * declarations.
2294
     *
2295
     * <p>No load-number filter: the fallback depends on which Spaces exist (parent
2296
     * + child {@code SpaceRef}s), not on which were just added. Always full-scan;
2297
     * the dedup {@code FILTER NOT EXISTS} on the tag IRI prevents re-insertion.
2298
     *
2299
     * <p>No invalidation handling: derived edges have no source nanopub. Two
2300
     * staleness modes: (a) child later gets first validated declaration → old
2301
     * derived edges stay sticky until the next periodic rebuild (same policy as
2302
     * admin-RI invalidation); (b) child loses last validated declaration → the
2303
     * regular fallback pass on the next cycle re-engages, adds derived edges
2304
     * incrementally, no rebuild needed.
2305
     */
2306
    static String subSpacePrefixFallbackUpdate(IRI graph) {
2307
        return """
48✔
2308
                PREFIX npa: <%1$s>
2309
                INSERT { GRAPH <%2$s> {
2310
                  ?childRef  npa:isSubSpaceOf ?parentRef .
2311
                  ?parentRef npa:hasSubSpace  ?childRef  .
2312
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2313
                  # derived sub-space edge alongside the ref-to-ref one, mirroring the
2314
                  # explicit sub-space pass, so pre-ref published queries keep binding on a
2315
                  # mixed-version fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2316
                  ?child  npa:isSubSpaceOf ?parent .
2317
                  ?parent npa:hasSubSpace  ?child  .
2318
                  ?tagIri a npa:DerivedSubSpaceLink ;
2319
                          npa:childSpace     ?child ;
2320
                          npa:parentSpace    ?parent ;
2321
                          # Ref endpoints too (issue #125 finding #5), so the sub-space
2322
                          # orphan-sweep recognizes a prefix-derived ref edge as backed and
2323
                          # never deletes it. Derived links have no source nanopub, so they
2324
                          # are never invalidation-deleted; the fallback self-heals each cycle.
2325
                          npa:childSpaceRef  ?childRef ;
2326
                          npa:parentSpaceRef ?parentRef ;
2327
                          npa:derivationKind npa:byUrlPrefix .
2328
                } }
2329
                WHERE {
2330
                  # 1. Anchor: child SpaceRef → its path-prefixes (extracted at load
2331
                  #    time from the Space IRI; see SpacesExtractor.enumerateIdPrefixes).
2332
                  GRAPH <%3$s> {
2333
                    ?childRef  npa:spaceIri    ?child ;
2334
                               npa:hasIdPrefix ?parent .
2335
                    # 2. Parent SpaceRef must exist for the same IRI as the prefix.
2336
                    ?parentRef npa:spaceIri    ?parent .
2337
                  }
2338
                  # 3. Suppress fallback for any child that has a validated declaration
2339
                  #    in this state graph. Per-child IRI, all-or-nothing.
2340
                  FILTER NOT EXISTS {
2341
                    GRAPH <%2$s> {
2342
                      ?d a npa:SubSpaceDeclaration ;
2343
                         npa:childSpace ?child .
2344
                    }
2345
                  }
2346
                  # 4. Mint a deterministic tag IRI per (child ref, parent ref) — the edge
2347
                  #    is emitted ref-to-ref, so the tag and dedup are per ref-pair.
2348
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/derivedlink/",
2349
                                  MD5(CONCAT(STR(?childRef), "|", STR(?parentRef))))) AS ?tagIri)
2350
                  # 5. Dedup: don't re-insert if this tag is already present.
2351
                  FILTER NOT EXISTS {
2352
                    GRAPH <%2$s> {
2353
                      ?tagIri a npa:DerivedSubSpaceLink .
2354
                    }
2355
                  }
2356
                }
2357
                """.formatted(
3✔
2358
                NPA.NAMESPACE,
2359
                graph,
2360
                SpacesVocab.SPACES_GRAPH);
2361
    }
2362

2363
    /**
2364
     * Reflexive governing-space-ref pass (issue #130). For every {@code SpaceRef}
2365
     * aggregate {@code ?spaceRef} (identified by {@code npa:spaceIri ?space} in the
2366
     * extraction graph), emits {@code <space> npa:hasGoverningSpaceRef <spaceRef>} into
2367
     * the space-state graph — the space pointing at its own ref through the same predicate
2368
     * a maintained resource uses to point at its maintaining space's ref (emitted in
2369
     * {@link #maintainedResourceAdmitUpdate}).
2370
     *
2371
     * <p>This removes the zero-hop special case from consumer authority gates: instead of
2372
     * {@code ?resource npa:isMaintainedBy? ?space} (a bare-IRI optional path that breaks
2373
     * once the hop is ref-valued), a consumer does a single mandatory
2374
     * {@code ?resource npa:hasGoverningSpaceRef ?spaceRef} that binds whether the resource
2375
     * is a maintained resource or a space itself. A space IRI claimed by several refs emits
2376
     * one edge per ref — the non-ref consumer variant's merged-across-refs behaviour falls
2377
     * out naturally; the ref variant pins {@code ?passedRef}.
2378
     *
2379
     * <p>Self-healing, like {@link #subSpacePrefixFallbackUpdate}: the edge has no source
2380
     * nanopub (it follows purely from a {@code SpaceRef} existing), so there is no
2381
     * invalidation handling and no load-number filter — always full-scan, with the dedup
2382
     * {@code FILTER NOT EXISTS} on the edge preventing re-insertion. A {@code SpaceRef}
2383
     * disappearing is itself a structural-rebuild event, which clears its reflexive edge.
2384
     */
2385
    static String governingSpaceRefReflexiveUpdate(IRI graph) {
2386
        return """
48✔
2387
                PREFIX npa: <%1$s>
2388
                INSERT { GRAPH <%2$s> {
2389
                  ?space npa:hasGoverningSpaceRef ?spaceRef .
2390
                } }
2391
                WHERE {
2392
                  GRAPH <%3$s> { ?spaceRef npa:spaceIri ?space . }
2393
                  FILTER NOT EXISTS { GRAPH <%2$s> {
2394
                    ?space npa:hasGoverningSpaceRef ?spaceRef .
2395
                  } }
2396
                }
2397
                """.formatted(
3✔
2398
                NPA.NAMESPACE,
2399
                graph,
2400
                SpacesVocab.SPACES_GRAPH);
2401
    }
2402

2403
    // ---------------- Invalidation templates (incremental cycle) ----------------
2404

2405
    /**
2406
     * WHERE clause shared by the admin-RI invalidation ASK precheck and the
2407
     * matching DELETE. Identifies admin-tier {@code gen:RoleInstantiation} rows
2408
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
2409
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
2410
     * has a load number in {@code (lastProcessed, ∞)}.
2411
     */
2412
    static String adminInvalidationCheckWhere(IRI graph, long lastProcessed) {
2413
        return String.format("""
60✔
2414
                  GRAPH <%1$s> {
2415
                    ?ri a gen:RoleInstantiation ;
2416
                        npa:inverseProperty gen:hasAdmin ;
2417
                        npa:viaNanopub ?np .
2418
                  }
2419
                  GRAPH <%2$s> {
2420
                    ?invNp <%3$s> ?np ;
2421
                           npa:hasLoadNumber ?ln .
2422
                    FILTER (?ln > %4$d)
2423
                    %5$s
2424
                  }
2425
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2426
                samePublisherClause("invNp", "np"));
6✔
2427
    }
2428

2429
    /** DELETE template for admin-tier RoleInstantiations whose source nanopub was invalidated. */
2430
    static String adminInvalidationDelete(IRI graph, long lastProcessed) {
2431
        return String.format("""
63✔
2432
                PREFIX npa: <%1$s>
2433
                PREFIX gen: <%2$s>
2434
                DELETE { GRAPH <%3$s> {
2435
                  ?ri ?p ?o .
2436
                } }
2437
                WHERE {
2438
                  GRAPH <%3$s> { ?ri ?p ?o . }
2439
                %4$s
2440
                }
2441
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2442
                adminInvalidationCheckWhere(graph, lastProcessed));
6✔
2443
    }
2444

2445
    /** WHERE clause for RoleAssignment invalidation. */
2446
    static String roleAssignmentInvalidationCheckWhere(IRI graph, long lastProcessed) {
2447
        return String.format("""
60✔
2448
                  GRAPH <%1$s> {
2449
                    ?ra a gen:RoleAssignment ;
2450
                        npa:viaNanopub ?np .
2451
                  }
2452
                  GRAPH <%2$s> {
2453
                    ?invNp <%3$s> ?np ;
2454
                           npa:hasLoadNumber ?ln .
2455
                    FILTER (?ln > %4$d)
2456
                    %5$s
2457
                  }
2458
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2459
                samePublisherClause("invNp", "np"));
6✔
2460
    }
2461

2462
    /** DELETE template for RoleAssignments whose source nanopub was invalidated. */
2463
    static String roleAssignmentInvalidationDelete(IRI graph, long lastProcessed) {
2464
        return String.format("""
63✔
2465
                PREFIX npa: <%1$s>
2466
                PREFIX gen: <%2$s>
2467
                DELETE { GRAPH <%3$s> {
2468
                  ?ra ?p ?o .
2469
                } }
2470
                WHERE {
2471
                  GRAPH <%3$s> { ?ra ?p ?o . }
2472
                %4$s
2473
                }
2474
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2475
                roleAssignmentInvalidationCheckWhere(graph, lastProcessed));
6✔
2476
    }
2477

2478
    /**
2479
     * DELETE template for non-admin (leaf-tier) RoleInstantiations whose source
2480
     * nanopub was invalidated. Identified as {@code gen:RoleInstantiation} rows
2481
     * lacking the admin-pinning {@code npa:inverseProperty gen:hasAdmin} triple.
2482
     * No flag is set; leaf-tier removals are recoverable on the next cycle.
2483
     */
2484
    static String leafTierInvalidationDelete(IRI graph, long lastProcessed) {
2485
        return String.format("""
84✔
2486
                PREFIX npa: <%1$s>
2487
                PREFIX gen: <%2$s>
2488
                DELETE { GRAPH <%3$s> {
2489
                  ?ri ?p ?o .
2490
                } }
2491
                WHERE {
2492
                  GRAPH <%3$s> {
2493
                    ?ri a gen:RoleInstantiation ;
2494
                        npa:viaNanopub ?np .
2495
                    FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
2496
                    ?ri ?p ?o .
2497
                  }
2498
                  GRAPH <%4$s> {
2499
                    ?invNp <%5$s> ?np ;
2500
                           npa:hasLoadNumber ?ln .
2501
                    FILTER (?ln > %6$d)
2502
                    %7$s
2503
                  }
2504
                }
2505
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2506
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2507
                samePublisherClause("invNp", "np"));
6✔
2508
    }
2509

2510
    /**
2511
     * WHERE clause shared by the sub-space invalidation ASK precheck and the
2512
     * matching DELETE. Identifies validated {@code npa:SubSpaceDeclaration} rows
2513
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
2514
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
2515
     * has a load number in {@code (lastProcessed, ∞)}.
2516
     */
2517
    static String subSpaceInvalidationCheckWhere(IRI graph, long lastProcessed) {
2518
        return String.format("""
60✔
2519
                  GRAPH <%1$s> {
2520
                    ?d a npa:SubSpaceDeclaration ;
2521
                       npa:viaNanopub ?np .
2522
                  }
2523
                  GRAPH <%2$s> {
2524
                    ?invNp <%3$s> ?np ;
2525
                           npa:hasLoadNumber ?ln .
2526
                    FILTER (?ln > %4$d)
2527
                    %5$s
2528
                  }
2529
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2530
                samePublisherClause("invNp", "np"));
6✔
2531
    }
2532

2533
    /**
2534
     * DELETE template for validated {@code npa:SubSpaceDeclaration} rows whose
2535
     * source nanopub was invalidated. Removes the per-declaration row by subject;
2536
     * the convenience direct triples ({@code <child> npa:isSubSpaceOf <parent>}
2537
     * and inverse) are then dropped by {@link #subSpaceConvenienceEdgeCleanup} in the
2538
     * same cycle (issue #125 finding #5) once no surviving link backs them.
2539
     */
2540
    static String subSpaceInvalidationDelete(IRI graph, long lastProcessed) {
2541
        return String.format("""
63✔
2542
                PREFIX npa: <%1$s>
2543
                PREFIX gen: <%2$s>
2544
                DELETE { GRAPH <%3$s> {
2545
                  ?d ?p ?o .
2546
                } }
2547
                WHERE {
2548
                  GRAPH <%3$s> { ?d ?p ?o . }
2549
                %4$s
2550
                }
2551
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2552
                subSpaceInvalidationCheckWhere(graph, lastProcessed));
6✔
2553
    }
2554

2555
    /**
2556
     * DELETE template for validated {@code npa:MaintainedResourceDeclaration} rows
2557
     * whose source nanopub was invalidated. Removes the per-declaration row by
2558
     * subject; the convenience direct triples ({@code <r> npa:isMaintainedBy <s>}
2559
     * and inverse) are then dropped by {@link #maintainedResourceConvenienceEdgeCleanup}
2560
     * in the same cycle (issue #125 finding #5). No structural-rebuild flag —
2561
     * maintained-resource is a leaf relation, no downstream consumers depend on its
2562
     * closure, so the prompt edge cleanup fully resolves its invalidation.
2563
     */
2564
    static String maintainedResourceInvalidationDelete(IRI graph, long lastProcessed) {
2565
        return String.format("""
84✔
2566
                PREFIX npa: <%1$s>
2567
                PREFIX gen: <%2$s>
2568
                DELETE { GRAPH <%3$s> {
2569
                  ?d ?p ?o .
2570
                } }
2571
                WHERE {
2572
                  GRAPH <%3$s> {
2573
                    ?d a npa:MaintainedResourceDeclaration ;
2574
                       npa:viaNanopub ?np .
2575
                    ?d ?p ?o .
2576
                  }
2577
                  GRAPH <%4$s> {
2578
                    ?invNp <%5$s> ?np ;
2579
                           npa:hasLoadNumber ?ln .
2580
                    FILTER (?ln > %6$d)
2581
                    %7$s
2582
                  }
2583
                }
2584
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2585
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2586
                samePublisherClause("invNp", "np"));
6✔
2587
    }
2588

2589
    /**
2590
     * WHERE clause shared by the alias invalidation ASK precheck and the matching
2591
     * DELETE. Identifies validated {@code npa:SpaceAliasDeclaration} rows in the
2592
     * space-state graph whose {@code npa:viaNanopub} is the target of an
2593
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
2594
     * load number in {@code (lastProcessed, ∞)}.
2595
     */
2596
    static String aliasInvalidationCheckWhere(IRI graph, long lastProcessed) {
2597
        return String.format("""
60✔
2598
                  GRAPH <%1$s> {
2599
                    ?d a npa:SpaceAliasDeclaration ;
2600
                       npa:viaNanopub ?np .
2601
                  }
2602
                  GRAPH <%2$s> {
2603
                    ?invNp <%3$s> ?np ;
2604
                           npa:hasLoadNumber ?ln .
2605
                    FILTER (?ln > %4$d)
2606
                    %5$s
2607
                  }
2608
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2609
                samePublisherClause("invNp", "np"));
6✔
2610
    }
2611

2612
    /**
2613
     * DELETE template for validated {@code npa:SpaceAliasDeclaration} rows whose
2614
     * source nanopub was invalidated. Removes the per-declaration row by subject; the
2615
     * convenience {@code <alias> npa:sameAsSpace <canonical>} edge is then dropped by
2616
     * {@link #aliasConvenienceEdgeCleanup} in the same cycle (issue #125 finding #5),
2617
     * so an alias can no longer grant admin authority after its declaration is retracted.
2618
     * The alias feeds the authority closure, so this kind is still structural and flips
2619
     * {@code npa:needsFullRebuild} to bound any rows already derived through the edge.
2620
     */
2621
    static String aliasInvalidationDelete(IRI graph, long lastProcessed) {
2622
        return String.format("""
63✔
2623
                PREFIX npa: <%1$s>
2624
                PREFIX gen: <%2$s>
2625
                DELETE { GRAPH <%3$s> {
2626
                  ?d ?p ?o .
2627
                } }
2628
                WHERE {
2629
                  GRAPH <%3$s> { ?d ?p ?o . }
2630
                %4$s
2631
                }
2632
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2633
                aliasInvalidationCheckWhere(graph, lastProcessed));
6✔
2634
    }
2635

2636
    /**
2637
     * WHERE clause shared by the maintained-resource invalidation ASK precheck and the
2638
     * matching cleanup. Identifies validated {@code npa:MaintainedResourceDeclaration}
2639
     * rows in the 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 maintainedResourceInvalidationCheckWhere(IRI graph, long lastProcessed) {
2644
        return String.format("""
×
2645
                  GRAPH <%1$s> {
2646
                    ?d a npa:MaintainedResourceDeclaration ;
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,
×
2656
                samePublisherClause("invNp", "np"));
×
2657
    }
2658

2659
    /**
2660
     * Convenience-edge cleanup for invalidated sub-space declarations (issue #125
2661
     * finding #5). Run after {@link #subSpaceInvalidationDelete} (which removes the
2662
     * {@code npa:SubSpaceDeclaration} rows). Two phases as one multi-operation update:
2663
     * <ol>
2664
     *   <li>delete every {@code npa:SubSpaceLink} provenance link whose
2665
     *       {@code npa:viaNanopub} was invalidated (same {@code npx:invalidates} +
2666
     *       same-publisher gate as the declaration delete);</li>
2667
     *   <li>orphan-sweep: delete the convenience {@code npa:isSubSpaceOf} /
2668
     *       {@code npa:hasSubSpace} edges (both ref- and IRI-valued) that no surviving
2669
     *       link backs — neither a {@code npa:SubSpaceLink} (explicit declaration) nor a
2670
     *       {@code npa:DerivedSubSpaceLink} (URL-prefix fallback).</li>
2671
     * </ol>
2672
     * Edges backed by another surviving declaration or by the URL-prefix fallback are
2673
     * kept. The {@code npa:needsFullRebuild} flag still fires for the structural kind, so
2674
     * downstream rows derived through a removed edge remain rebuild-bounded; this only
2675
     * stops the convenience edges themselves from going sticky.
2676
     */
2677
    static String subSpaceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2678
        return String.format("""
72✔
2679
                PREFIX npa: <%1$s>
2680
                # 1. Drop sub-space provenance links whose source nanopub was invalidated.
2681
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2682
                WHERE {
2683
                  GRAPH <%2$s> {
2684
                    ?l a npa:SubSpaceLink ;
2685
                       npa:viaNanopub ?np .
2686
                    ?l ?p ?o .
2687
                  }
2688
                  GRAPH <%3$s> {
2689
                    ?invNp <%4$s> ?np ;
2690
                           npa:hasLoadNumber ?ln .
2691
                    FILTER (?ln > %5$d)
2692
                    %6$s
2693
                  }
2694
                } ;
2695
                # 2. Orphan-sweep isSubSpaceOf edges (ref- and IRI-valued) with no backing link.
2696
                DELETE { GRAPH <%2$s> { ?c npa:isSubSpaceOf ?p . } }
2697
                WHERE {
2698
                  GRAPH <%2$s> {
2699
                    ?c npa:isSubSpaceOf ?p .
2700
                    FILTER NOT EXISTS {
2701
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2702
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2703
                        UNION
2704
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2705
                    }
2706
                  }
2707
                } ;
2708
                # 3. Orphan-sweep the inverse hasSubSpace edges symmetrically.
2709
                DELETE { GRAPH <%2$s> { ?p npa:hasSubSpace ?c . } }
2710
                WHERE {
2711
                  GRAPH <%2$s> {
2712
                    ?p npa:hasSubSpace ?c .
2713
                    FILTER NOT EXISTS {
2714
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2715
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2716
                        UNION
2717
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2718
                    }
2719
                  }
2720
                }
2721
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2722
                samePublisherClause("invNp", "np"));
6✔
2723
    }
2724

2725
    /**
2726
     * Convenience-edge cleanup for invalidated maintained-resource declarations (issue
2727
     * #125 finding #5). Run after {@link #maintainedResourceInvalidationDelete}. Deletes
2728
     * the {@code npa:MaintainedResourceLink} provenance links whose source nanopub was
2729
     * invalidated, then orphan-sweeps the {@code npa:isMaintainedBy} /
2730
     * {@code npa:hasMaintainedResource} edges (ref- and IRI-valued) that no surviving link
2731
     * backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase structure.
2732
     */
2733
    static String maintainedResourceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2734
        return String.format("""
72✔
2735
                PREFIX npa: <%1$s>
2736
                # 1. Drop maintained-resource provenance links whose source nanopub was invalidated.
2737
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2738
                WHERE {
2739
                  GRAPH <%2$s> {
2740
                    ?l a npa:MaintainedResourceLink ;
2741
                       npa:viaNanopub ?np .
2742
                    ?l ?p ?o .
2743
                  }
2744
                  GRAPH <%3$s> {
2745
                    ?invNp <%4$s> ?np ;
2746
                           npa:hasLoadNumber ?ln .
2747
                    FILTER (?ln > %5$d)
2748
                    %6$s
2749
                  }
2750
                } ;
2751
                # 2. Orphan-sweep isMaintainedBy edges (ref- and IRI-valued) with no backing link.
2752
                DELETE { GRAPH <%2$s> { ?r npa:isMaintainedBy ?o . } }
2753
                WHERE {
2754
                  GRAPH <%2$s> {
2755
                    ?r npa:isMaintainedBy ?o .
2756
                    FILTER NOT EXISTS {
2757
                      ?l a npa:MaintainedResourceLink ;
2758
                         npa:resourceIri ?r .
2759
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2760
                    }
2761
                  }
2762
                } ;
2763
                # 3. Orphan-sweep the inverse hasMaintainedResource edges symmetrically.
2764
                DELETE { GRAPH <%2$s> { ?o npa:hasMaintainedResource ?r . } }
2765
                WHERE {
2766
                  GRAPH <%2$s> {
2767
                    ?o npa:hasMaintainedResource ?r .
2768
                    FILTER NOT EXISTS {
2769
                      ?l a npa:MaintainedResourceLink ;
2770
                         npa:resourceIri ?r .
2771
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2772
                    }
2773
                  }
2774
                } ;
2775
                # 4. Orphan-sweep the maintained arm of hasGoverningSpaceRef (issue #130).
2776
                #    Only the ref-valued maintained edge is removed here — it is backed by a
2777
                #    MaintainedResourceLink. The reflexive space self-edge (subject = a space
2778
                #    IRI that has its own SpaceRef) is NOT a maintained edge and is left to the
2779
                #    self-healing reflexive pass, so the guard keeps any ?r that is itself a space.
2780
                DELETE { GRAPH <%2$s> { ?r npa:hasGoverningSpaceRef ?o . } }
2781
                WHERE {
2782
                  GRAPH <%2$s> {
2783
                    ?r npa:hasGoverningSpaceRef ?o .
2784
                    FILTER NOT EXISTS {
2785
                      ?l a npa:MaintainedResourceLink ;
2786
                         npa:resourceIri ?r ;
2787
                         npa:maintainerSpaceRef ?o .
2788
                    }
2789
                    FILTER NOT EXISTS { GRAPH <%7$s> { ?o npa:spaceIri ?r . } }
2790
                  }
2791
                }
2792
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2793
                samePublisherClause("invNp", "np"), SpacesVocab.SPACES_GRAPH);
18✔
2794
    }
2795

2796
    /**
2797
     * Convenience-edge cleanup for invalidated space-alias declarations (issue #125
2798
     * finding #5 — the load-bearing case, since the alias edge feeds the admin-authority
2799
     * closure). Run after {@link #aliasInvalidationDelete}. Deletes the
2800
     * {@code npa:SpaceAliasLink} provenance links whose source nanopub was invalidated,
2801
     * then orphan-sweeps the {@code npa:sameAsSpace} edges (ref- and IRI-valued) that no
2802
     * surviving link backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase
2803
     * structure.
2804
     */
2805
    static String aliasConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2806
        return String.format("""
72✔
2807
                PREFIX npa: <%1$s>
2808
                # 1. Drop alias provenance links whose source nanopub was invalidated.
2809
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2810
                WHERE {
2811
                  GRAPH <%2$s> {
2812
                    ?l a npa:SpaceAliasLink ;
2813
                       npa:viaNanopub ?np .
2814
                    ?l ?p ?o .
2815
                  }
2816
                  GRAPH <%3$s> {
2817
                    ?invNp <%4$s> ?np ;
2818
                           npa:hasLoadNumber ?ln .
2819
                    FILTER (?ln > %5$d)
2820
                    %6$s
2821
                  }
2822
                } ;
2823
                # 2. Orphan-sweep sameAsSpace edges (ref- and IRI-valued) with no backing link.
2824
                DELETE { GRAPH <%2$s> { ?alias npa:sameAsSpace ?o . } }
2825
                WHERE {
2826
                  GRAPH <%2$s> {
2827
                    ?alias npa:sameAsSpace ?o .
2828
                    FILTER NOT EXISTS {
2829
                      ?l a npa:SpaceAliasLink ;
2830
                         npa:aliasSpace ?alias .
2831
                      { ?l npa:canonicalSpaceRef ?o } UNION { ?l npa:canonicalSpace ?o }
2832
                    }
2833
                  }
2834
                }
2835
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2836
                samePublisherClause("invNp", "np"));
6✔
2837
    }
2838

2839
    /**
2840
     * WHERE clause shared by the preset-deactivation ASK precheck and the matching DELETE
2841
     * (Nanodash issue #302). Binds {@code ?ra} = a materialized preset-derived
2842
     * {@code gen:RoleAssignment} ({@code npa:derivedFromPreset}) for which a <em>newer,
2843
     * admin-authored</em> same-{@code (preset, resource)} assignment exists by
2844
     * {@code dct:created} (load number in {@code (lastProcessed, ∞)}). This is NOT an
2845
     * {@code npx:invalidates} check — preset activation is latest-wins by timestamp.
2846
     *
2847
     * <p>Authorization-scoped (anti-hijack, design doc §3/§4.4): the newer assignment's
2848
     * publisher must itself be a validated admin of the row's {@code npa:forSpaceRef}, so an
2849
     * unauthorized key's newer assignment can neither delete nor shadow an admin's
2850
     * materialized role. {@code dct:created} is written as a full IRI (not a {@code dct:}
2851
     * prefix) because {@link #wouldInvalidate}'s ASK wrapper only declares {@code npa:} /
2852
     * {@code gen:}.
2853
     */
2854
    static String presetDeactivationCheckWhere(IRI graph, long lastProcessed) {
2855
        return String.format("""
60✔
2856
                  GRAPH <%1$s> {
2857
                    ?ra a gen:RoleAssignment ;
2858
                        npa:derivedFromPreset ?assignNp ;
2859
                        npa:forSpaceRef ?targetRef .
2860
                  }
2861
                  GRAPH <%2$s> {
2862
                    ?pa a npa:PresetAssignment ;
2863
                        npa:viaNanopub  ?assignNp ;
2864
                        npa:ofPreset    ?preset ;
2865
                        npa:forResource ?resource ;
2866
                        <http://purl.org/dc/terms/created> ?created .
2867
                    ?paNewer a npa:PresetAssignment ;
2868
                             npa:ofPreset    ?preset ;
2869
                             npa:forResource ?resource ;
2870
                             npa:pubkeyHash  ?pkhNewer ;
2871
                             npa:viaNanopub  ?assignNpNewer ;
2872
                             <http://purl.org/dc/terms/created> ?createdNewer .
2873
                    FILTER (?createdNewer > ?created
2874
                            || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
2875
                  }
2876
                  GRAPH <%3$s> {
2877
                    ?assignNpNewer npa:hasLoadNumber ?lnNewer .
2878
                    FILTER (?lnNewer > %4$d)
2879
                  }
2880
                  GRAPH <%1$s> {
2881
                    ?acctNewer a npa:AccountState ;
2882
                               npa:agent  ?publisherNewer ;
2883
                               npa:pubkey ?pkhNewer .
2884
                    ?adminRINewer a gen:RoleInstantiation ;
2885
                                  npa:forSpaceRef ?targetRef ;
2886
                                  npa:inverseProperty gen:hasAdmin ;
2887
                                  npa:forAgent ?publisherNewer .
2888
                  }
2889
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
2890
    }
2891

2892
    /**
2893
     * DELETE template for preset-derived {@code gen:RoleAssignment} rows superseded by a
2894
     * newer admin-authored same-pair assignment (issue #302). Removes the whole row by
2895
     * subject; scoped via {@code npa:derivedFromPreset} so directly-published attachments
2896
     * are never touched. The {@link #presetAttachmentValidationUpdate} re-INSERT in the
2897
     * same cycle re-materializes the pair iff the newest assignment is still active.
2898
     */
2899
    static String presetDeactivationDelete(IRI graph, long lastProcessed) {
2900
        return String.format("""
63✔
2901
                PREFIX npa: <%1$s>
2902
                PREFIX gen: <%2$s>
2903
                DELETE { GRAPH <%3$s> {
2904
                  ?ra ?p ?o .
2905
                } }
2906
                WHERE {
2907
                  GRAPH <%3$s> { ?ra ?p ?o . }
2908
                %4$s
2909
                }
2910
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2911
                presetDeactivationCheckWhere(graph, lastProcessed));
6✔
2912
    }
2913

2914
    /**
2915
     * WHERE clause matching a materialized <em>non-admin</em> {@code gen:RoleInstantiation}
2916
     * row whose {@code (forSpaceRef, forAgent, gen:hasRole)} key is shadowed by a newer
2917
     * authorized {@code npa:RoleRevocation} (issue #129). The grant timestamp comes from the
2918
     * originating instantiation in the extraction graph (the materialized row carries no
2919
     * {@code dct:created}); the revocation nanopub's load number must be in
2920
     * {@code (lastProcessed, ∞)} so only revocations new in this cycle trigger a delete.
2921
     * Authorization is keyed on the row's bound {@code ?tier} (the matrix: a strictly-higher
2922
     * tier in the ref, or self). Not an {@code npx:invalidates} check.
2923
     */
2924
    static String roleRevocationCheckWhere(IRI graph, long lastProcessed, IRI targetTier) {
2925
        return String.format("""
60✔
2926
                  GRAPH <%1$s> {
2927
                    ?ri2 a gen:RoleInstantiation ;
2928
                         npa:forSpaceRef ?spaceRef ;
2929
                         npa:forAgent    ?agent ;
2930
                         gen:hasRole     ?role ;
2931
                         npa:hasRoleType <%7$s> ;
2932
                         npa:viaNanopub  ?np .
2933
                  }
2934
                  OPTIONAL { GRAPH <%2$s> {
2935
                    ?riSrc npa:viaNanopub ?np ;
2936
                           <http://purl.org/dc/terms/created> ?candCreatedRaw .
2937
                  } }
2938
                  BIND(COALESCE(?candCreatedRaw, %5$s) AS ?candCreated)
2939
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
2940
                  UNION
2941
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
2942
                  GRAPH <%2$s> {
2943
                    ?rev a npa:RoleRevocation ;
2944
                         npa:forSpace    ?revSpace ;
2945
                         npa:forAgent    ?agent ;
2946
                         npa:revokedRole ?role ;
2947
                         npa:pubkeyHash  ?revPkh ;
2948
                         npa:viaNanopub  ?revNp .
2949
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
2950
                  }
2951
                  BIND(COALESCE(?revCreatedRaw, %5$s) AS ?revCreated)
2952
                  GRAPH <%3$s> {
2953
                    ?revNp npa:hasLoadNumber ?lnRev .
2954
                    FILTER (?lnRev > %4$d)
2955
                  }
2956
                  FILTER (?revCreated > ?candCreated
2957
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri2)))
2958
                  { %6$s }
2959
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed,
30✔
2960
                EPOCH_DT, revocationAuthorityArmsForTier(graph, targetTier), targetTier);
18✔
2961
    }
2962

2963
    /**
2964
     * DELETE template removing a non-admin {@code gen:RoleInstantiation} row of {@code
2965
     * targetTier} shadowed by a newer authorized revocation (issue #129). Removes the whole
2966
     * row by subject. Run once per non-admin tier (maintainer/member/observer) so the
2967
     * authorization arms are the compile-time set for that tier — matching the inline
2968
     * suppression filter, no runtime {@code ?tier} (see {@link #revocationAuthorityArmsForTier}).
2969
     * Caller sets {@code needsFullRebuild} (a revoked maintainer/member is a sub-granting
2970
     * authority).
2971
     */
2972
    static String roleRevocationDelete(IRI graph, long lastProcessed, IRI targetTier) {
2973
        return String.format("""
66✔
2974
                PREFIX npa: <%1$s>
2975
                PREFIX gen: <%2$s>
2976
                DELETE { GRAPH <%3$s> {
2977
                  ?ri2 ?p ?o .
2978
                } }
2979
                WHERE {
2980
                  GRAPH <%3$s> { ?ri2 ?p ?o . }
2981
                %4$s
2982
                }
2983
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2984
                roleRevocationCheckWhere(graph, lastProcessed, targetTier));
6✔
2985
    }
2986

2987
    /**
2988
     * WHERE clause matching a materialized <em>admin</em> {@code gen:RoleInstantiation} row
2989
     * whose {@code (forSpaceRef, forAgent)} key is shadowed by a newer authorized admin
2990
     * {@code npa:RoleRevocation} ({@code revokedRole = gen:AdminRole}), authorized by an
2991
     * admin of the ref or by the agent itself. <b>Root admins are exempt</b> (constitutional,
2992
     * issue #129/#110): the nested {@code FILTER NOT EXISTS} on {@code npa:hasRootAdmin}
2993
     * makes the revocation inert. The revocation nanopub's load number must be in
2994
     * {@code (lastProcessed, ∞)}.
2995
     */
2996
    static String adminRevocationCheckWhere(IRI graph, long lastProcessed) {
2997
        return String.format("""
60✔
2998
                  GRAPH <%1$s> {
2999
                    ?sri a gen:RoleInstantiation ;
3000
                         npa:forSpaceRef     ?spaceRef ;
3001
                         npa:inverseProperty gen:hasAdmin ;
3002
                         npa:forAgent        ?agent ;
3003
                         npa:viaNanopub      ?np .
3004
                  }
3005
                  FILTER NOT EXISTS { GRAPH <%2$s> {
3006
                    ?rootDef a npa:SpaceDefinition ;
3007
                             npa:forSpaceRef  ?spaceRef ;
3008
                             npa:hasRootAdmin ?agent .
3009
                  } }
3010
                  OPTIONAL { GRAPH <%2$s> {
3011
                    ?riSrc npa:viaNanopub ?np ;
3012
                           <http://purl.org/dc/terms/created> ?candCreatedRaw .
3013
                  } }
3014
                  BIND(COALESCE(?candCreatedRaw, %5$s) AS ?candCreated)
3015
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
3016
                  UNION
3017
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
3018
                  GRAPH <%2$s> {
3019
                    ?rev a npa:RoleRevocation ;
3020
                         npa:forSpace    ?revSpace ;
3021
                         npa:forAgent    ?agent ;
3022
                         npa:revokedRole gen:AdminRole ;
3023
                         npa:pubkeyHash  ?revPkh ;
3024
                         npa:viaNanopub  ?revNp .
3025
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
3026
                  }
3027
                  BIND(COALESCE(?revCreatedRaw, %5$s) AS ?revCreated)
3028
                  GRAPH <%3$s> {
3029
                    ?revNp npa:hasLoadNumber ?lnRev .
3030
                    FILTER (?lnRev > %4$d)
3031
                  }
3032
                  FILTER (?revCreated > ?candCreated
3033
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?sri)))
3034
                  { %6$s }
3035
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed, EPOCH_DT,
27✔
3036
                "{ " + revokerAdminGraphBlock(graph) + " }\nUNION\n{ "
6✔
3037
                        + revokerSelfGraphBlock(graph) + " }");
9✔
3038
    }
3039

3040
    /**
3041
     * DELETE template removing an admin {@code gen:RoleInstantiation} row shadowed by a newer
3042
     * authorized admin revocation (issue #129). Removes the whole row by subject.
3043
     * <b>Structural</b> — admin RIs feed every downstream tier — so the caller sets
3044
     * {@code npa:needsFullRebuild} (mirrors {@code adminInvalidationDelete}). The
3045
     * {@code adminTierUpdate} inline suppression filter prevents re-materialization.
3046
     */
3047
    static String adminRevocationDelete(IRI graph, long lastProcessed) {
3048
        return String.format("""
63✔
3049
                PREFIX npa: <%1$s>
3050
                PREFIX gen: <%2$s>
3051
                DELETE { GRAPH <%3$s> {
3052
                  ?sri ?p ?o .
3053
                } }
3054
                WHERE {
3055
                  GRAPH <%3$s> { ?sri ?p ?o . }
3056
                %4$s
3057
                }
3058
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3059
                adminRevocationCheckWhere(graph, lastProcessed));
6✔
3060
    }
3061

3062
    /**
3063
     * WHERE clause matching a materialized {@code gen:RoleAssignment} row (direct
3064
     * <em>or</em> preset-derived) whose {@code (forSpaceRef, gen:hasRole)} key is shadowed by
3065
     * a newer admin-authored {@code npa:RoleDetachment} (issue #129). The attachment
3066
     * timestamp comes from whichever extraction row shares the materialized row's
3067
     * {@code npa:viaNanopub} (a {@code RoleAssignment} for direct attachments, a
3068
     * {@code PresetAssignment} for preset-derived ones). The detachment nanopub's load number
3069
     * must be in {@code (lastProcessed, ∞)}; authority = admin of the ref.
3070
     */
3071
    static String roleDetachmentCheckWhere(IRI graph, long lastProcessed) {
3072
        return String.format("""
60✔
3073
                  GRAPH <%1$s> {
3074
                    ?ra2 a gen:RoleAssignment ;
3075
                         npa:forSpaceRef ?targetRef ;
3076
                         gen:hasRole     ?role ;
3077
                         npa:viaNanopub  ?np .
3078
                  }
3079
                  OPTIONAL { GRAPH <%2$s> {
3080
                    ?attSrc npa:viaNanopub ?np ;
3081
                            <http://purl.org/dc/terms/created> ?attCreatedRaw .
3082
                  } }
3083
                  BIND(COALESCE(?attCreatedRaw, %5$s) AS ?attCreated)
3084
                  { GRAPH <%2$s> { ?targetRef npa:spaceIri ?detSpace . } }
3085
                  UNION
3086
                  { GRAPH <%1$s> { ?detSpace npa:sameAsSpace ?targetRef . } }
3087
                  GRAPH <%2$s> {
3088
                    ?det a npa:RoleDetachment ;
3089
                         npa:forSpace    ?detSpace ;
3090
                         npa:revokedRole ?role ;
3091
                         npa:pubkeyHash  ?detPkh ;
3092
                         npa:viaNanopub  ?detNp .
3093
                    OPTIONAL { ?det <http://purl.org/dc/terms/created> ?detCreatedRaw . }
3094
                  }
3095
                  BIND(COALESCE(?detCreatedRaw, %5$s) AS ?detCreated)
3096
                  GRAPH <%3$s> {
3097
                    ?detNp npa:hasLoadNumber ?lnDet .
3098
                    FILTER (?lnDet > %4$d)
3099
                  }
3100
                  FILTER (?detCreated > ?attCreated
3101
                          || (?detCreated = ?attCreated && STR(?det) > STR(?ra2)))
3102
                  GRAPH <%1$s> {
3103
                    ?detAcct a npa:AccountState ; npa:pubkey ?detPkh ; npa:agent ?detAgent .
3104
                    ?detAdminRI a gen:RoleInstantiation ;
3105
                                npa:forSpaceRef ?targetRef ;
3106
                                npa:inverseProperty gen:hasAdmin ;
3107
                                npa:forAgent ?detAgent .
3108
                  }
3109
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed, EPOCH_DT);
18✔
3110
    }
3111

3112
    /**
3113
     * DELETE template removing a {@code gen:RoleAssignment} row (direct or preset-derived)
3114
     * shadowed by a newer admin-authored {@code gen:detachedRole} (issue #129). Removes the
3115
     * whole row by subject. <b>Structural</b> — instantiations anchored on the removed
3116
     * attachment are bounded by the periodic full rebuild (the cascade), so the caller sets
3117
     * {@code npa:needsFullRebuild}. The attachment-tier inline filters prevent
3118
     * re-materialization until a newer attachment / preset assignment out-ranks the detach
3119
     * (non-sticky).
3120
     */
3121
    static String roleDetachmentDelete(IRI graph, long lastProcessed) {
3122
        return String.format("""
63✔
3123
                PREFIX npa: <%1$s>
3124
                PREFIX gen: <%2$s>
3125
                DELETE { GRAPH <%3$s> {
3126
                  ?ra2 ?p ?o .
3127
                } }
3128
                WHERE {
3129
                  GRAPH <%3$s> { ?ra2 ?p ?o . }
3130
                %4$s
3131
                }
3132
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3133
                roleDetachmentCheckWhere(graph, lastProcessed));
6✔
3134
    }
3135

3136
    /**
3137
     * DELETE template for ref-scoped preset-assignment stamps ({@link
3138
     * #presetAssignmentRefStampUpdate}) whose underlying assignment nanopub was
3139
     * hard-retracted (issue #122). Removes the whole row by subject; scoped to
3140
     * state-graph {@code npa:PresetAssignment} rows that carry {@code npa:forSpaceRef}
3141
     * (the IRI-keyed extraction rows never do), so it can never touch them.
3142
     *
3143
     * <p>Leaf delete — no structural flag: nothing downstream derives from a listing
3144
     * stamp, so a stale row only mis-displays a retracted assignment until this cycle's
3145
     * delete runs. Admin-grant revocation is bounded by the periodic full rebuild (same
3146
     * sticky-convenience policy as the alias / sub-space declaration edges). A
3147
     * <em>deactivation</em> needs no delete here: it is represented as a newer
3148
     * admin-authored stamp with {@code npa:isActivated false}, resolved by the consumer's
3149
     * latest-wins.
3150
     */
3151
    static String presetAssignmentRefInvalidationDelete(IRI graph, long lastProcessed) {
3152
        return String.format("""
84✔
3153
                PREFIX npa: <%1$s>
3154
                PREFIX gen: <%2$s>
3155
                DELETE { GRAPH <%3$s> {
3156
                  ?paRef ?p ?o .
3157
                } }
3158
                WHERE {
3159
                  GRAPH <%3$s> {
3160
                    ?paRef a npa:PresetAssignment ;
3161
                           npa:forSpaceRef ?targetRef ;
3162
                           npa:viaNanopub  ?assignNp .
3163
                    ?paRef ?p ?o .
3164
                  }
3165
                  GRAPH <%4$s> {
3166
                    ?invNp <%5$s> ?assignNp ;
3167
                           npa:hasLoadNumber ?ln .
3168
                    FILTER (?ln > %6$d)
3169
                    %7$s
3170
                  }
3171
                }
3172
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3173
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
3174
                samePublisherClause("invNp", "assignNp"));
6✔
3175
    }
3176

3177
    /** Wraps an ASK by joining the shared prefixes. */
3178
    private boolean wouldInvalidate(IRI graph, long lastProcessed,
3179
                                    boolean adminPinned, String whereClause) {
3180
        // adminPinned is informational only — kept to make call sites read clearly;
3181
        // the WHERE clause already encodes the kind via its own type predicates.
3182
        String ask = String.format("""
×
3183
                PREFIX npa: <%1$s>
3184
                PREFIX gen: <%2$s>
3185
                ASK { %3$s }
3186
                """, NPA.NAMESPACE, GEN.NAMESPACE, whereClause);
3187
        return runAsk(ask);
×
3188
    }
3189

3190
    private boolean runAsk(String sparql) {
3191
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3192
            return conn.prepareBooleanQuery(QueryLanguage.SPARQL, sparql).evaluate();
×
3193
        }
3194
    }
3195

3196
    private void executeUpdate(String sparqlUpdate) {
3197
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3198
            conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
×
3199
        }
3200
    }
×
3201

3202
    // ---------------- Mirror step ----------------
3203

3204
    /**
3205
     * Copies trust-approved {@code npa:AccountState} rows from {@code npat:<T>}
3206
     * in the {@code trust} repo into {@code newGraph} in the {@code spaces} repo,
3207
     * inside one spaces-side serializable transaction.
3208
     *
3209
     * @return number of rows mirrored (useful for metrics / logging)
3210
     */
3211
    int mirrorTrustState(String trustStateHash, IRI newGraph) {
3212
        IRI trustStateIri = NPAT.forHash(trustStateHash);
×
3213
        int count = 0;
×
3214
        try (RepositoryConnection trustConn = TripleStore.get().getRepoConnection(TRUST_REPO);
×
3215
             RepositoryConnection spacesConn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3216
            trustConn.begin(IsolationLevels.READ_COMMITTED);
×
3217
            spacesConn.begin(IsolationLevels.SERIALIZABLE);
×
3218
            // Walk rdf:type triples in the trust state's graph; for each AccountState,
3219
            // check status and copy the approved ones verbatim (minus status-specific
3220
            // detail triples, which we don't need for validation).
3221
            try (RepositoryResult<Statement> typeRows = trustConn.getStatements(
×
3222
                    null, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri)) {
3223
                while (typeRows.hasNext()) {
×
3224
                    Statement st = typeRows.next();
×
3225
                    if (!(st.getSubject() instanceof IRI accountStateIri)) continue;
×
3226
                    Value status = trustConn.getStatements(accountStateIri, NPA_TRUST_STATUS, null, trustStateIri)
×
3227
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
3228
                    if (!(status instanceof IRI statusIri) || !APPROVED_SET.contains(statusIri)) continue;
×
3229
                    Value agent = trustConn.getStatements(accountStateIri, NPA_AGENT, null, trustStateIri)
×
3230
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
3231
                    Value pubkey = trustConn.getStatements(accountStateIri, NPA_PUBKEY, null, trustStateIri)
×
3232
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
3233
                    if (agent == null || pubkey == null) {
×
3234
                        logger.warn("AuthorityResolver.mirror: account {} missing agent or pubkey; skipping",
×
3235
                                accountStateIri);
3236
                        continue;
×
3237
                    }
3238
                    spacesConn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, newGraph);
×
3239
                    spacesConn.add(accountStateIri, NPA_AGENT, agent, newGraph);
×
3240
                    spacesConn.add(accountStateIri, NPA_PUBKEY, pubkey, newGraph);
×
3241
                    spacesConn.add(accountStateIri, NPA_TRUST_STATUS, statusIri, newGraph);
×
3242
                    // Mirror the authorizing introduction provenance when present (issue #125
3243
                    // finding #4). Optional: absent for snapshots from registries that predate
3244
                    // nanopub-registry#117/#118, so consumers (e.g. get-space-members-ref) must
3245
                    // treat npa:viaNanopub on an AccountState as best-effort, not guaranteed.
3246
                    Value viaNanopub = trustConn.getStatements(accountStateIri, NPA_VIA_NANOPUB, null, trustStateIri)
×
3247
                            .stream().findFirst().map(Statement::getObject).orElse(null);
×
3248
                    if (viaNanopub != null) {
×
3249
                        spacesConn.add(accountStateIri, NPA_VIA_NANOPUB, viaNanopub, newGraph);
×
3250
                    }
3251
                    count++;
×
3252
                }
×
3253
            }
3254
            // Mirror canonical foaf:name triples for approved agents. The trust
3255
            // loader emits one per agent (across approved keys, MAX(ratio) wins).
3256
            // Copying them into the space-state graph means consumers reading
3257
            // ?agent foaf:name ?n inside the state graph hit local data, with no
3258
            // cross-repo SERVICE.
3259
            try (RepositoryResult<Statement> nameRows = trustConn.getStatements(
×
3260
                    null, FOAF.NAME, null, trustStateIri)) {
3261
                while (nameRows.hasNext()) {
×
3262
                    Statement st = nameRows.next();
×
3263
                    spacesConn.add(st.getSubject(), st.getPredicate(), st.getObject(), newGraph);
×
3264
                }
×
3265
            }
3266
            spacesConn.commit();
×
3267
            trustConn.commit();
×
3268
        }
3269
        return count;
×
3270
    }
3271

3272
    // ---------------- Pointer + counter helpers ----------------
3273

3274
    /**
3275
     * Reads the current {@code npa:hasCurrentSpaceState} pointer from the
3276
     * {@code npa:graph} admin graph of the {@code spaces} repo. Returns
3277
     * {@code null} if no pointer exists yet.
3278
     */
3279
    IRI getCurrentSpaceStateGraph() {
3280
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3281
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
3282
                    SpacesVocab.HAS_CURRENT_SPACE_STATE);
3283
            return (v instanceof IRI iri) ? iri : null;
×
3284
        } catch (Exception ex) {
3✔
3285
            throw new SpaceStateUnavailableException("failed to read hasCurrentSpaceState pointer", ex);
18✔
3286
        }
3287
    }
3288

3289
    long getCurrentLoadCounter() {
3290
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3291
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
3292
                    SpacesVocab.CURRENT_LOAD_COUNTER);
3293
            if (v == null) return 0;
×
3294
            try {
3295
                return Long.parseLong(v.stringValue());
×
3296
            } catch (NumberFormatException ex) {
×
3297
                // Was "return 0", which would name the new graph <hash>_0 and make it
3298
                // differ from the real current graph — so the build proceeded and then
3299
                // dropped the good one. Corrupt bookkeeping must stop the build.
3300
                throw new SpaceStateUnavailableException("non-numeric currentLoadCounter: " + v, ex);
×
3301
            }
3302
        } catch (SpaceStateUnavailableException ex) {
×
3303
            throw ex;
×
3304
        } catch (Exception ex) {
3✔
3305
            throw new SpaceStateUnavailableException("failed to read currentLoadCounter", ex);
18✔
3306
        }
3307
    }
3308

3309
    /**
3310
     * Atomic pointer flip: a single SPARQL {@code DELETE … INSERT … WHERE}
3311
     * replaces the old pointer with the new one in one statement, so readers
3312
     * never see a zero-pointer window.
3313
     */
3314
    void flipPointer(IRI newGraph) {
3315
        String update = String.format("""
×
3316
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3317
                INSERT { GRAPH <%s> { <%s> <%s> <%s> } }
3318
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3319
                """,
3320
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE,
3321
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE, newGraph,
3322
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE);
3323
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3324
            conn.begin(IsolationLevels.SERIALIZABLE);
×
3325
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
3326
            conn.commit();
×
3327
        }
3328
    }
×
3329

3330
    void writeProcessedUpTo(IRI graph, long loadCounter) {
3331
        String update = String.format("""
×
3332
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3333
                INSERT { GRAPH <%s> { <%s> <%s> "%d"^^<http://www.w3.org/2001/XMLSchema#long> } }
3334
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3335
                """,
3336
                graph, graph, SpacesVocab.PROCESSED_UP_TO,
3337
                graph, graph, SpacesVocab.PROCESSED_UP_TO, loadCounter,
×
3338
                graph, graph, SpacesVocab.PROCESSED_UP_TO);
3339
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3340
            conn.begin(IsolationLevels.SERIALIZABLE);
×
3341
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
3342
            conn.commit();
×
3343
        }
3344
    }
×
3345

3346
    /**
3347
     * Reads {@code processedUpTo} from the given space-state graph.
3348
     * Returns {@code -1} if absent (graph not fully built yet).
3349
     */
3350
    long readProcessedUpTo(IRI graph) {
3351
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3352
            String query = String.format(
×
3353
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
3354
                    graph, graph, SpacesVocab.PROCESSED_UP_TO);
3355
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
3356
                if (!r.hasNext()) return -1;
×
3357
                BindingSet b = r.next();
×
3358
                return Long.parseLong(b.getBinding("n").getValue().stringValue());
×
3359
            }
×
3360
        } catch (Exception ex) {
3!
3361
            // Must not collapse to -1: callers read -1 as "this graph was never
3362
            // finished" and rebuild from scratch. A timed-out read returning -1 would
3363
            // make a healthy state look damaged and trigger a destructive rebuild.
3364
            throw new SpaceStateUnavailableException("failed to read processedUpTo for " + graph, ex);
24✔
3365
        }
3366
    }
3367

3368
    /**
3369
     * Reads the {@code npa:needsFullRebuild} flag (boolean literal) from
3370
     * {@code npa:graph} in the {@code spaces} repo. Defaults to {@code false}
3371
     * when the triple is absent.
3372
     */
3373
    boolean readNeedsFullRebuild() {
3374
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3375
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
3376
                    SpacesVocab.NEEDS_FULL_REBUILD);
3377
            return v != null && Boolean.parseBoolean(v.stringValue());
×
3378
        } catch (Exception ex) {
×
3379
            logger.warn("AuthorityResolver: failed to read needsFullRebuild: {}", ex.toString());
×
3380
            return false;
×
3381
        }
3382
    }
3383

3384
    void setNeedsFullRebuild() {
3385
        writeNeedsFullRebuild(true);
×
3386
    }
×
3387

3388
    void clearNeedsFullRebuild() {
3389
        writeNeedsFullRebuild(false);
×
3390
    }
×
3391

3392
    private void writeNeedsFullRebuild(boolean value) {
3393
        String update = String.format("""
×
3394
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3395
                INSERT { GRAPH <%s> { <%s> <%s> "%s"^^<http://www.w3.org/2001/XMLSchema#boolean> } }
3396
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3397
                """,
3398
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD,
3399
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD, value,
×
3400
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD);
3401
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3402
            conn.begin(IsolationLevels.SERIALIZABLE);
×
3403
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
3404
            conn.commit();
×
3405
        }
3406
    }
×
3407

3408
    void dropGraph(IRI graph) {
3409
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3410
            conn.begin(IsolationLevels.SERIALIZABLE);
×
3411
            conn.clear(graph);
×
3412
            conn.commit();
×
3413
            logger.info("AuthorityResolver: dropped old space-state graph {}", graph);
×
3414
        }
3415
    }
×
3416

3417
    // ---------------- Trust-repo pointer lookup (used by TrustStateRegistry's bootstrap) ----------------
3418

3419
    /**
3420
     * Queries the {@code trust} repo directly for the current trust-state hash.
3421
     * Prefer {@link TrustStateRegistry#getCurrentHash()} in normal operation —
3422
     * this helper exists for tests and diagnostics.
3423
     *
3424
     * @return the current trust-state hash, or empty if none is set
3425
     */
3426
    Optional<String> readTrustRepoCurrentHash() {
3427
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
3428
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
3429
                    NPA_HAS_CURRENT_TRUST_STATE);
3430
            if (!(v instanceof IRI iri)) return Optional.empty();
×
3431
            String s = iri.stringValue();
×
3432
            if (!s.startsWith(NPAT.NAMESPACE)) return Optional.empty();
×
3433
            return Optional.of(s.substring(NPAT.NAMESPACE.length()));
×
3434
        } catch (Exception ex) {
×
3435
            logger.warn("AuthorityResolver: failed to read trust-repo current pointer: {}", ex.toString());
×
3436
            return Optional.empty();
×
3437
        }
3438
    }
3439

3440
    private static String abbrev(String hash) {
3441
        return hash.length() > 12 ? hash.substring(0, 12) + "…" : hash;
×
3442
    }
3443

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