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

knowledgepixels / nanopub-query / 32720083966

24 Aug 2026 11:05AM UTC coverage: 77.054% (+0.4%) from 76.67%
32720083966

push

github

web-flow
Merge pull request #199 from knowledgepixels/pending-account-materialization

feat(spaces): materialize introduced-but-unapproved accounts (#195)

887 of 1300 branches covered (68.23%)

Branch coverage included in aggregate %.

2592 of 3215 relevant lines covered (80.62%)

12.67 hits per line

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

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

3
import java.nio.charset.StandardCharsets;
4
import java.util.ArrayList;
5
import java.util.HashSet;
6
import java.util.List;
7
import java.util.Optional;
8
import java.util.Set;
9

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

28
import com.google.common.hash.Hashing;
29
import com.knowledgepixels.query.vocabulary.GEN;
30
import com.knowledgepixels.query.vocabulary.NPAA;
31
import com.knowledgepixels.query.vocabulary.NPAT;
32
import com.knowledgepixels.query.vocabulary.SpacesVocab;
33

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

62
    private static final Logger logger = LoggerFactory.getLogger(AuthorityResolver.class);
9✔
63

64
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
65

66
    private static final String SPACES_REPO = "spaces";
67
    private static final String TRUST_REPO = "trust";
68
    /** Source of introduction-nanopub meta triples for the pending-account mirror (issue #195). */
69
    private static final String META_REPO = "meta";
70

71
    /** NPA constants pulled in locally (trust-side). */
72
    private static final IRI NPA_HAS_CURRENT_TRUST_STATE =
9✔
73
            vf.createIRI(NPA.NAMESPACE, "hasCurrentTrustState");
6✔
74
    private static final IRI NPA_ACCOUNT_STATE = vf.createIRI(NPA.NAMESPACE, "AccountState");
15✔
75
    private static final IRI NPA_AGENT = vf.createIRI(NPA.NAMESPACE, "agent");
15✔
76
    private static final IRI NPA_PUBKEY = vf.createIRI(NPA.NAMESPACE, "pubkey");
15✔
77
    private static final IRI NPA_TRUST_STATUS = vf.createIRI(NPA.NAMESPACE, "trustStatus");
15✔
78
    private static final IRI NPA_VIA_NANOPUB = vf.createIRI(NPA.NAMESPACE, "viaNanopub");
15✔
79
    private static final IRI NPA_LOADED = vf.createIRI(NPA.NAMESPACE, "loaded");
15✔
80
    private static final IRI NPA_TO_LOAD = vf.createIRI(NPA.NAMESPACE, "toLoad");
15✔
81

82
    /**
83
     * Class of the introduced-but-unapproved account rows written by
84
     * {@link #mirrorPendingAccounts} (issue #195).
85
     *
86
     * <p>Deliberately <em>not</em> {@code npa:AccountState}. An {@code AccountState}
87
     * row is the pubkey&rarr;agent identity binding every authority join in this class
88
     * resolves through ({@code PUBLISHER_IS_ADMIN}, {@link #publisherIsTieredRole},
89
     * {@link #adminTierUpdate}, the {@code revoker*} blocks), and those joins pair it
90
     * with a {@code RoleInstantiation} keyed on the <em>agent</em>. Since an
91
     * introduction is self-asserted, emitting these rows as bare {@code AccountState}
92
     * would let anyone bind their own key to any agent that already holds authority
93
     * without having an approved account — 43 such agents existed on the production
94
     * fleet when this was written, three of them admins. The distinct class fails
95
     * closed for every existing join site, and for published queries that cannot be
96
     * retrofitted.
97
     */
98
    private static final IRI NPA_PENDING_ACCOUNT_STATE =
9✔
99
            vf.createIRI(NPA.NAMESPACE, "PendingAccountState");
6✔
100
    /** Trust status stamped on pending rows and on the roles materialized through them. */
101
    private static final IRI NPA_SEEN = vf.createIRI(NPA.NAMESPACE, "seen");
15✔
102

103
    /**
104
     * Trust-approved set: rows with one of these {@code npa:trustStatus} values
105
     * are mirrored into the space-state graph. Per
106
     * {@code doc/design-trust-state-repos.md}, these are the two "authority-
107
     * approving" statuses; {@code npa:contested}, {@code npa:skipped}, and the
108
     * transient statuses are distinct values of the same predicate and are
109
     * excluded automatically by this positive-list filter.
110
     */
111
    private static final Set<IRI> APPROVED_SET = Set.of(NPA_LOADED, NPA_TO_LOAD);
15✔
112

113
    private static AuthorityResolver instance;
114

115
    /** Returns the singleton. */
116
    public static synchronized AuthorityResolver get() {
117
        if (instance == null) {
6✔
118
            instance = new AuthorityResolver();
12✔
119
        }
120
        return instance;
6✔
121
    }
122

123
    private AuthorityResolver() {
6✔
124
    }
3✔
125

126
    // ---------------- Operational metrics snapshot ----------------
127
    //
128
    // Updated at the end of each runFullBuild / runIncrementalCycle, read by
129
    // MetricsCollector via the get*() accessors below. volatile is enough —
130
    // writers serialise via the synchronized methods, and readers (Prometheus
131
    // scrapes) only need most-recent visibility, not transactional consistency
132
    // across the snapshot. Defaults to zero values so a scrape that races a
133
    // boot before the first cycle returns 0, not NaN.
134

135
    private volatile TierSubjectTotals lastSubjectTotals = new TierSubjectTotals(0L, 0L, 0L);
24✔
136
    private volatile long lastInsertedTriplesTotal;
137
    private volatile long lastFullBuildDurationMs;
138
    private volatile long lastIncrementalCycleDurationMs;
139
    private volatile long lastProcessedUpToLag;
140

141
    public TierSubjectTotals getLastSubjectTotals() { return lastSubjectTotals; }
9✔
142
    public long getLastInsertedTriplesTotal() { return lastInsertedTriplesTotal; }
9✔
143
    public long getLastFullBuildDurationMs() { return lastFullBuildDurationMs; }
9✔
144
    public long getLastIncrementalCycleDurationMs() { return lastIncrementalCycleDurationMs; }
9✔
145
    public long getLastProcessedUpToLag() { return lastProcessedUpToLag; }
9✔
146

147
    /**
148
     * Raised when the space-state bookkeeping in the {@code spaces} repo cannot be
149
     * <em>read</em>, as opposed to being legitimately absent.
150
     *
151
     * <p>The distinction is the whole point. Before 2026-08-05 every reader here
152
     * collapsed both cases onto the same value — {@code null} pointer, load counter
153
     * {@code 0}, {@code processedUpTo} {@code -1} — so a degraded RDF4J looked
154
     * identical to a fresh install. On that day RDF4J was answering reads with
155
     * {@code Read timed out}; {@link #tick()} saw a {@code null} pointer, logged a
156
     * "trust-state flip" that had not happened, and ran a full build whose every
157
     * source read also failed. The build inserted nothing, published the resulting
158
     * empty graph, and dropped the previous good one — 2730 triples of live space
159
     * state, on a query server that then served zero rows to every state query for
160
     * hours. A sibling instance that stayed healthy still had all of it.
161
     *
162
     * <p>Throwing instead lets the caller abort. Doing nothing this tick is always
163
     * safe; acting on a failed read is not.
164
     */
165
    static class SpaceStateUnavailableException extends RuntimeException {
166
        SpaceStateUnavailableException(String message, Throwable cause) {
167
            super(message, cause);
12✔
168
        }
3✔
169
    }
170

171
    // ---------------- Public entry points ----------------
172

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

238
    /**
239
     * Periodic worker. If {@code npa:needsFullRebuild} was raised by an
240
     * incremental cycle's structural DELETE, runs a from-scratch rebuild into
241
     * a fresh space-state graph (using the current trust-state hash and load
242
     * counter) and clears the flag. No-op when the flag is not set. Safe to
243
     * call concurrently with {@link #tick()} when both are scheduled on the
244
     * same single-threaded executor.
245
     */
246
    public void periodicRebuildTick() {
247
        if (!FeatureFlags.spacesEnabled()) return;
9✔
248
        if (!readNeedsFullRebuild()) return;
12✔
249
        String trustStateHash = TrustStateRegistry.get().getCurrentHash().orElse(null);
18✔
250
        if (trustStateHash == null) {
6✔
251
            logger.debug("AuthorityResolver.periodicRebuildTick: no current trust state — deferring");
9✔
252
            return;
3✔
253
        }
254
        logger.info("AuthorityResolver.periodicRebuildTick: needsFullRebuild flag set; rebuilding");
9✔
255
        runFullBuild(trustStateHash);
9✔
256
        clearNeedsFullRebuild();
6✔
257
    }
3✔
258

259
    /**
260
     * Startup cleanup: drop any {@code npass:*} graph that the
261
     * {@code npa:hasCurrentSpaceState} pointer isn't pointing at. Orphans come
262
     * from crashes mid-build. Safe to call at any time; idempotent.
263
     */
264
    public synchronized void cleanOrphans() {
265
        if (!FeatureFlags.spacesEnabled()) return;
9✔
266
        IRI current;
267
        try {
268
            current = getCurrentSpaceStateGraph();
9✔
269
        } catch (SpaceStateUnavailableException ex) {
3✔
270
            // Every npass:* graph is "not the current one" when the pointer cannot be
271
            // read, so continuing here would drop the live state along with the
272
            // orphans. Skipping costs nothing: orphans are inert, and the next start
273
            // will clean them up.
274
            logger.warn("AuthorityResolver.cleanOrphans: cannot read the current-state pointer, "
12✔
275
                    + "skipping so orphan cleanup cannot delete the live graph: {}", ex.toString());
3✔
276
            return;
3✔
277
        }
3✔
278
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
279
            int dropped = 0;
6✔
280
            try (RepositoryResult<org.eclipse.rdf4j.model.Resource> ctxs = conn.getContextIDs()) {
9✔
281
                List<IRI> toDrop = new ArrayList<>();
12✔
282
                while (ctxs.hasNext()) {
9✔
283
                    org.eclipse.rdf4j.model.Resource ctx = ctxs.next();
12✔
284
                    if (!(ctx instanceof IRI iri)) continue;
18!
285
                    if (!iri.stringValue().startsWith(SpacesVocab.NPASS_NAMESPACE)) continue;
18✔
286
                    if (iri.equals(current)) continue;
15✔
287
                    toDrop.add(iri);
12✔
288
                }
3✔
289
                for (IRI iri : toDrop) {
30✔
290
                    conn.begin(IsolationLevels.SNAPSHOT);
9✔
291
                    conn.clear(iri);
24✔
292
                    conn.commit();
6✔
293
                    dropped++;
3✔
294
                    logger.info("AuthorityResolver.cleanOrphans: dropped orphan graph {}", iri);
12✔
295
                }
3✔
296
            }
297
            if (dropped == 0) {
6✔
298
                logger.debug("AuthorityResolver.cleanOrphans: no orphan space-state graphs");
9✔
299
            }
300
        } catch (Exception ex) {
×
301
            logger.info("AuthorityResolver.cleanOrphans: failed: {}", ex.toString());
×
302
        }
3✔
303
    }
3✔
304

305
    // ---------------- Full build ----------------
306

307
    /**
308
     * Mutex-protected full build of the space-state graph for the given trust
309
     * state. Captures {@code M = currentLoadCounter}, mirrors trust-approved
310
     * rows, (PR 2b: runs per-tier UPDATE loops from scratch), stamps
311
     * {@code processedUpTo = M}, flips the pointer, drops the previous graph.
312
     */
313
    synchronized void runFullBuild(String trustStateHash) {
314
        long startNanos = System.nanoTime();
6✔
315
        long loadCounter = getCurrentLoadCounter();
9✔
316
        IRI newGraph = SpacesVocab.forSpaceState(trustStateHash, loadCounter);
12✔
317
        IRI oldGraph = getCurrentSpaceStateGraph();
9✔
318
        boolean rebuildInPlace = newGraph.equals(oldGraph);
12✔
319
        if (rebuildInPlace) {
6✔
320
            // "Already current" is only true if that graph was actually finished AND
321
            // still holds everything it claims to. Without the processedUpTo check
322
            // this early return was the second half of the 2026-08-05 trap: once a
323
            // damaged graph was published, the pointer name still matched, so every
324
            // subsequent full build returned here and the instance could never repair
325
            // itself. The integrity-count check closes the same loophole for the
326
            // 2026-08-22 shape (graph truncated after publication, stamps intact) —
327
            // without it, tick() would detect the mismatch, call this method, and be
328
            // bounced right back here forever.
329
            long expected = readStateTripleCount(oldGraph);
12✔
330
            boolean countConsistent = expected < 0 || expected == countStateGraphTriples(oldGraph);
42✔
331
            if (readProcessedUpTo(oldGraph) >= 0 && countConsistent) {
24✔
332
                logger.debug("AuthorityResolver.runFullBuild: already current at {}", newGraph);
12✔
333
                return;
3✔
334
            }
335
            logger.warn("AuthorityResolver.runFullBuild: {} is the current graph but is "
39✔
336
                    + "unfinished or inconsistent (processedUpTo={}, countConsistent={}); "
337
                    + "rebuilding it in place", newGraph, readProcessedUpTo(oldGraph), countConsistent);
24✔
338
            dropGraph(newGraph);
9✔
339
        }
340

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

344
        // 1b. Mirror introduced-but-unapproved accounts (issue #195). Runs before the
345
        //     tier loops so this build's observer(self,pending) pass sees the rows.
346
        //     Additive and authority-free, so it is deliberately not part of the
347
        //     empty-build guard below.
348
        int pendingMirrored = mirrorPendingAccountsSafely(newGraph, /*fromScratch=*/ true);
15✔
349

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

354
        // 2b. Refuse to publish an empty build over a state we already have — but only
355
        // when the emptiness cannot be true.
356
        //
357
        // Steps 4 and 5 below are destructive, so a build that read nothing must not
358
        // reach them. The trap is that "produced nothing" has two causes: every source
359
        // read failed, or the sources really are empty. Refusing in the second case
360
        // would pin a stale space state forever, and stale trust data is
361
        // over-permissive — revocations would stop propagating. That is the wrong way
362
        // to fail for a trust-derived state.
363
        //
364
        // So the condition is: nothing was produced *while the trust state still has
365
        // content to mirror*. That is the shape of a read failure. A genuinely empty
366
        // trust state yields an empty build and is published normally.
367
        //
368
        // Only guarded when a previous state exists: a genuinely empty first build on
369
        // a fresh instance has nothing to lose and must still be allowed to publish.
370
        //
371
        // Note this would NOT have fired on 2026-08-05: that build reported
372
        // subspace-prefix=2478, so it was not empty. The wipe there came from the
373
        // registry's trust state collapsing (correctly reflected) plus 2478 triples
374
        // that were reported inserted and then measured as zero. This guard is for the
375
        // total-read-failure case, which the same outage came close to several times.
376
        long insertedTotal = totalInserted(counts);
9✔
377
        if (mirrored == 0 && insertedTotal == 0 && oldGraph != null
30!
378
                && trustStateHasContent(trustStateHash)) {
6✔
379
            logger.error("AuthorityResolver.runFullBuild: build produced an empty state graph "
12✔
380
                    + "(mirrored=0, inserted=0) while trust state {} still has content and {} "
381
                    + "holds the current state — refusing to flip the pointer or drop it. "
382
                    + "This is the shape of a total read failure; the next tick will retry.",
383
                    abbrev(trustStateHash), oldGraph);
6✔
384
            if (!rebuildInPlace) {
6!
385
                dropGraph(newGraph);
9✔
386
            }
387
            return;
3✔
388
        }
389

390
        // 3. Stamp processedUpTo inside the new graph.
391
        writeProcessedUpTo(newGraph, loadCounter);
12✔
392

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

398
        // 4. Flip the current-space-state pointer.
399
        flipPointer(newGraph);
9✔
400

401
        // 5. Drop the old graph if a *different* one existed. Dropping it when
402
        //    rebuilding in place would delete what we just built.
403
        if (oldGraph != null && !rebuildInPlace) {
12✔
404
            dropGraph(oldGraph);
9✔
405
        }
406

407
        TierSubjectTotals totals = computeTierSubjectTotals(newGraph);
12✔
408
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
18✔
409
        lastSubjectTotals = totals;
9✔
410
        lastInsertedTriplesTotal = insertedTotal;
9✔
411
        lastFullBuildDurationMs = durationMs;
9✔
412
        lastProcessedUpToLag = 0L;
9✔
413
        logger.info("AuthorityResolver: full build complete — graph={} mirrored={} rows pending={} rows loadCounter={} "
36✔
414
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
415
                        + "(inserted-triples: admin={} alias={} preset-attachment={} preset-assignment-ref={} attachment={} maintainer={} member={} observer={} "
416
                        + "subspace={} subspace-prefix={} maintained-resource={} governing-space-ref={}) durationMs={}",
417
                newGraph, mirrored, pendingMirrored, loadCounter,
45✔
418
                totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(),
57✔
419
                counts.admin, counts.alias, counts.presetAttachment, counts.presetAssignmentRef, counts.attachment, counts.maintainer, counts.member, counts.observer,
144✔
420
                counts.subSpace, counts.subSpacePrefix, counts.maintainedResource, counts.governingSpaceRef,
69✔
421
                durationMs);
6✔
422
    }
3✔
423

424
    // ---------------- Incremental cycle ----------------
425

426
    /**
427
     * Single delta cycle on the current space-state graph. Bounded by
428
     * {@code (processedUpTo, currentLoadCounter]}; no-op if the range is empty.
429
     *
430
     * <p>Order:
431
     * <ol>
432
     *   <li>Apply invalidation DELETEs (admin RI, RoleAssignment, non-admin RI)
433
     *       and the RoleDeclaration ASK. Any DELETE on a structural kind sets
434
     *       {@code npa:needsFullRebuild} to bound the staleness from sticky
435
     *       downstream entries; the periodic worker turns that into a from-scratch
436
     *       rebuild on its next pass.</li>
437
     *   <li>Run per-tier INSERTs in the same order as the full build.</li>
438
     *   <li>Late-arrival sweep: if any structural row was added, re-run downstream
439
     *       tier INSERTs with {@code lastProcessed = -1} to catch candidates whose
440
     *       enabling event landed in this same cycle. Dedup filters protect
441
     *       against double-insert.</li>
442
     *   <li>Bump {@code processedUpTo} to {@code currentLoadCounter}.</li>
443
     * </ol>
444
     */
445
    synchronized void runIncrementalCycle(IRI graph) {
446
        long startNanos = System.nanoTime();
6✔
447
        long currentLoadCounter = getCurrentLoadCounter();
9✔
448
        long lastProcessed = readProcessedUpTo(graph);
12✔
449
        if (lastProcessed < 0) {
12✔
450
            // tick() now catches this first and rebuilds, so reaching here means a
451
            // direct caller. Still refuse to run a delta against a graph that was
452
            // never finished — the deltas would be layered onto missing base rows.
453
            logger.warn("AuthorityResolver.runIncrementalCycle: missing processedUpTo on {}; "
12✔
454
                    + "skipping (a full build is needed to repair this graph)", graph);
455
            return;
3✔
456
        }
457
        lastProcessedUpToLag = currentLoadCounter - lastProcessed;
15✔
458
        if (currentLoadCounter <= lastProcessed) {
12✔
459
            logger.debug("AuthorityResolver.runIncrementalCycle: caught up at load {} on {}",
12✔
460
                    currentLoadCounter, graph);
6✔
461
            return;
3✔
462
        }
463

464
        boolean structuralInvalidation = applyInvalidations(graph, lastProcessed);
15✔
465
        // Pending-account delta (issue #195), before the tier loops so a newcomer's
466
        // introduction and their self-signed role can land in the same cycle. Keyed on
467
        // the meta repo's own load numbers, hence its own watermark.
468
        int pendingMirrored = mirrorPendingAccountsSafely(graph, /*fromScratch=*/ false);
15✔
469
        TierInsertedTriples counts = runAllTierLoops(graph, lastProcessed);
15✔
470
        boolean structuralAdds = (pendingMirrored > 0)
57!
471
                || (counts.admin > 0)
472
                || (counts.alias > 0)
473
                || (counts.presetAttachment > 0)
474
                || (counts.attachment > 0)
475
                || (counts.subSpace > 0)
476
                || newRoleDeclarationsArrived(lastProcessed)
12✔
477
                || newPresetAssignmentsArrived(lastProcessed);
18!
478
        if (structuralAdds) {
6✔
479
            // Late-arrival sweep: leaf tiers (attachment/maintainer/member/observer)
480
            // can promote candidates whose enabling event arrived in this same cycle.
481
            // Sub-space admit is also re-run here for Mode-B late-arrival (a new
482
            // partner declaration can validate an older primary that the regular
483
            // pass's load-number filter excluded). The URL-prefix fallback also
484
            // re-runs so newly-orphaned children pick up derived edges. Skip the
485
            // admin tier — its only enabling event is the admin grant itself,
486
            // already handled by the regular pass.
487
            TierInsertedTriples lateCounts = runDownstreamWithoutLoadFilter(graph);
12✔
488
            counts.alias              += lateCounts.alias;
21✔
489
            counts.presetAttachment   += lateCounts.presetAttachment;
21✔
490
            counts.presetAssignmentRef += lateCounts.presetAssignmentRef;
21✔
491
            counts.attachment         += lateCounts.attachment;
21✔
492
            counts.maintainer         += lateCounts.maintainer;
21✔
493
            counts.member             += lateCounts.member;
21✔
494
            counts.observer           += lateCounts.observer;
21✔
495
            counts.subSpace           += lateCounts.subSpace;
21✔
496
            counts.subSpacePrefix     += lateCounts.subSpacePrefix;
21✔
497
            counts.governingSpaceRef  += lateCounts.governingSpaceRef;
21✔
498
            counts.maintainedResource += lateCounts.maintainedResource;
21✔
499
        }
500

501
        writeProcessedUpTo(graph, currentLoadCounter);
12✔
502
        // Re-stamp the integrity count after this cycle's mutations (also
503
        // upgrades pre-stamp graphs to verifiable on their first mutation).
504
        writeStateTripleCount(graph);
9✔
505

506
        TierSubjectTotals totals = computeTierSubjectTotals(graph);
12✔
507
        long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
18✔
508
        lastSubjectTotals = totals;
9✔
509
        lastInsertedTriplesTotal = (long) counts.admin + counts.alias + counts.presetAttachment
147✔
510
                + counts.presetAssignmentRef
511
                + counts.attachment + counts.maintainer + counts.member + counts.observer
512
                + counts.subSpace + counts.subSpacePrefix + counts.maintainedResource
513
                + counts.governingSpaceRef;
514
        lastIncrementalCycleDurationMs = durationMs;
9✔
515
        logger.info("AuthorityResolver: incremental cycle complete — graph={} delta=({}, {}] pending={} rows "
36✔
516
                        + "subjects: adminRIs={} attachmentRAs={} nonAdminRIs={} "
517
                        + "(inserted-triples: admin={} alias={} preset-attachment={} preset-assignment-ref={} attachment={} maintainer={} member={} observer={} "
518
                        + "subspace={} subspace-prefix={} maintained-resource={} governing-space-ref={}) "
519
                        + "structuralInvalidation={} structuralAdds={} durationMs={}",
520
                graph, lastProcessed, currentLoadCounter, pendingMirrored,
45✔
521
                totals.adminRIs(), totals.attachmentRAs(), totals.nonAdminRIs(),
57✔
522
                counts.admin, counts.alias, counts.presetAttachment, counts.presetAssignmentRef, counts.attachment, counts.maintainer, counts.member, counts.observer,
144✔
523
                counts.subSpace, counts.subSpacePrefix, counts.maintainedResource, counts.governingSpaceRef,
69✔
524
                structuralInvalidation, structuralAdds, durationMs);
36✔
525
    }
3✔
526

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

638
    /**
639
     * Runs the four leaf tiers (attachment/maintainer/member/observer) with
640
     * {@code lastProcessed = -1} so the load-number filter on the candidate
641
     * side admits everything. Dedup filters in the tier templates prevent
642
     * double-insert. Used by the late-arrival sweep.
643
     */
644
    TierInsertedTriples runDownstreamWithoutLoadFilter(IRI graph) {
645
        TierInsertedTriples c = new TierInsertedTriples();
12✔
646
        // Alias late-arrival: catches alias declarations whose canonical admin grant
647
        // became valid only in this same cycle (the load-number filter on the
648
        // declaration's nanopub would otherwise exclude it). Runs first so the
649
        // attachment / role tiers below see this cycle's fresh npa:sameAsSpace edges.
650
        c.alias = runTierLabeled("alias(late)", graph, aliasAdmitUpdate(graph, -1));
27✔
651
        // Sub-space late-arrival: catches Mode-B candidates whose primary
652
        // declaration is older than lastProcessed but whose partner just landed.
653
        c.subSpace = runTierLabeled("subspace(late)", graph,
24✔
654
                subSpaceAdmitUpdate(graph, -1));
3✔
655
        // Maintained-resource late-arrival: catches declarations that landed
656
        // before the publisher's admin grant became valid in this state.
657
        c.maintainedResource = runTierLabeled("maintained-resource(late)", graph,
24✔
658
                maintainedResourceAdmitUpdate(graph, -1));
3✔
659
        // URL-prefix fallback: re-run after the late-arrival sub-space admit so
660
        // any newly-validated children get their fallback edges suppressed (for
661
        // future inserts) and any newly-orphaned children pick up fallback edges.
662
        c.subSpacePrefix = runTierLabeled("subspace-prefix(late)", graph,
21✔
663
                subSpacePrefixFallbackUpdate(graph));
3✔
664
        // Reflexive governing-space-ref late sweep (issue #130): catches refs whose
665
        // SpaceRef aggregate became visible only this cycle. Self-healing dedup.
666
        c.governingSpaceRef = runTierLabeled("governing-space-ref(late)", graph,
21✔
667
                governingSpaceRefReflexiveUpdate(graph));
3✔
668
        // Preset-attachment late-arrival: catches assignments whose preset declaration or
669
        // admin grant only became valid in this same cycle. Runs before attachment(late)
670
        // so the non-admin late tiers below see this cycle's fresh preset-derived RAs.
671
        c.presetAttachment = runTierLabeled("preset-attachment(late)", graph,
24✔
672
                presetAttachmentValidationUpdate(graph, -1));
3✔
673
        // Ref-scoped preset-assignment late stamp: catches assignments whose authorizing
674
        // admin grant only became valid this cycle (the load filter would skip the older
675
        // assignment nanopub). Mirrors the preset-attachment late sweep above.
676
        c.presetAssignmentRef = runTierLabeled("preset-assignment-ref(late)", graph,
24✔
677
                presetAssignmentRefStampUpdate(graph, -1));
3✔
678
        c.attachment = runTierLabeled("attachment(late)", graph,
24✔
679
                attachmentValidationUpdate(graph, -1));
3✔
680
        c.maintainer = runTierLabeled("maintainer(late)", graph,
30✔
681
                nonAdminTierUpdate(graph, -1, GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
3✔
682
        c.member = runTierLabeled("member(admin-pub,late)", graph,
30✔
683
                nonAdminTierUpdate(graph, -1, GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
3✔
684
        c.member += runTierLabeled("member(maint-pub,late)", graph,
39✔
685
                nonAdminTierUpdate(graph, -1,
3✔
686
                        GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
3✔
687
        c.observer = runTierLabeled("observer(admin-pub,late)", graph,
30✔
688
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
3✔
689
        c.observer += runTierLabeled("observer(maint-pub,late)", graph,
39✔
690
                nonAdminTierUpdate(graph, -1,
3✔
691
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
3✔
692
        c.observer += runTierLabeled("observer(member-pub,late)", graph,
39✔
693
                nonAdminTierUpdate(graph, -1,
3✔
694
                        GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
3✔
695
        c.observer += runTierLabeled("observer(self,late)", graph,
39✔
696
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
3✔
697
        c.observer += runTierLabeled("observer(self,pending,late)", graph,
42✔
698
                nonAdminTierUpdate(graph, -1, GEN.OBSERVER_ROLE,
3✔
699
                        PUBLISHER_IS_SELF_PENDING, PENDING_ROLE_STAMP));
700
        return c;
6✔
701
    }
702

703
    /**
704
     * Cheap ASK: did any new {@code npa:RoleDeclaration} extraction land in the
705
     * load-number delta {@code (lastProcessed, ∞)}? Used by the late-arrival
706
     * trigger so an RD that arrives in the same cycle as a matching candidate
707
     * still gets validated.
708
     */
709
    boolean newRoleDeclarationsArrived(long lastProcessed) {
710
        String ask = String.format("""
60✔
711
                PREFIX npa: <%1$s>
712
                ASK {
713
                  GRAPH <%2$s> {
714
                    ?rd a npa:RoleDeclaration ;
715
                        npa:viaNanopub ?np .
716
                  }
717
                  GRAPH <%3$s> {
718
                    ?np npa:hasLoadNumber ?ln .
719
                    FILTER (?ln > %4$d)
720
                  }
721
                }
722
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
723
        return runAsk(ask);
12✔
724
    }
725

726
    /**
727
     * Cheap ASK: did any new {@code npa:PresetAssignment} or {@code npa:PresetDeclaration}
728
     * extraction land in the load-number delta {@code (lastProcessed, ∞)}? Drives the
729
     * late-arrival re-run so a preset assignment that arrives in the same cycle as its
730
     * declaration (or admin grant) still materializes, and so an arriving newer assignment
731
     * triggers the deactivation/latest-wins re-evaluation.
732
     */
733
    boolean newPresetAssignmentsArrived(long lastProcessed) {
734
        String ask = String.format("""
60✔
735
                PREFIX npa: <%1$s>
736
                ASK {
737
                  GRAPH <%2$s> {
738
                    ?x a ?t ;
739
                       npa:viaNanopub ?np .
740
                    FILTER (?t = npa:PresetAssignment || ?t = npa:PresetDeclaration)
741
                  }
742
                  GRAPH <%3$s> {
743
                    ?np npa:hasLoadNumber ?ln .
744
                    FILTER (?ln > %4$d)
745
                  }
746
                }
747
                """, NPA.NAMESPACE, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
748
        return runAsk(ask);
12✔
749
    }
750

751
    // ---------------- Tier UPDATE loops ----------------
752

753
    /**
754
     * Per-tier inserted-triple tallies for one build or cycle. Counts the sum
755
     * of {@code (graphSize_after - graphSize_before)} across all iterations of
756
     * each tier's fixed-point INSERT loop — i.e. inserted *triples*, not
757
     * distinct subjects (a single RoleInstantiation insert writes 4–5 triples).
758
     *
759
     * <p>Used internally by the {@link #runIncrementalCycle structuralAdds}
760
     * boolean check (we only care whether any tier inserted at all).
761
     * Not what the log lines report: see {@link TierSubjectTotals} +
762
     * {@link #computeTierSubjectTotals} for the distinct-subject totals
763
     * surfaced to operators.
764
     */
765
    /**
766
     * Total triples inserted across every tier. Used both for the metrics gauge and
767
     * for the empty-build guard in {@link #runFullBuild}, so the two can never
768
     * disagree about what "this build produced nothing" means.
769
     */
770
    static long totalInserted(TierInsertedTriples c) {
771
        return (long) c.admin + c.alias + c.presetAttachment + c.presetAssignmentRef
144✔
772
                + c.attachment + c.maintainer + c.member + c.observer
773
                + c.subSpace + c.subSpacePrefix + c.maintainedResource
774
                + c.governingSpaceRef;
775
    }
776

777
    static final class TierInsertedTriples {
9✔
778
        int admin;
779
        int alias;
780
        int presetAttachment;
781
        int presetAssignmentRef;
782
        int attachment;
783
        int maintainer;
784
        int member;
785
        int observer;
786
        int subSpace;
787
        int subSpacePrefix;
788
        int maintainedResource;
789
        int governingSpaceRef;
790
    }
791

792
    /**
793
     * Snapshot of distinct-subject totals in a space-state graph at a moment
794
     * in time. Independent of which tier-loop added each subject.
795
     */
796
    record TierSubjectTotals(long adminRIs, long attachmentRAs, long nonAdminRIs) {}
36✔
797

798
    /**
799
     * Runs the five tier loops in order: admin → {@code gen:hasRole} attachment
800
     * validation → maintainer → member → observer. Each loop iterates a SPARQL
801
     * INSERT to fixed point (no new triples added). Returns per-tier counts.
802
     *
803
     * @param graph         target space-state graph
804
     * @param lastProcessed load-number horizon; use {@code -1} for full build
805
     */
806
    TierInsertedTriples runAllTierLoops(IRI graph, long lastProcessed) {
807
        TierInsertedTriples c = new TierInsertedTriples();
12✔
808
        c.admin = runTierLabeled("admin", graph, adminTierUpdate(graph, lastProcessed));
27✔
809
        // Alias admit runs after the admin closure has settled (both the authority
810
        // gate and the anti-hijack check read the admin set) and before attachment /
811
        // role tiers (their alias-aware admin lookups consume the npa:sameAsSpace edge
812
        // this pass emits). See issue #113.
813
        c.alias = runTierLabeled("alias", graph, aliasAdmitUpdate(graph, lastProcessed));
27✔
814
        // Sub-space admit runs after admin closure has settled (Mode A + Mode B both
815
        // need the admin set). Independent of role tiers — order between subspace
816
        // and attachment / maintainer / member / observer doesn't matter.
817
        c.subSpace = runTierLabeled("subspace", graph, subSpaceAdmitUpdate(graph, lastProcessed));
27✔
818
        // Maintained-resource admit also depends only on the admin closure. Single
819
        // Mode A: publisher must be admin of the maintaining space. No co-declaration
820
        // partner, no URL-prefix fallback.
821
        c.maintainedResource = runTierLabeled("maintained-resource", graph,
24✔
822
                maintainedResourceAdmitUpdate(graph, lastProcessed));
3✔
823
        // URL-prefix sub-space fallback runs after the explicit-declaration admit
824
        // pass commits so the per-child suppression check sees this cycle's fresh
825
        // validations. No load filter — depends on which Spaces exist, not on
826
        // delta-arrivals; the dedup FILTER NOT EXISTS prevents re-insertion.
827
        c.subSpacePrefix = runTierLabeled("subspace-prefix", graph,
21✔
828
                subSpacePrefixFallbackUpdate(graph));
3✔
829
        // Reflexive governing-space-ref edges (issue #130). Self-healing, no load filter;
830
        // runs after the maintained-resource admit so a maintained resource that is itself
831
        // a space already has its maintained governing edge by now (the two are independent
832
        // anyway — different subjects/objects). Order vs. other tiers doesn't matter.
833
        c.governingSpaceRef = runTierLabeled("governing-space-ref", graph,
21✔
834
                governingSpaceRefReflexiveUpdate(graph));
3✔
835
        // Preset-attachment runs immediately before the regular attachment tier so the
836
        // gen:RoleAssignment rows it materializes (from active, admin-authored preset
837
        // assignments) are picked up by the downstream non-admin tiers in the same pass,
838
        // exactly like directly-published attachments. See
839
        // doc/design-preset-role-materialization.md.
840
        c.presetAttachment = runTierLabeled("preset-attachment", graph,
24✔
841
                presetAttachmentValidationUpdate(graph, lastProcessed));
3✔
842
        // Ref-scoped preset-assignment listing stamp (issue #122). Display-only leaf —
843
        // independent of the role tiers and of structuralAdds; order doesn't matter.
844
        c.presetAssignmentRef = runTierLabeled("preset-assignment-ref", graph,
24✔
845
                presetAssignmentRefStampUpdate(graph, lastProcessed));
3✔
846
        c.attachment = runTierLabeled("attachment", graph,
24✔
847
                attachmentValidationUpdate(graph, lastProcessed));
3✔
848
        c.maintainer = runTierLabeled("maintainer", graph, nonAdminTierUpdate(graph, lastProcessed,
33✔
849
                GEN.MAINTAINER_ROLE, PUBLISHER_IS_ADMIN));
850
        // Member tier: admin OR maintainer publisher — split into two simpler updates
851
        // so the query planner doesn't struggle with the UNION.
852
        c.member = runTierLabeled("member(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
33✔
853
                GEN.MEMBER_ROLE, PUBLISHER_IS_ADMIN));
854
        c.member += runTierLabeled("member(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
42✔
855
                GEN.MEMBER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
3✔
856
        // Observer tier: self-evidence OR a downward grant from any higher tier.
857
        // ObserverRole is the default tier when a role definition omits an
858
        // explicit subclass (see "Role types" in design-space-repositories.md), so
859
        // most "X assigned Y this role" nanopubs land here. Restricting the tier
860
        // to PUBLISHER_IS_SELF would silently drop those grants. The four
861
        // sub-loops mirror the trust-state's downward-only chain: admin grants
862
        // anything; maintainers and members grant observer; everyone may
863
        // self-attest.
864
        c.observer = runTierLabeled("observer(admin-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
33✔
865
                GEN.OBSERVER_ROLE, PUBLISHER_IS_ADMIN));
866
        c.observer += runTierLabeled("observer(maint-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
42✔
867
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MAINTAINER_ROLE)));
3✔
868
        c.observer += runTierLabeled("observer(member-pub)", graph, nonAdminTierUpdate(graph, lastProcessed,
42✔
869
                GEN.OBSERVER_ROLE, publisherIsTieredRole(GEN.MEMBER_ROLE)));
3✔
870
        c.observer += runTierLabeled("observer(self)", graph, nonAdminTierUpdate(graph, lastProcessed,
42✔
871
                GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF));
872
        // Self-attestation by a not-yet-approved account (issue #195). Runs after the
873
        // approved pass; the two are disjoint by construction (a pending row only exists
874
        // for an agent with no approved row) and the tier's dedup covers the rest.
875
        c.observer += runTierLabeled("observer(self,pending)", graph, nonAdminTierUpdate(graph, lastProcessed,
45✔
876
                GEN.OBSERVER_ROLE, PUBLISHER_IS_SELF_PENDING, PENDING_ROLE_STAMP));
877
        return c;
6✔
878
    }
879

880
    /**
881
     * Builds a publisher constraint requiring the publisher to be a validated holder
882
     * of the given tier's role (maintainer or member) in the target space.
883
     * Owns its own AccountState resolution so ?publisher is bound through the
884
     * targeted (pkh → agent) lookup rather than enumerated.
885
     */
886
    private static String publisherIsTieredRole(IRI tierClass) {
887
        // Re-keyed on the assignment's ref (alias → canonical already resolved by the
888
        // attachment tier). Relies on materialized non-admin RIs carrying their role
889
        // property (npa:regularProperty / npa:inverseProperty) — supplied by the
890
        // enrichment in nonAdminTierUpdate; without it this constraint matched nothing.
891
        return """
24✔
892
                ?acct a npa:AccountState ;
893
                      npa:pubkey ?pkh ;
894
                      npa:agent  ?publisher .
895
                ?tierRI a gen:RoleInstantiation ;
896
                        npa:forSpaceRef ?spaceRef ;
897
                        npa:forAgent ?publisher .
898
                ?rdT a npa:RoleDeclaration ;
899
                     npa:hasRoleType <%1$s> .
900
                { ?tierRI npa:regularProperty ?predT . ?rdT gen:hasRegularProperty ?predT . }
901
                UNION
902
                { ?tierRI npa:inverseProperty ?predT . ?rdT gen:hasInverseProperty ?predT . }
903
                """.formatted(tierClass);
3✔
904
    }
905

906
    // ---------------- Role revocation / detachment (issue #129) ----------------
907

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

919
    /** Inner {@code GRAPH} block matching a revoker who is a validated admin of {@code ?spaceRef}. */
920
    private static String revokerAdminGraphBlock(IRI graph) {
921
        return String.format("""
27✔
922
                GRAPH <%1$s> {
923
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?revAgent .
924
                  ?revRI a gen:RoleInstantiation ;
925
                         npa:forSpaceRef ?spaceRef ;
926
                         npa:inverseProperty gen:hasAdmin ;
927
                         npa:forAgent ?revAgent .
928
                }""", graph);
929
    }
930

931
    /** Inner {@code GRAPH} block matching a revoker who holds {@code tier} in {@code ?spaceRef}. */
932
    private static String revokerTierGraphBlock(IRI graph, IRI tier) {
933
        return String.format("""
39✔
934
                GRAPH <%1$s> {
935
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?revAgent .
936
                  ?revRI a gen:RoleInstantiation ;
937
                         npa:forSpaceRef ?spaceRef ;
938
                         npa:forAgent ?revAgent ;
939
                         npa:hasRoleType <%2$s> .
940
                }""", graph, tier);
941
    }
942

943
    /** Inner {@code GRAPH} block matching a self-revoke: the revoker's key belongs to {@code ?agent}. */
944
    private static String revokerSelfGraphBlock(IRI graph) {
945
        return String.format("""
27✔
946
                GRAPH <%1$s> {
947
                  ?revAcct a npa:AccountState ; npa:pubkey ?revPkh ; npa:agent ?agent .
948
                }""", graph);
949
    }
950

951
    /**
952
     * Authorization arms for an instantiation revocation targeting a <em>compile-time</em>
953
     * tier — issue #129's matrix, the single arm builder used by BOTH the inline suppression
954
     * filter and the (per-tier-scoped) displacement DELETE, so the two paths can never
955
     * authorize different revokers and flip-flop a row. UNION of only the arms the matrix
956
     * permits for {@code targetTier}: admin of {@code ?spaceRef} (revokes any non-admin); a
957
     * maintainer (member/observer targets); a member (observer target); plus the assignee
958
     * itself (self-leave, any tier). A revoker must hold a tier strictly higher than the
959
     * target. Compile-time selection (no runtime {@code ?tier} variable) deliberately avoids
960
     * the SPARQL pitfall where a {@code FILTER} inside a {@code UNION} branch cannot see a
961
     * {@code ?tier} bound in the enclosing group.
962
     */
963
    private static String revocationAuthorityArmsForTier(IRI graph, IRI targetTier) {
964
        List<String> arms = new ArrayList<>();
12✔
965
        arms.add("{ " + revokerAdminGraphBlock(graph) + " }");
18✔
966
        if (GEN.MEMBER_ROLE.equals(targetTier) || GEN.OBSERVER_ROLE.equals(targetTier)) {
24✔
967
            arms.add("{ " + revokerTierGraphBlock(graph, GEN.MAINTAINER_ROLE) + " }");
21✔
968
        }
969
        if (GEN.OBSERVER_ROLE.equals(targetTier)) {
12✔
970
            arms.add("{ " + revokerTierGraphBlock(graph, GEN.MEMBER_ROLE) + " }");
21✔
971
        }
972
        arms.add("{ " + revokerSelfGraphBlock(graph) + " }");
18✔
973
        return String.join("\nUNION\n", arms);
12✔
974
    }
975

976
    /**
977
     * Inline suppression filter for {@code nonAdminTierUpdate}: rejects a candidate
978
     * instantiation ({@code ?ri}, created {@code ?candCreated}) whose {@code (space, agent,
979
     * role)} key has a newer authorized {@code npa:RoleRevocation}, using
980
     * {@link #revocationAuthorityArmsForTier} for {@code targetTier} (the loop tier) — the same
981
     * builder the displacement DELETE uses, so suppression and re-materialization always agree.
982
     * The revocation's named space is matched against any IRI denoting {@code ?spaceRef}
983
     * (canonical or validated {@code owl:sameAs} alias, issue #113), so an alias-named
984
     * revocation is not a silent no-op. Latest-wins by {@code dct:created} ({@link #EPOCH_DT}
985
     * fallback) with an {@code STR()} subject tiebreak. Not wrapped in {@code invalidationFilter}:
986
     * per issue #129 the only un-revoke path is a newer positive re-assignment.
987
     */
988
    private static String nonAdminRevocationSuppressionFilter(IRI graph, IRI targetTier) {
989
        return String.format("""
51✔
990
                FILTER NOT EXISTS {
991
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
992
                  UNION
993
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
994
                  GRAPH <%2$s> {
995
                    ?rev a npa:RoleRevocation ;
996
                         npa:forSpace    ?revSpace ;
997
                         npa:forAgent    ?agent ;
998
                         npa:revokedRole ?role ;
999
                         npa:pubkeyHash  ?revPkh .
1000
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
1001
                  }
1002
                  BIND(COALESCE(?revCreatedRaw, %4$s) AS ?revCreated)
1003
                  FILTER (?revCreated > ?candCreated
1004
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri)))
1005
                  { %3$s }
1006
                }""", graph, SpacesVocab.SPACES_GRAPH,
1007
                revocationAuthorityArmsForTier(graph, targetTier), EPOCH_DT);
18✔
1008
    }
1009

1010
    /**
1011
     * Inline suppression filter for {@code adminTierUpdate}: rejects an admin instantiation
1012
     * ({@code ?ri}, created {@code ?candCreated}) whose {@code (ref, agent)} key has a newer
1013
     * authorized admin {@code npa:RoleRevocation} ({@code revokedRole = gen:AdminRole}) —
1014
     * authorized by an admin of the ref (admins revoke admins) or by the agent itself
1015
     * (self-leave). <b>Root admins are exempt</b> (issue #129/#110): a nested
1016
     * {@code FILTER NOT EXISTS} on {@code npa:hasRootAdmin} makes any revocation against a
1017
     * root admin structurally inert, overriding self-leave. {@code gen:AdminRole} resolves
1018
     * via the {@code gen:} prefix the admin-tier template declares.
1019
     */
1020
    private static String adminRevocationSuppressionFilter(IRI graph) {
1021
        return String.format("""
48✔
1022
                FILTER NOT EXISTS {
1023
                  FILTER NOT EXISTS { GRAPH <%2$s> {
1024
                    ?rootDef a npa:SpaceDefinition ;
1025
                             npa:forSpaceRef  ?spaceRef ;
1026
                             npa:hasRootAdmin ?agent .
1027
                  } }
1028
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
1029
                  UNION
1030
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
1031
                  GRAPH <%2$s> {
1032
                    ?rev a npa:RoleRevocation ;
1033
                         npa:forSpace    ?revSpace ;
1034
                         npa:forAgent    ?agent ;
1035
                         npa:revokedRole gen:AdminRole ;
1036
                         npa:pubkeyHash  ?revPkh .
1037
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
1038
                  }
1039
                  BIND(COALESCE(?revCreatedRaw, %4$s) AS ?revCreated)
1040
                  FILTER (?revCreated > ?candCreated
1041
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri)))
1042
                  { %3$s }
1043
                }""", graph, SpacesVocab.SPACES_GRAPH,
1044
                "{ " + revokerAdminGraphBlock(graph) + " }\nUNION\n{ "
6✔
1045
                        + revokerSelfGraphBlock(graph) + " }",
21✔
1046
                EPOCH_DT);
1047
    }
1048

1049
    /**
1050
     * Inline suppression filter for the attachment tiers ({@code attachmentValidationUpdate}
1051
     * and {@code presetAttachmentValidationUpdate}): rejects a {@code (targetRef, role)}
1052
     * attachment whose effective timestamp ({@code ?<createdVar>}) is out-ranked by a newer
1053
     * admin-authored {@code npa:RoleDetachment} (issue #129). Authority = admin of
1054
     * {@code ?targetRef} (matching who may attach). Non-sticky latest-wins: a newer
1055
     * attachment / preset assignment naturally re-attaches because its timestamp beats the
1056
     * detachment. The detachment's named space is matched against any IRI denoting
1057
     * {@code ?targetRef} (canonical or {@code owl:sameAs} alias).
1058
     *
1059
     * @param createdVar     bare name of the attachment's effective-created variable
1060
     * @param attachSubjVar  bare name of the attachment subject variable (for the STR tiebreak)
1061
     */
1062
    private static String roleDetachmentSuppressionFilter(IRI graph, String createdVar, String attachSubjVar) {
1063
        return String.format("""
75✔
1064
                FILTER NOT EXISTS {
1065
                  { GRAPH <%2$s> { ?targetRef npa:spaceIri ?detSpace . } }
1066
                  UNION
1067
                  { GRAPH <%1$s> { ?detSpace npa:sameAsSpace ?targetRef . } }
1068
                  GRAPH <%2$s> {
1069
                    ?det a npa:RoleDetachment ;
1070
                         npa:forSpace    ?detSpace ;
1071
                         npa:revokedRole ?role ;
1072
                         npa:pubkeyHash  ?detPkh .
1073
                    OPTIONAL { ?det <http://purl.org/dc/terms/created> ?detCreatedRaw . }
1074
                  }
1075
                  BIND(COALESCE(?detCreatedRaw, %5$s) AS ?detCreated)
1076
                  FILTER (?detCreated > ?%3$s
1077
                          || (?detCreated = ?%3$s && STR(?det) > STR(?%4$s)))
1078
                  GRAPH <%1$s> {
1079
                    ?detAcct a npa:AccountState ; npa:pubkey ?detPkh ; npa:agent ?detAgent .
1080
                    ?detAdminRI a gen:RoleInstantiation ;
1081
                                npa:forSpaceRef ?targetRef ;
1082
                                npa:inverseProperty gen:hasAdmin ;
1083
                                npa:forAgent ?detAgent .
1084
                  }
1085
                }""", graph, SpacesVocab.SPACES_GRAPH, createdVar, attachSubjVar, EPOCH_DT);
1086
    }
1087

1088
    /** Wraps {@link #runTierLoop} with tier-name context for logs/exceptions. */
1089
    private int runTierLabeled(String tier, IRI graph, String sparqlUpdate) {
1090
        try {
1091
            return runTierLoop(graph, sparqlUpdate);
15✔
1092
        } catch (RuntimeException ex) {
×
1093
            logger.error("AuthorityResolver: tier={} failed with SPARQL UPDATE:\n{}\n", tier, sparqlUpdate, ex);
×
1094
            throw ex;
×
1095
        }
1096
    }
1097

1098
    /**
1099
     * Runs a single tier's INSERT to fixed point. Counts rows by probing
1100
     * graph size before/after each INSERT; stops when the size doesn't change.
1101
     *
1102
     * @return total number of triples inserted by this tier across all iterations
1103
     */
1104
    int runTierLoop(IRI graph, String sparqlUpdate) {
1105
        int total = 0;
6✔
1106
        long before = graphSize(graph);
12✔
1107
        while (true) {
1108
            // Note: no explicit transaction wrapping here. In tests we observed that
1109
            // HTTPRepository's RDF4J-transaction protocol silently no-op'd cross-graph
1110
            // SPARQL UPDATEs with UNION sub-patterns inside conn.begin()/commit(),
1111
            // while the same UPDATE POSTed directly to /statements applied correctly.
1112
            // A bare prepareUpdate().execute() takes the direct /statements path and
1113
            // runs the UPDATE atomically per SPARQL 1.1 semantics — which is all we
1114
            // need; there's nothing else to commit atomically alongside the UPDATE.
1115
            try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
1116
                conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
15✔
1117
            }
1118
            long after = graphSize(graph);
12✔
1119
            long added = after - before;
12✔
1120
            if (added <= 0) break;
15✔
1121
            total += added;
18✔
1122
            before = after;
6✔
1123
        }
3✔
1124
        return total;
6✔
1125
    }
1126

1127
    private long graphSize(IRI graph) {
1128
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
1129
            return conn.size(graph);
33✔
1130
        }
1131
    }
1132

1133
    /**
1134
     * Distinct-subject totals in the given space-state graph, broken down by
1135
     * RoleInstantiation kind (admin-pinned vs not) and RoleAssignment.
1136
     * Three SELECT-COUNT queries — cheap, called once per build/cycle for
1137
     * the user-facing log line. Returns zeros on failure (logged) so a flaky
1138
     * count read can't wedge the cycle.
1139
     */
1140
    TierSubjectTotals computeTierSubjectTotals(IRI graph) {
1141
        long adminRIs       = countDistinctSubjects(graph, """
18✔
1142
                ?ri a gen:RoleInstantiation ; npa:inverseProperty gen:hasAdmin .
1143
                """, "ri");
1144
        long attachmentRAs  = countDistinctSubjects(graph, """
18✔
1145
                ?ra a gen:RoleAssignment .
1146
                """, "ra");
1147
        long nonAdminRIs    = countDistinctSubjects(graph, """
18✔
1148
                ?ri a gen:RoleInstantiation .
1149
                FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
1150
                """, "ri");
1151
        return new TierSubjectTotals(adminRIs, attachmentRAs, nonAdminRIs);
21✔
1152
    }
1153

1154
    private long countDistinctSubjects(IRI graph, String wherePattern, String varName) {
1155
        String query = String.format("""
75✔
1156
                PREFIX npa: <%1$s>
1157
                PREFIX gen: <%2$s>
1158
                SELECT (COUNT(DISTINCT ?%3$s) AS ?n) WHERE {
1159
                  GRAPH <%4$s> {
1160
                    %5$s
1161
                  }
1162
                }
1163
                """, NPA.NAMESPACE, GEN.NAMESPACE, varName, graph, wherePattern);
1164
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
12✔
1165
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18✔
1166
            if (!r.hasNext()) return 0;
9!
1167
            return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
33✔
1168
        } catch (Exception ex) {
3!
1169
            logger.warn("AuthorityResolver: countDistinctSubjects on {} failed: {}",
15✔
1170
                    graph, ex.toString());
3✔
1171
            return 0;
6✔
1172
        }
1173
    }
1174

1175
    // ---------------- SPARQL templates ----------------
1176

1177
    /**
1178
     * Reusable invalidation filter on a bound nanopub-IRI variable. Pass the bare
1179
     * variable name (no leading {@code ?}); e.g. {@code invalidationFilter("np")}
1180
     * produces an outer-scoped {@code FILTER NOT EXISTS { GRAPH npa:graph
1181
     * { ?_inv_np npx:invalidates ?np . } }}.
1182
     *
1183
     * <p>Joins on the raw {@code npx:invalidates} triple in {@code npa:graph},
1184
     * which {@link com.knowledgepixels.query.NanopubLoader} writes into the
1185
     * spaces repo from two complementary directions, making the filter symmetric
1186
     * in load order:
1187
     * <ul>
1188
     *   <li>At the invalidator's own load: the loader's space-repo trigger fires
1189
     *       whenever the nanopub has either its own space-relevant extractions
1190
     *       OR an {@code npx:invalidates}/{@code npx:retracts}/{@code npx:supersedes}
1191
     *       triple, so a pure-retraction nanopub still lands its raw triple plus
1192
     *       {@code npa:hasLoadNumber} stamp in {@code npa:graph}.</li>
1193
     *   <li>At the invalidated target's load (when the invalidator landed
1194
     *       earlier): {@code NanopubLoader.getInvalidatingStatements} reads the
1195
     *       triple back from the meta repo and mirrors it into the target's own
1196
     *       write to the spaces repo.</li>
1197
     * </ul>
1198
     *
1199
     * <p>The earlier shape joined on a structured {@code npa:Invalidation} entry
1200
     * in {@code npa:spacesGraph} that was only emitted on the invalidator's side
1201
     * AND only when the invalidated target's meta had already loaded, leaving a
1202
     * window where a superseding nanopub loaded before its target produced no
1203
     * entry and the stale row was never filtered out (see also the matching
1204
     * change in the tier-specific {@code *InvalidationCheckWhere}/{@code
1205
     * *InvalidationDelete} templates below).
1206
     *
1207
     * <p>Important: this filter must be placed OUTSIDE the surrounding
1208
     * {@code GRAPH npa:spacesGraph { ... }} block, not nested inside it. When
1209
     * nested, RDF4J's planner couples the FILTER NOT EXISTS evaluation into the
1210
     * join order (per-row scan multiplied by the candidate set), which we
1211
     * measured turning a 39ms query into a 60s+ timeout on the live observer-tier
1212
     * data. Outside the GRAPH block, the planner defers the filter until
1213
     * {@code ?np}/{@code ?rdNp} are bound and does a targeted index lookup.
1214
     *
1215
     * <p>Variable names must match {@code [A-Za-z0-9_]+} per SPARQL grammar —
1216
     * embedding a {@code ?} inside {@code ?_inv_?np} would yield a parse error.
1217
     */
1218
    private static String invalidationFilter(String bareVarName) {
1219
        return "FILTER NOT EXISTS { GRAPH <" + NPA.GRAPH + "> {"
30✔
1220
                + " ?_inv_" + bareVarName
1221
                + " <" + NPX.INVALIDATES + "> ?" + bareVarName + " . "
1222
                + samePublisherClause("_inv_" + bareVarName, bareVarName)
6✔
1223
                + " } }";
1224
    }
1225

1226
    /**
1227
     * SPARQL triple pair (placed inside a {@code GRAPH npa:graph { ... }} block)
1228
     * requiring the invalidating nanopub and its target to share a signing public
1229
     * key — the self-retraction authority gate for issue #112. Without it, the
1230
     * materializer honors {@code npx:invalidates}/{@code retracts}/{@code supersedes}
1231
     * from <em>any</em> validly-signed nanopub, so any agent can erase another
1232
     * space's materialized state (griefing/DoS of the view — fail-closed, no
1233
     * privilege escalation, but real). Additions are already admin-gated; this is
1234
     * the symmetric gate on removals.
1235
     *
1236
     * <p>Both {@code npa:hasValidSignatureForPublicKeyHash} triples live in
1237
     * {@code npa:graph} of the spaces repo: the target via its own space-load, the
1238
     * invalidator via the symmetric retractor propagation in
1239
     * {@link com.knowledgepixels.query.NanopubLoader} (forward {@code
1240
     * loadInvalidateStatements} + reverse {@code loadInvalidatorIntoSpacesRepo}),
1241
     * so the join is populated regardless of load order.
1242
     *
1243
     * <p>"Same pubkey" is intentionally stricter than "same agent": a retraction
1244
     * signed by a different key the author owns (key rotation) is not honored, and
1245
     * cross-admin supersession is out of scope here (would need an admin-authority
1246
     * arm). The pubkey-bridge variable is suffixed with {@code targetVar} so two
1247
     * filters in one query (e.g. on {@code ?np} and {@code ?rdNp}) don't collide.
1248
     *
1249
     * @param invVar    invalidator nanopub variable name (no leading {@code ?})
1250
     * @param targetVar invalidated-target nanopub variable name (no leading {@code ?})
1251
     */
1252
    private static String samePublisherClause(String invVar, String targetVar) {
1253
        String pk = "?_invpk_" + targetVar;
9✔
1254
        return "?" + invVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " . "
30✔
1255
                + "?" + targetVar + " <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH + "> " + pk + " .";
1256
    }
1257

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

1386
    /**
1387
     * Seed-survival filter for the admin tier (issue #110). The {@code hasRootAdmin}
1388
     * seed is anchored to the root NPID, which is the immutable space-ref identity, so
1389
     * it must survive supersession of the root <em>nanopub</em> by a continuation
1390
     * revision (a later definition re-roots to the same ref via
1391
     * {@code gen:hasRootDefinition} and so carries no {@code hasRootAdmin} of its own).
1392
     * The previous {@code invalidationFilter("defNp")} dropped the seed the moment the
1393
     * root revision was superseded, leaving the whole admin closure — and everything
1394
     * cascading from it — unmaterialized for any space whose definition had ever been
1395
     * updated.
1396
     *
1397
     * <p>Expressed positively: the seed survives iff the space ref still has at least
1398
     * one non-invalidated {@link SpacesVocab#SPACE_DEFINITION}. A fully-retracted ref
1399
     * (every definition invalidated) has no live definition, so the {@code FILTER
1400
     * EXISTS} fails and the seed correctly disappears. Anchored on the already-bound
1401
     * {@code ?spaceRef}, so it's a targeted lookup over that ref's (few) definitions.
1402
     */
1403
    private static String spaceRefAliveFilter() {
1404
        return """
33✔
1405
                FILTER EXISTS {
1406
                  GRAPH <%1$s> {
1407
                    ?liveDef a npa:SpaceDefinition ;
1408
                             npa:forSpaceRef ?spaceRef ;
1409
                             npa:viaNanopub  ?liveNp .
1410
                  }
1411
                  %2$s
1412
                }
1413
                """.formatted(SpacesVocab.SPACES_GRAPH, invalidationFilter("liveNp"));
9✔
1414
    }
1415

1416
    /**
1417
     * {@code gen:hasRole} attachment validation: an attachment is validated iff its
1418
     * publisher is already a validated admin of the target space. Adds
1419
     * {@code gen:RoleAssignment} rows to the space-state graph.
1420
     */
1421
    static String attachmentValidationUpdate(IRI graph, long lastProcessed) {
1422
        // Ref-keyed (see doc/design-spaceref-isolation.md). The attachment names a bare
1423
        // Space IRI; it is validated per-ref for every ref of that IRI whose admin set
1424
        // contains the publisher (direct), or — when the named IRI is an owl:sameAs alias
1425
        // — for the canonical ref it maps to (issue #113). ?targetRef is the ref the
1426
        // RoleAssignment attaches to; the inserted subject is minted per (?ra, ?targetRef)
1427
        // so one attachment validating into N refs yields N distinct rows.
1428
        // TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace (the attached IRI, possibly an
1429
        // alias) is kept so the non-admin tier can probe the IRI-keyed instantiations
1430
        // naming it, and so pre-ref read queries keep functioning on a mixed-version fleet.
1431
        return """
69✔
1432
                PREFIX npa:  <%1$s>
1433
                PREFIX gen:  <%2$s>
1434
                INSERT { GRAPH <%3$s> {
1435
                  ?ra2 a gen:RoleAssignment ;
1436
                       npa:forSpaceRef ?targetRef ;
1437
                       npa:forSpace ?space ;
1438
                       gen:hasRole  ?role ;
1439
                       npa:viaNanopub ?np .
1440
                } }
1441
                WHERE {
1442
                  GRAPH <%4$s> {
1443
                    ?ra a gen:RoleAssignment ;
1444
                        npa:forSpace ?space ;
1445
                        gen:hasRole  ?role ;
1446
                        npa:pubkeyHash ?pkh ;
1447
                        npa:viaNanopub ?np .
1448
                    # Attachment timestamp for the detachment latest-wins (issue #129).
1449
                    OPTIONAL { ?ra <http://purl.org/dc/terms/created> ?attCreatedRaw . }
1450
                  }
1451
                  BIND(COALESCE(?attCreatedRaw, %8$s) AS ?attCreated)
1452
                  GRAPH <%7$s> {
1453
                    ?np npa:hasLoadNumber ?ln .
1454
                    FILTER (?ln > %5$d)
1455
                  }
1456
                  GRAPH <%3$s> {
1457
                    ?acct a npa:AccountState ;
1458
                          npa:agent  ?publisher ;
1459
                          npa:pubkey ?pkh .
1460
                  }
1461
                  # Per-ref admin gate. ?targetRef = a ref of ?space the publisher admins
1462
                  # (direct), or the canonical ref ?space is an owl:sameAs alias of.
1463
                  {
1464
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?space . }
1465
                    GRAPH <%3$s> {
1466
                      ?adminRI a gen:RoleInstantiation ;
1467
                               npa:forSpaceRef ?targetRef ;
1468
                               npa:inverseProperty gen:hasAdmin ;
1469
                               npa:forAgent ?publisher .
1470
                    }
1471
                  }
1472
                  UNION
1473
                  {
1474
                    GRAPH <%3$s> {
1475
                      ?space npa:sameAsSpace ?targetRef .
1476
                      ?adminRI a gen:RoleInstantiation ;
1477
                               npa:forSpaceRef ?targetRef ;
1478
                               npa:inverseProperty gen:hasAdmin ;
1479
                               npa:forAgent ?publisher .
1480
                    }
1481
                  }
1482
                  BIND(IRI(CONCAT(STR(?ra), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?ra2)
1483
                  %6$s
1484
                  # Detachment latest-wins (issue #129): suppress if a newer admin-authored
1485
                  # gen:detachedRole out-ranks this (ref, role) attachment.
1486
                  %9$s
1487
                  FILTER NOT EXISTS { GRAPH <%3$s> {
1488
                    ?existing a gen:RoleAssignment ;
1489
                              npa:forSpaceRef ?targetRef ;
1490
                              gen:hasRole  ?role .
1491
                  } }
1492
                }
1493
                """.formatted(
3✔
1494
                NPA.NAMESPACE,
1495
                GEN.NAMESPACE,
1496
                graph,
1497
                SpacesVocab.SPACES_GRAPH,
1498
                lastProcessed,
15✔
1499
                invalidationFilter("np"),
45✔
1500
                NPA.GRAPH,
1501
                EPOCH_DT,
1502
                roleDetachmentSuppressionFilter(graph, "attCreated", "ra"));
6✔
1503
    }
1504

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

1675
    /**
1676
     * Stamps a ref-scoped, admin-validated mirror of each {@code npa:PresetAssignment}
1677
     * into the state graph (issue #122). The publisher-agnostic extraction row
1678
     * ({@link SpacesExtractor#extractPresetAssignment}) is keyed only by
1679
     * {@code npa:forResource}, so a consumer listing a space's preset assignments by IRI
1680
     * sees the union across <em>all</em> refs claiming that IRI. This stamp adds
1681
     * {@code npa:forSpaceRef ?targetRef} so the "Assigned presets" listing is no longer
1682
     * merged across refs of the same IRI — the one remaining About-tab listing that still
1683
     * merged across refs (every other ref-scoped listing already has a {@code forSpaceRef}
1684
     * companion).
1685
     *
1686
     * <p>Faithful per-assignment mirror — deliberately <em>not</em> role-gated and
1687
     * <em>not</em> latest-wins-resolved, unlike {@link #presetAttachmentValidationUpdate}:
1688
     * <ul>
1689
     *   <li>No {@code npa:PresetDeclaration}/role join, so a preset that bundles only
1690
     *       <em>views</em> (no roles) is still listed.</li>
1691
     *   <li>Emits active <em>and</em> deactivated rows (carries {@code npa:isActivated})
1692
     *       so the listing can show state; a deactivation is just a newer admin-authored
1693
     *       row, so no {@code dct:created}-driven removal is needed here (contrast §4.4).</li>
1694
     *   <li>Latest-wins is deferred to the consumer query, which ranges only over these
1695
     *       admin-authored rows — so it is authorization-scoped for free (design §3): a
1696
     *       non-admin of the ref can never get a row stamped, so it cannot enter the
1697
     *       latest-wins race.</li>
1698
     * </ul>
1699
     *
1700
     * <p>Display-only leaf: nothing downstream derives from these rows (contrast the
1701
     * preset-derived {@code gen:RoleAssignment}), so the caller must <em>not</em> feed this
1702
     * tier's count into {@code structuralAdds}. The {@code npa:forSpaceRef} predicate also
1703
     * distinguishes a stamped row from the IRI-keyed extraction row (which never carries it),
1704
     * so {@link #presetAssignmentRefInvalidationDelete} can target exactly these rows.
1705
     * Reuses steps 1–4 of {@link #presetAttachmentValidationUpdate}; see
1706
     * doc/design-preset-role-materialization.md §3 and issue #122.
1707
     */
1708
    static String presetAssignmentRefStampUpdate(IRI graph, long lastProcessed) {
1709
        return """
69✔
1710
                PREFIX npa:  <%1$s>
1711
                PREFIX gen:  <%2$s>
1712
                INSERT { GRAPH <%3$s> {
1713
                  ?paRef a npa:PresetAssignment ;
1714
                         npa:ofPreset    ?preset ;
1715
                         npa:forResource ?resource ;
1716
                         npa:forSpaceRef ?targetRef ;
1717
                         npa:isActivated ?activated ;
1718
                         npa:viaNanopub  ?assignNp ;
1719
                         <http://purl.org/dc/terms/created> ?created .
1720
                } }
1721
                WHERE {
1722
                  # 1. Anchor: every assignment row (active or not) in the extraction graph.
1723
                  GRAPH <%4$s> {
1724
                    ?pa a npa:PresetAssignment ;
1725
                        npa:ofPreset    ?preset ;
1726
                        npa:forResource ?resource ;
1727
                        npa:isActivated ?activated ;
1728
                        npa:pubkeyHash  ?pkh ;
1729
                        npa:viaNanopub  ?assignNp ;
1730
                        <http://purl.org/dc/terms/created> ?created .
1731
                  }
1732
                  # 2. Load-number filter on the assignment nanopub (delta window).
1733
                  GRAPH <%6$s> {
1734
                    ?assignNp npa:hasLoadNumber ?ln .
1735
                    FILTER (?ln > %5$d)
1736
                  }
1737
                  # 3. Resolve publisher pkh -> agent via the mirrored trust-approved row.
1738
                  GRAPH <%3$s> {
1739
                    ?acct a npa:AccountState ;
1740
                          npa:agent  ?publisher ;
1741
                          npa:pubkey ?pkh .
1742
                  }
1743
                  # 4. Target must be a Space ref the publisher admins. ?targetRef = that ref;
1744
                  #    fan-out to N refs the publisher admins (per-ref isolation, consistent
1745
                  #    with the role materializer and design-spaceref-isolation.md). Direct,
1746
                  #    or the canonical ref ?resource is an owl:sameAs alias of (issue #113),
1747
                  #    so an assignment naming an alias is still listed under the canonical ref.
1748
                  {
1749
                    GRAPH <%4$s> { ?targetRef npa:spaceIri ?resource . }
1750
                    GRAPH <%3$s> {
1751
                      ?adminRI a gen:RoleInstantiation ;
1752
                               npa:forSpaceRef ?targetRef ;
1753
                               npa:inverseProperty gen:hasAdmin ;
1754
                               npa:forAgent ?publisher .
1755
                    }
1756
                  }
1757
                  UNION
1758
                  {
1759
                    GRAPH <%3$s> {
1760
                      ?resource npa:sameAsSpace ?targetRef .
1761
                      ?adminRI a gen:RoleInstantiation ;
1762
                               npa:forSpaceRef ?targetRef ;
1763
                               npa:inverseProperty gen:hasAdmin ;
1764
                               npa:forAgent ?publisher .
1765
                    }
1766
                  }
1767
                  # 5. Defensive: drop if the assignment nanopub itself was hard-retracted.
1768
                  %7$s
1769
                  # 6. Mint per (assignment, ref); dedup on the bound subject. No latest-wins
1770
                  #    here — a deactivation is just a newer admin-authored row, and the
1771
                  #    consumer resolves latest dct:created per (preset,resource) over these
1772
                  #    admin-authored rows (so the resolution is authorization-scoped).
1773
                  BIND(IRI(CONCAT(STR(?pa), "__", ENCODE_FOR_URI(STR(?targetRef)))) AS ?paRef)
1774
                  FILTER NOT EXISTS { GRAPH <%3$s> { ?paRef a npa:PresetAssignment . } }
1775
                }
1776
                """.formatted(
3✔
1777
                NPA.NAMESPACE,
1778
                GEN.NAMESPACE,
1779
                graph,
1780
                SpacesVocab.SPACES_GRAPH,
1781
                lastProcessed,
27✔
1782
                NPA.GRAPH,
1783
                invalidationFilter("assignNp"));
6✔
1784
    }
1785

1786
    /**
1787
     * Non-admin tier publisher constraints (inserted as a SPARQL sub-pattern).
1788
     * Each constraint owns the AccountState (pkh → agent) lookup so the join
1789
     * variable is bound through a targeted pattern. The observer-self variant
1790
     * binds {@code npa:agent ?agent} directly — no separate {@code ?publisher}
1791
     * variable, no post-join equality filter — which lets the planner anchor
1792
     * the AccountState lookup on the already-bound {@code ?agent} instead of
1793
     * enumerating all approved publishers and filtering at the end.
1794
     */
1795
    static final String PUBLISHER_IS_ADMIN = """
1796
            ?acct a npa:AccountState ;
1797
                  npa:pubkey ?pkh ;
1798
                  npa:agent  ?publisher .
1799
            # Admin of the assignment's ref. The ref already resolves alias →
1800
            # canonical (the attachment tier bound ?spaceRef through the owl:sameAs
1801
            # alias edge for aliased IRIs, issue #113), so no alias arm is needed here.
1802
            ?adminRI a gen:RoleInstantiation ;
1803
                     npa:forSpaceRef ?spaceRef ;
1804
                     npa:inverseProperty gen:hasAdmin ;
1805
                     npa:forAgent ?publisher .
1806
            """;
1807

1808
    /** Observer self-evidence: the assignee's own pubkey signed the instantiation. */
1809
    static final String PUBLISHER_IS_SELF = """
1810
            ?acct a npa:AccountState ;
1811
                  npa:pubkey ?pkh ;
1812
                  npa:agent  ?agent .
1813
            """;
1814

1815
    /**
1816
     * Observer self-evidence from a not-yet-approved account (issue #195): same shape,
1817
     * but resolved through the pending row {@link #mirrorPendingAccounts} wrote.
1818
     *
1819
     * <p>Safe only because the observer tier is self-assignable by the tier model — the
1820
     * grant carries no authority, and {@code ?agent} is already bound by the
1821
     * instantiation, so this can never bind a pending key to a <em>different</em> agent.
1822
     * It is the single place in this class where a non-approved row is accepted; every
1823
     * other constraint keeps matching {@code npa:AccountState}.
1824
     */
1825
    static final String PUBLISHER_IS_SELF_PENDING = """
1826
            ?acct a npa:PendingAccountState ;
1827
                  npa:pubkey ?pkh ;
1828
                  npa:agent  ?agent .
1829
            """;
1830

1831
    /**
1832
     * Extra INSERT triples stamped on roles materialized through a pending account, so
1833
     * read queries can render them as "pending approval" with a one-triple OPTIONAL
1834
     * instead of re-deriving the account's status.
1835
     */
1836
    static final String PENDING_ROLE_STAMP = "npa:trustStatus npa:seen ;\n                       ";
1837

1838
    /**
1839
     * Maintainer / Member / Observer tier INSERT. Same shape: find an instantiation
1840
     * whose predicate matches a RoleDeclaration of the given tier attached to the
1841
     * target space, and whose publisher passes the tier-specific constraint.
1842
     */
1843
    static String nonAdminTierUpdate(IRI graph, long lastProcessed,
1844
                                     IRI tierClass, String publisherConstraint) {
1845
        return nonAdminTierUpdate(graph, lastProcessed, tierClass, publisherConstraint, "");
21✔
1846
    }
1847

1848
    /**
1849
     * Variant that stamps {@code extraInsert} onto every materialized row — used by the
1850
     * pending-account observer pass to mark the row as awaiting trust approval.
1851
     */
1852
    static String nonAdminTierUpdate(IRI graph, long lastProcessed, IRI tierClass,
1853
                                     String publisherConstraint, String extraInsert) {
1854
        // Order tuned for RDF4J's evaluator (which executes BGPs roughly in order).
1855
        // The crucial choice is the *anchor*: instantiation-first plans send the
1856
        // planner exploring the full ~thousands of candidate RIs and only filter
1857
        // by tier at the very end. Attachment-first anchors on the small set of
1858
        // gen:RoleAssignment rows already validated in this space-state graph
1859
        // (~hundreds, often zero) and walks outward by bound (?role, ?space).
1860
        //
1861
        //   1. Anchor on RoleAssignments in this space-state graph (small).
1862
        //   1a. Resolve the IRIs that denote the assignment's ref — its canonical
1863
        //      IRI plus any validated owl:sameAs aliases — so an instantiation that
1864
        //      names an alias of the space still matches (issue #113). Bound here so
1865
        //      the instantiation lookup below stays anchored by ?instSpace.
1866
        //   2. Match the tier-pinned RoleDeclaration by ?role.
1867
        //   3. Pair role-decl direction to instantiation direction in one UNION
1868
        //      so only (reg, reg)/(inv, inv) combos are explored.
1869
        //   4. Targeted instantiation lookup — (?instSpace, ?pred) are bound.
1870
        //   5. Publisher constraint (incl. AccountState resolution).
1871
        //   6. Load-number filter on bound ?np.
1872
        //   7. Dedup at the end.
1873
        return """
69✔
1874
                PREFIX npa:  <%1$s>
1875
                PREFIX gen:  <%2$s>
1876
                INSERT { GRAPH <%3$s> {
1877
                  ?ri2 a gen:RoleInstantiation ;
1878
                       npa:forSpaceRef ?spaceRef ;
1879
                       # TRANSITIONAL-DUAL-EMIT (Phase 4: remove): forSpace alongside
1880
                       # forSpaceRef so pre-ref read queries (e.g. get-space-members) keep
1881
                       # functioning on a mixed-version fleet; downstream tiers key on the ref.
1882
                       npa:forSpace ?space ;
1883
                       npa:forAgent ?agent ;
1884
                       ?dirPred ?pred ;
1885
                       # Persist the tier and role IRI that are already bound at this point —
1886
                       # the loop's tierClass arg (%7$s) and the anchoring attachment's ?role
1887
                       # (step 1) — so ref-scoped consumers key on identity rather than
1888
                       # re-deriving the tier from the bare predicate against GLOBAL
1889
                       # RoleDeclarations. The bare-predicate re-derivation bleeds tiers
1890
                       # across spaces that declare the same predicate at different tiers
1891
                       # (issue #125): consumers should match ?ri2 npa:hasRoleType <tier>
1892
                       # / gen:hasRole ?role, exactly as the *-roles-ref queries do.
1893
                       npa:hasRoleType <%7$s> ;
1894
                       gen:hasRole ?role ;
1895
                       %12$snpa:viaNanopub ?np .
1896
                } }
1897
                WHERE {
1898
                  # 1. Anchor: validated attachments in this space-state graph (ref-keyed).
1899
                  GRAPH <%3$s> {
1900
                    ?ra a gen:RoleAssignment ;
1901
                        gen:hasRole     ?role ;
1902
                        npa:forSpaceRef ?spaceRef ;
1903
                        npa:forSpace    ?space .
1904
                  }
1905
                  # 1a. The IRIs that denote this ref: its canonical IRI, plus any validated
1906
                  #     owl:sameAs aliases of it (issue #113) — so an instantiation naming an
1907
                  #     alias of the space still materializes here. Bound BEFORE the
1908
                  #     instantiation BGP so that lookup stays anchored by ?instSpace (planner
1909
                  #     note above); ?spaceRef is already bound, so each arm is a targeted
1910
                  #     lookup yielding a tiny IRI set. The alias arm only follows admin-
1911
                  #     validated npa:sameAsSpace edges, so it grants no authority the admin
1912
                  #     tier would not (anti-hijack is enforced upstream, not relaxed here).
1913
                  {
1914
                    GRAPH <%4$s> { ?spaceRef npa:spaceIri ?instSpace . }
1915
                  }
1916
                  UNION
1917
                  {
1918
                    GRAPH <%3$s> { ?instSpace npa:sameAsSpace ?spaceRef . }
1919
                  }
1920
                  # 2. Tier-pinned RoleDeclaration (?role bound from the attachment). Its
1921
                  #    nanopub's invalidation is intentionally NOT consulted (see step 7), so
1922
                  #    no ?rdNp binding is needed.
1923
                  GRAPH <%4$s> {
1924
                    ?rd a npa:RoleDeclaration ;
1925
                        npa:hasRoleType <%7$s> ;
1926
                        npa:role        ?role .
1927
                    # 3. Pair role-decl direction to the instantiation in one UNION so only
1928
                    #    matching combos are explored, binding (?instSpace, ?agent) per arm.
1929
                    #    ?dirPred carries the resolved direction so the materialized row
1930
                    #    records the role property (read by get-space-members and
1931
                    #    publisherIsTieredRole) — identical shape whichever arm matched.
1932
                    #
1933
                    #    The first two arms handle instantiations the extractor already
1934
                    #    classified (npa:regularProperty / npa:inverseProperty). The last two
1935
                    #    resolve a custom predicate the extractor left neutral (npa:rolePredicate
1936
                    #    with raw npa:bindingSubject / npa:bindingObject): the role declaration
1937
                    #    supplies the direction, which fixes which raw endpoint is the space vs
1938
                    #    the agent. INVERSE = <space> pred <agent>; REGULAR = <agent> pred <space>.
1939
                    {
1940
                      ?rd gen:hasRegularProperty ?pred .
1941
                      ?ri npa:regularProperty ?pred ;
1942
                          npa:forSpace ?instSpace ;
1943
                          npa:forAgent ?agent .
1944
                      BIND(npa:regularProperty AS ?dirPred)
1945
                    }
1946
                    UNION
1947
                    {
1948
                      ?rd gen:hasInverseProperty ?pred .
1949
                      ?ri npa:inverseProperty ?pred ;
1950
                          npa:forSpace ?instSpace ;
1951
                          npa:forAgent ?agent .
1952
                      BIND(npa:inverseProperty AS ?dirPred)
1953
                    }
1954
                    UNION
1955
                    {
1956
                      ?rd gen:hasInverseProperty ?pred .
1957
                      ?ri npa:rolePredicate   ?pred ;
1958
                          npa:bindingSubject  ?instSpace ;
1959
                          npa:bindingObject   ?agent .
1960
                      BIND(npa:inverseProperty AS ?dirPred)
1961
                    }
1962
                    UNION
1963
                    {
1964
                      ?rd gen:hasRegularProperty ?pred .
1965
                      ?ri npa:rolePredicate   ?pred ;
1966
                          npa:bindingObject   ?instSpace ;
1967
                          npa:bindingSubject  ?agent .
1968
                      BIND(npa:regularProperty AS ?dirPred)
1969
                    }
1970
                    # 4. Common instantiation columns. ?instSpace was resolved to this ref
1971
                    #    above (canonical or owl:sameAs alias), so an alias-named instantiation
1972
                    #    joins the same ?spaceRef as a canonical one. The materialized row still
1973
                    #    carries npa:forSpace ?space (the attachment's IRI) for the transitional
1974
                    #    dual-emit, so pre-ref reads see the member under the space's primary IRI.
1975
                    ?ri a gen:RoleInstantiation ;
1976
                        npa:pubkeyHash ?pkh ;
1977
                        npa:viaNanopub ?np .
1978
                    # Candidate grant timestamp for the revocation latest-wins (issue #129);
1979
                    # absent ⇒ epoch (always loses).
1980
                    OPTIONAL { ?ri <http://purl.org/dc/terms/created> ?candCreatedRaw . }
1981
                  }
1982
                  BIND(COALESCE(?candCreatedRaw, %11$s) AS ?candCreated)
1983
                  # 5. Publisher constraint (incl. AccountState resolution).
1984
                  GRAPH <%3$s> {
1985
                    %8$s
1986
                  }
1987
                  # 5a. Mint the per-ref state subject: (?ri, ?spaceRef) → ?ri2.
1988
                  BIND(IRI(CONCAT(STR(?ri), "__", ENCODE_FOR_URI(STR(?spaceRef)))) AS ?ri2)
1989
                  # 6. Load-number filter on bound ?np.
1990
                  GRAPH <%9$s> {
1991
                    ?np npa:hasLoadNumber ?ln .
1992
                    FILTER (?ln > %5$d)
1993
                  }
1994
                  # 7. Instantiation invalidation filter — outside the GRAPH block so the
1995
                  #    planner defers it until ?np is bound. Role-DECLARATION invalidation is
1996
                  #    deliberately NOT consulted: the tier already anchors on the admin-
1997
                  #    validated attachment (?ra), which is removed when an admin retracts it,
1998
                  #    so admin control is fully enforced there. Letting the declaration's
1999
                  #    author (usually not the space admin) supersede/retract their declaration
2000
                  #    strip a space's members is the same cross-author-strip anti-pattern as
2001
                  #    issue #112. Role IRIs are version-pinned, so the attached definition is
2002
                  #    immutable regardless of the declaration nanopub's later lifecycle.
2003
                  %6$s
2004
                  # 7a. Revocation latest-wins (issue #129): suppress if a newer authorized
2005
                  #     gen:RevokedRoleInstantiation shadows this (space, agent, role) key.
2006
                  %10$s
2007
                  # 8. Dedup last — keyed on (ref, agent, nanopub).
2008
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2009
                    ?existing a gen:RoleInstantiation ;
2010
                              npa:forSpaceRef ?spaceRef ;
2011
                              npa:forAgent ?agent ;
2012
                              npa:viaNanopub ?np .
2013
                  } }
2014
                }
2015
                """.formatted(
3✔
2016
                NPA.NAMESPACE,
2017
                GEN.NAMESPACE,
2018
                graph,
2019
                SpacesVocab.SPACES_GRAPH,
2020
                lastProcessed,
15✔
2021
                invalidationFilter("np"),
54✔
2022
                tierClass,
2023
                publisherConstraint,
2024
                NPA.GRAPH,
2025
                nonAdminRevocationSuppressionFilter(graph, tierClass),
30✔
2026
                EPOCH_DT,
2027
                extraInsert);
2028
    }
2029

2030
    /**
2031
     * Sub-space admit pass. Copies validated {@code npa:SubSpaceDeclaration}
2032
     * extraction rows into the space-state graph (preserving the {@code npasub:}
2033
     * subject) and emits convenience {@code <child> npa:isSubSpaceOf <parent>} and
2034
     * {@code <parent> npa:hasSubSpace <child>} direct triples. Two satisfaction
2035
     * modes joined by UNION:
2036
     * <ul>
2037
     *   <li>Mode A — the declaration's publisher is a validated admin of both the
2038
     *       child and the parent space.</li>
2039
     *   <li>Mode B — a different non-invalidated declaration for the same
2040
     *       {@code (child, parent)} pair exists, and the two publishers between
2041
     *       them cover both admin sides (i.e. one of them is admin of the child,
2042
     *       one of them is admin of the parent — possibly the same one twice if
2043
     *       both happen to be admin of both).</li>
2044
     * </ul>
2045
     *
2046
     * <p>Mode-B late-arrival: when only the partner declaration is new in this
2047
     * cycle (the primary is older than {@code lastProcessed}), the load-number
2048
     * filter on {@code ?np} excludes the candidate. The late-arrival sweep
2049
     * ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass without the
2050
     * load filter and catches it.
2051
     */
2052
    static String subSpaceAdmitUpdate(IRI graph, long lastProcessed) {
2053
        return """
69✔
2054
                PREFIX npa: <%1$s>
2055
                PREFIX gen: <%2$s>
2056
                INSERT { GRAPH <%3$s> {
2057
                  ?d a npa:SubSpaceDeclaration ;
2058
                     npa:childSpace  ?child ;
2059
                     npa:parentSpace ?parent ;
2060
                     npa:viaNanopub  ?np .
2061
                  ?childRef  npa:isSubSpaceOf ?parentRef .
2062
                  ?parentRef npa:hasSubSpace  ?childRef  .
2063
                  # Reified per-(nanopub, ref-pair) provenance link (issue #125 finding #5):
2064
                  # carries npa:viaNanopub plus both the ref and IRI endpoints, so the
2065
                  # invalidation cleanup can drop the convenience edges below once no
2066
                  # surviving link backs them — instead of leaving them sticky until the
2067
                  # next periodic full rebuild.
2068
                  ?ssLink a npa:SubSpaceLink ;
2069
                          npa:viaNanopub     ?np ;
2070
                          npa:childSpaceRef  ?childRef ;
2071
                          npa:parentSpaceRef ?parentRef ;
2072
                          npa:childSpace     ?child ;
2073
                          npa:parentSpace    ?parent .
2074
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2075
                  # sub-space edge alongside the ref-to-ref one, so pre-ref published
2076
                  # queries that key on the bare Space IRI keep binding on a mixed-version
2077
                  # fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2078
                  ?child  npa:isSubSpaceOf ?parent .
2079
                  ?parent npa:hasSubSpace  ?child  .
2080
                } }
2081
                WHERE {
2082
                  # 1. Anchor: candidate declarations from the extraction graph.
2083
                  GRAPH <%4$s> {
2084
                    ?d a npa:SubSpaceDeclaration ;
2085
                       npa:childSpace  ?child ;
2086
                       npa:parentSpace ?parent ;
2087
                       npa:pubkeyHash  ?pkh ;
2088
                       npa:viaNanopub  ?np .
2089
                  }
2090
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
2091
                  GRAPH <%3$s> {
2092
                    ?acct a npa:AccountState ;
2093
                          npa:pubkey ?pkh ;
2094
                          npa:agent  ?publisher .
2095
                  }
2096
                  # 3. Authority gate, ref-keyed. The edge is emitted ref-to-ref between
2097
                  #    the child ref and parent ref the authorizing admin governs; the
2098
                  #    admin rows' dual-emitted npa:forSpace binds the refs to the child /
2099
                  #    parent IRIs (cross-product when an IRI has several governed refs).
2100
                  {
2101
                    # Mode A — publisher is admin of BOTH a child ref and a parent ref.
2102
                    GRAPH <%3$s> {
2103
                      ?riC a gen:RoleInstantiation ;
2104
                           npa:inverseProperty gen:hasAdmin ;
2105
                           npa:forSpace ?child ;
2106
                           npa:forSpaceRef ?childRef ;
2107
                           npa:forAgent ?publisher .
2108
                      ?riP a gen:RoleInstantiation ;
2109
                           npa:inverseProperty gen:hasAdmin ;
2110
                           npa:forSpace ?parent ;
2111
                           npa:forSpaceRef ?parentRef ;
2112
                           npa:forAgent ?publisher .
2113
                    }
2114
                  }
2115
                  UNION
2116
                  {
2117
                    # Mode B — co-declaration whose publisher covers the side this
2118
                    # one's publisher doesn't. Between {publisher, publisher2},
2119
                    # both admin sides must be covered.
2120
                    GRAPH <%4$s> {
2121
                      ?d2 a npa:SubSpaceDeclaration ;
2122
                          npa:childSpace  ?child ;
2123
                          npa:parentSpace ?parent ;
2124
                          npa:pubkeyHash  ?pkh2 ;
2125
                          npa:viaNanopub  ?np2 .
2126
                      FILTER (?np2 != ?np)
2127
                    }
2128
                    %8$s
2129
                    GRAPH <%3$s> {
2130
                      ?acct2 a npa:AccountState ;
2131
                             npa:pubkey ?pkh2 ;
2132
                             npa:agent  ?publisher2 .
2133
                      ?riA a gen:RoleInstantiation ;
2134
                           npa:inverseProperty gen:hasAdmin ;
2135
                           npa:forSpace ?child ;
2136
                           npa:forSpaceRef ?childRef .
2137
                      { ?riA npa:forAgent ?publisher } UNION { ?riA npa:forAgent ?publisher2 }
2138
                      ?riB a gen:RoleInstantiation ;
2139
                           npa:inverseProperty gen:hasAdmin ;
2140
                           npa:forSpace ?parent ;
2141
                           npa:forSpaceRef ?parentRef .
2142
                      { ?riB npa:forAgent ?publisher } UNION { ?riB npa:forAgent ?publisher2 }
2143
                    }
2144
                  }
2145
                  # 4. Invalidation filter on the primary declaration's nanopub.
2146
                  %6$s
2147
                  # 5. Load-number filter on bound ?np.
2148
                  GRAPH <%7$s> {
2149
                    ?np npa:hasLoadNumber ?ln .
2150
                    FILTER (?ln > %5$d)
2151
                  }
2152
                  # 6. Mint the per-(nanopub, ref-pair) provenance link IRI and dedup on it
2153
                  #    (not on the bare edge). Keyed on ?np so every backing declaration of
2154
                  #    the same ref-pair records its own removable link; the convenience
2155
                  #    edges above are re-asserted idempotently.
2156
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/subspace/",
2157
                                  MD5(CONCAT(STR(?np), "|", STR(?childRef), "|", STR(?parentRef))))) AS ?ssLink)
2158
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2159
                    ?ssLink a npa:SubSpaceLink .
2160
                  } }
2161
                }
2162
                """.formatted(
3✔
2163
                NPA.NAMESPACE,
2164
                GEN.NAMESPACE,
2165
                graph,
2166
                SpacesVocab.SPACES_GRAPH,
2167
                lastProcessed,
15✔
2168
                invalidationFilter("np"),
27✔
2169
                NPA.GRAPH,
2170
                invalidationFilter("np2"));
6✔
2171
    }
2172

2173
    /**
2174
     * Maintained-resource admit pass. Copies validated
2175
     * {@code npa:MaintainedResourceDeclaration} extraction rows into the space-state
2176
     * graph (preserving the {@code npamrd:} subject) and emits convenience
2177
     * {@code <r> npa:isMaintainedBy <s>} and {@code <s> npa:hasMaintainedResource <r>}
2178
     * direct triples. Single satisfaction mode:
2179
     * <ul>
2180
     *   <li>Mode A — the declaration's publisher is a validated admin of the
2181
     *       maintaining space.</li>
2182
     * </ul>
2183
     *
2184
     * <p>No Mode B because only one space is involved; the two-sides-must-be-covered
2185
     * concern that drives sub-space Mode B doesn't apply. Late-arrival is still
2186
     * possible (declaration lands before the publisher's admin grant becomes valid):
2187
     * the load-number filter on {@code ?np} excludes the candidate, and the
2188
     * late-arrival sweep ({@link #runDownstreamWithoutLoadFilter}) re-runs this pass
2189
     * without the load filter and catches it.
2190
     */
2191
    static String maintainedResourceAdmitUpdate(IRI graph, long lastProcessed) {
2192
        return """
69✔
2193
                PREFIX npa: <%1$s>
2194
                PREFIX gen: <%2$s>
2195
                INSERT { GRAPH <%3$s> {
2196
                  ?d a npa:MaintainedResourceDeclaration ;
2197
                     npa:resourceIri     ?r ;
2198
                     npa:maintainerSpace ?s ;
2199
                     npa:viaNanopub      ?np .
2200
                  ?r npa:isMaintainedBy        ?sRef .
2201
                  ?sRef npa:hasMaintainedResource ?r .
2202
                  # Uniform ref-valued resource→governing-space-ref edge (issue #130). The
2203
                  # same predicate the reflexive space self-edge uses, so a single consumer
2204
                  # hop covers both "resource maintained by space S" and "resource IS a space".
2205
                  # Backed by the same MaintainedResourceLink below, so the invalidation
2206
                  # cleanup sweeps it alongside isMaintainedBy.
2207
                  ?r npa:hasGoverningSpaceRef  ?sRef .
2208
                  # Reified per-(nanopub, resource→ref) provenance link (issue #125 finding
2209
                  # #5): lets the invalidation cleanup drop the convenience edges below once
2210
                  # no surviving link backs them, instead of leaving them sticky.
2211
                  ?mrLink a npa:MaintainedResourceLink ;
2212
                          npa:viaNanopub         ?np ;
2213
                          npa:resourceIri        ?r ;
2214
                          npa:maintainerSpaceRef ?sRef ;
2215
                          npa:maintainerSpace    ?s .
2216
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2217
                  # maintained-resource edge alongside the resource→ref one, so pre-ref
2218
                  # published queries (e.g. get-view-displays' maintained hop) keep binding
2219
                  # on a mixed-version fleet. This is the edge whose absence broke 1.15.0 —
2220
                  # see doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2221
                  ?r npa:isMaintainedBy        ?s .
2222
                  ?s npa:hasMaintainedResource ?r .
2223
                } }
2224
                WHERE {
2225
                  # 1. Anchor: candidate declarations from the extraction graph.
2226
                  GRAPH <%4$s> {
2227
                    ?d a npa:MaintainedResourceDeclaration ;
2228
                       npa:resourceIri     ?r ;
2229
                       npa:maintainerSpace ?s ;
2230
                       npa:pubkeyHash      ?pkh ;
2231
                       npa:viaNanopub      ?np .
2232
                  }
2233
                  # 2. Mirror: resolve ?pkh → ?publisher via the trust-approved row.
2234
                  GRAPH <%3$s> {
2235
                    ?acct a npa:AccountState ;
2236
                          npa:pubkey ?pkh ;
2237
                          npa:agent  ?publisher .
2238
                    # 3. Authority gate (Mode A only): publisher is admin of a ref of the
2239
                    #    maintaining space. ?sRef = that ref (resource → ref edge).
2240
                    ?riA a gen:RoleInstantiation ;
2241
                         npa:inverseProperty gen:hasAdmin ;
2242
                         npa:forSpace ?s ;
2243
                         npa:forSpaceRef ?sRef ;
2244
                         npa:forAgent ?publisher .
2245
                  }
2246
                  # 4. Invalidation filter on the declaration's nanopub.
2247
                  %6$s
2248
                  # 5. Load-number filter on bound ?np.
2249
                  GRAPH <%7$s> {
2250
                    ?np npa:hasLoadNumber ?ln .
2251
                    FILTER (?ln > %5$d)
2252
                  }
2253
                  # 6. Mint the per-(nanopub, resource→ref) provenance link IRI and dedup on
2254
                  #    it (not on the bare edge), so every backing declaration records its own
2255
                  #    removable link; the convenience edges above are re-asserted idempotently.
2256
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/maintained/",
2257
                                  MD5(CONCAT(STR(?np), "|", STR(?r), "|", STR(?sRef))))) AS ?mrLink)
2258
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2259
                    ?mrLink a npa:MaintainedResourceLink .
2260
                  } }
2261
                }
2262
                """.formatted(
3✔
2263
                NPA.NAMESPACE,
2264
                GEN.NAMESPACE,
2265
                graph,
2266
                SpacesVocab.SPACES_GRAPH,
2267
                lastProcessed,
15✔
2268
                invalidationFilter("np"),
18✔
2269
                NPA.GRAPH);
2270
    }
2271

2272
    /**
2273
     * Space-alias admit pass (issue #113). Copies validated
2274
     * {@code npa:SpaceAliasDeclaration} extraction rows into the space-state graph
2275
     * (preserving the {@code npaalias:} subject) and emits the directional
2276
     * {@code <alias> npa:sameAsSpace <canonical>} edge consumed by the alias-aware
2277
     * admin-authority lookups in {@link #attachmentValidationUpdate},
2278
     * {@link #PUBLISHER_IS_ADMIN}, and {@link #publisherIsTieredRole}.
2279
     *
2280
     * <p>Two gates, both read against the (already-settled) admin closure in the
2281
     * space-state graph:
2282
     * <ul>
2283
     *   <li><b>Authority</b> — the declaration's publisher (resolved via the mirrored
2284
     *       trust-approved {@code AccountState}) is a validated admin of the
2285
     *       <em>canonical</em> space. The alias is declared inside the canonical
2286
     *       space's own {@code gen:Space} nanopub, so this is the same evidence rule
2287
     *       as a {@code gen:hasRole} attachment.</li>
2288
     *   <li><b>Anti-hijack</b> — the alias must not be an independently-governed live
2289
     *       space: it must have no admin who is not also an admin of the canonical
2290
     *       space ({@code admins(alias) ⊆ admins(canonical)}). The common rename case
2291
     *       (the alias's own definition was superseded, so it has no live admin
2292
     *       closure) passes trivially; an attacker publishing
2293
     *       {@code <evil> owl:sameAs <activeSpace>} is rejected because the active
2294
     *       space has admins not in evil's set.</li>
2295
     * </ul>
2296
     *
2297
     * <p>Late-arrival: when the canonical admin grant only becomes valid in the same
2298
     * cycle as the declaration, the load-number filter on {@code ?np} excludes the
2299
     * candidate; the late-arrival sweep ({@link #runDownstreamWithoutLoadFilter})
2300
     * re-runs this pass without the load filter and catches it.
2301
     */
2302
    static String aliasAdmitUpdate(IRI graph, long lastProcessed) {
2303
        // Ref-keyed (see doc/design-spaceref-isolation.md). The declaration names bare
2304
        // canonical/alias IRIs. It is admitted per canonical *ref* whose admin set
2305
        // contains the publisher; the emitted edge is ref-valued on the canonical side
2306
        // (<alias> npa:sameAsSpace <canonicalRef>), which is what the alias-aware admin
2307
        // lookups in the attachment tier consume. Anti-hijack compares the alias IRI's
2308
        // admins against that specific canonical ref's admins — strictly tighter than the
2309
        // old bare-IRI form.
2310
        return """
69✔
2311
                PREFIX npa: <%1$s>
2312
                PREFIX gen: <%2$s>
2313
                INSERT { GRAPH <%3$s> {
2314
                  ?d a npa:SpaceAliasDeclaration ;
2315
                     npa:canonicalSpace ?canonical ;
2316
                     npa:aliasSpace     ?alias ;
2317
                     npa:viaNanopub     ?np .
2318
                  ?alias npa:sameAsSpace ?canonRef .
2319
                  # Reified per-(nanopub, alias→canonical ref) provenance link (issue #125
2320
                  # finding #5). The alias edge feeds the admin-authority closure, so this
2321
                  # is the load-bearing case: the cleanup can now drop the edge when its
2322
                  # declaration is invalidated, rather than letting admin authority outlive
2323
                  # a retraction until the next periodic full rebuild.
2324
                  ?alLink a npa:SpaceAliasLink ;
2325
                          npa:viaNanopub        ?np ;
2326
                          npa:aliasSpace        ?alias ;
2327
                          npa:canonicalSpaceRef ?canonRef ;
2328
                          npa:canonicalSpace    ?canonical .
2329
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2330
                  # alias edge alongside the ref-valued one, so pre-ref published queries
2331
                  # that resolve owl:sameAs by bare canonical IRI keep binding on a
2332
                  # mixed-version fleet. Internal alias-aware lookups (attachment tier)
2333
                  # join through npa:forSpaceRef, which is ref-valued, so this IRI-valued
2334
                  # object never satisfies them — it is inert internally, read-only for
2335
                  # legacy consumers. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2336
                  ?alias npa:sameAsSpace ?canonical .
2337
                } }
2338
                WHERE {
2339
                  # 1. Anchor: candidate alias declarations from the extraction graph.
2340
                  GRAPH <%4$s> {
2341
                    ?d a npa:SpaceAliasDeclaration ;
2342
                       npa:canonicalSpace ?canonical ;
2343
                       npa:aliasSpace     ?alias ;
2344
                       npa:pubkeyHash     ?pkh ;
2345
                       npa:viaNanopub     ?np .
2346
                  }
2347
                  # 2. Authority gate per canonical ref: ?canonRef is a ref of ?canonical
2348
                  #    whose admin set contains the declaration's publisher.
2349
                  GRAPH <%4$s> { ?canonRef npa:spaceIri ?canonical . }
2350
                  GRAPH <%3$s> {
2351
                    ?acct a npa:AccountState ;
2352
                          npa:pubkey ?pkh ;
2353
                          npa:agent  ?publisher .
2354
                    ?adminRI a gen:RoleInstantiation ;
2355
                             npa:inverseProperty gen:hasAdmin ;
2356
                             npa:forSpaceRef ?canonRef ;
2357
                             npa:forAgent ?publisher .
2358
                  }
2359
                  # 3. Anti-hijack: the alias IRI must have no admin who is not also an
2360
                  #    admin of this canonical ref (admins(alias) ⊆ admins(canonRef)).
2361
                  FILTER NOT EXISTS {
2362
                    GRAPH <%3$s> {
2363
                      ?aliasAdmin a gen:RoleInstantiation ;
2364
                                  npa:inverseProperty gen:hasAdmin ;
2365
                                  npa:forSpace ?alias ;
2366
                                  npa:forAgent ?otherAgent .
2367
                    }
2368
                    FILTER NOT EXISTS {
2369
                      GRAPH <%3$s> {
2370
                        ?canonAdmin a gen:RoleInstantiation ;
2371
                                    npa:inverseProperty gen:hasAdmin ;
2372
                                    npa:forSpaceRef ?canonRef ;
2373
                                    npa:forAgent ?otherAgent .
2374
                      }
2375
                    }
2376
                  }
2377
                  # 4. Invalidation filter on the declaration's nanopub.
2378
                  %6$s
2379
                  # 5. Load-number filter on bound ?np.
2380
                  GRAPH <%7$s> {
2381
                    ?np npa:hasLoadNumber ?ln .
2382
                    FILTER (?ln > %5$d)
2383
                  }
2384
                  # 6. Mint the per-(nanopub, alias→canonical ref) provenance link IRI and
2385
                  #    dedup on it (not on the bare edge), so every backing declaration records
2386
                  #    its own removable link; the convenience edges above are re-asserted
2387
                  #    idempotently.
2388
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/spacelink/alias/",
2389
                                  MD5(CONCAT(STR(?np), "|", STR(?alias), "|", STR(?canonRef))))) AS ?alLink)
2390
                  FILTER NOT EXISTS { GRAPH <%3$s> {
2391
                    ?alLink a npa:SpaceAliasLink .
2392
                  } }
2393
                }
2394
                """.formatted(
3✔
2395
                NPA.NAMESPACE,
2396
                GEN.NAMESPACE,
2397
                graph,
2398
                SpacesVocab.SPACES_GRAPH,
2399
                lastProcessed,
15✔
2400
                invalidationFilter("np"),
18✔
2401
                NPA.GRAPH);
2402
    }
2403

2404
    /**
2405
     * URL-prefix sub-space fallback admit pass. For every pair of {@code SpaceRef}
2406
     * aggregates where the child's {@code npa:hasIdPrefix} matches the parent's
2407
     * {@code npa:spaceIri}, emits convenience {@code <child> npa:isSubSpaceOf <parent>}
2408
     * and {@code <parent> npa:hasSubSpace <child>} direct triples plus a reified
2409
     * {@code npa:DerivedSubSpaceLink} tag carrying {@code npa:derivationKind
2410
     * npa:byUrlPrefix} so consumers can hide derived edges.
2411
     *
2412
     * <p>Per-child suppression: any validated {@code npa:SubSpaceDeclaration} on the
2413
     * child in {@code npass:<…>} suppresses every fallback edge for that child.
2414
     * Suppression checks the validated set (not raw extraction-graph declarations)
2415
     * so an unapproved or in-flight Mode B declaration doesn't silently hide both
2416
     * the URL-prefix fallback and the (still-invalid) explicit relation.
2417
     *
2418
     * <p>Run order: must run after {@link #subSpaceAdmitUpdate} commits in the
2419
     * same cycle so the suppression check sees this cycle's freshly-validated
2420
     * declarations.
2421
     *
2422
     * <p>No load-number filter: the fallback depends on which Spaces exist (parent
2423
     * + child {@code SpaceRef}s), not on which were just added. Always full-scan;
2424
     * the dedup {@code FILTER NOT EXISTS} on the tag IRI prevents re-insertion.
2425
     *
2426
     * <p>No invalidation handling: derived edges have no source nanopub. Two
2427
     * staleness modes: (a) child later gets first validated declaration → old
2428
     * derived edges stay sticky until the next periodic rebuild (same policy as
2429
     * admin-RI invalidation); (b) child loses last validated declaration → the
2430
     * regular fallback pass on the next cycle re-engages, adds derived edges
2431
     * incrementally, no rebuild needed.
2432
     */
2433
    static String subSpacePrefixFallbackUpdate(IRI graph) {
2434
        return """
48✔
2435
                PREFIX npa: <%1$s>
2436
                INSERT { GRAPH <%2$s> {
2437
                  ?childRef  npa:isSubSpaceOf ?parentRef .
2438
                  ?parentRef npa:hasSubSpace  ?childRef  .
2439
                  # TRANSITIONAL-DUAL-EMIT (Phase 1.5; remove in Phase 4): IRI-valued
2440
                  # derived sub-space edge alongside the ref-to-ref one, mirroring the
2441
                  # explicit sub-space pass, so pre-ref published queries keep binding on a
2442
                  # mixed-version fleet. See doc/report-2026-06-12-mixed-fleet-spaceref-breakage.md.
2443
                  ?child  npa:isSubSpaceOf ?parent .
2444
                  ?parent npa:hasSubSpace  ?child  .
2445
                  ?tagIri a npa:DerivedSubSpaceLink ;
2446
                          npa:childSpace     ?child ;
2447
                          npa:parentSpace    ?parent ;
2448
                          # Ref endpoints too (issue #125 finding #5), so the sub-space
2449
                          # orphan-sweep recognizes a prefix-derived ref edge as backed and
2450
                          # never deletes it. Derived links have no source nanopub, so they
2451
                          # are never invalidation-deleted; the fallback self-heals each cycle.
2452
                          npa:childSpaceRef  ?childRef ;
2453
                          npa:parentSpaceRef ?parentRef ;
2454
                          npa:derivationKind npa:byUrlPrefix .
2455
                } }
2456
                WHERE {
2457
                  # 1. Anchor: child SpaceRef → its path-prefixes (extracted at load
2458
                  #    time from the Space IRI; see SpacesExtractor.enumerateIdPrefixes).
2459
                  GRAPH <%3$s> {
2460
                    ?childRef  npa:spaceIri    ?child ;
2461
                               npa:hasIdPrefix ?parent .
2462
                    # 2. Parent SpaceRef must exist for the same IRI as the prefix.
2463
                    ?parentRef npa:spaceIri    ?parent .
2464
                  }
2465
                  # 3. Suppress fallback for any child that has a validated declaration
2466
                  #    in this state graph. Per-child IRI, all-or-nothing.
2467
                  FILTER NOT EXISTS {
2468
                    GRAPH <%2$s> {
2469
                      ?d a npa:SubSpaceDeclaration ;
2470
                         npa:childSpace ?child .
2471
                    }
2472
                  }
2473
                  # 4. Mint a deterministic tag IRI per (child ref, parent ref) — the edge
2474
                  #    is emitted ref-to-ref, so the tag and dedup are per ref-pair.
2475
                  BIND(IRI(CONCAT("http://purl.org/nanopub/admin/derivedlink/",
2476
                                  MD5(CONCAT(STR(?childRef), "|", STR(?parentRef))))) AS ?tagIri)
2477
                  # 5. Dedup: don't re-insert if this tag is already present.
2478
                  FILTER NOT EXISTS {
2479
                    GRAPH <%2$s> {
2480
                      ?tagIri a npa:DerivedSubSpaceLink .
2481
                    }
2482
                  }
2483
                }
2484
                """.formatted(
3✔
2485
                NPA.NAMESPACE,
2486
                graph,
2487
                SpacesVocab.SPACES_GRAPH);
2488
    }
2489

2490
    /**
2491
     * Reflexive governing-space-ref pass (issue #130). For every {@code SpaceRef}
2492
     * aggregate {@code ?spaceRef} (identified by {@code npa:spaceIri ?space} in the
2493
     * extraction graph), emits {@code <space> npa:hasGoverningSpaceRef <spaceRef>} into
2494
     * the space-state graph — the space pointing at its own ref through the same predicate
2495
     * a maintained resource uses to point at its maintaining space's ref (emitted in
2496
     * {@link #maintainedResourceAdmitUpdate}).
2497
     *
2498
     * <p>This removes the zero-hop special case from consumer authority gates: instead of
2499
     * {@code ?resource npa:isMaintainedBy? ?space} (a bare-IRI optional path that breaks
2500
     * once the hop is ref-valued), a consumer does a single mandatory
2501
     * {@code ?resource npa:hasGoverningSpaceRef ?spaceRef} that binds whether the resource
2502
     * is a maintained resource or a space itself. A space IRI claimed by several refs emits
2503
     * one edge per ref — the non-ref consumer variant's merged-across-refs behaviour falls
2504
     * out naturally; the ref variant pins {@code ?passedRef}.
2505
     *
2506
     * <p>Self-healing, like {@link #subSpacePrefixFallbackUpdate}: the edge has no source
2507
     * nanopub (it follows purely from a {@code SpaceRef} existing), so there is no
2508
     * invalidation handling and no load-number filter — always full-scan, with the dedup
2509
     * {@code FILTER NOT EXISTS} on the edge preventing re-insertion. A {@code SpaceRef}
2510
     * disappearing is itself a structural-rebuild event, which clears its reflexive edge.
2511
     */
2512
    static String governingSpaceRefReflexiveUpdate(IRI graph) {
2513
        return """
48✔
2514
                PREFIX npa: <%1$s>
2515
                INSERT { GRAPH <%2$s> {
2516
                  ?space npa:hasGoverningSpaceRef ?spaceRef .
2517
                } }
2518
                WHERE {
2519
                  GRAPH <%3$s> { ?spaceRef npa:spaceIri ?space . }
2520
                  FILTER NOT EXISTS { GRAPH <%2$s> {
2521
                    ?space npa:hasGoverningSpaceRef ?spaceRef .
2522
                  } }
2523
                }
2524
                """.formatted(
3✔
2525
                NPA.NAMESPACE,
2526
                graph,
2527
                SpacesVocab.SPACES_GRAPH);
2528
    }
2529

2530
    // ---------------- Invalidation templates (incremental cycle) ----------------
2531

2532
    /**
2533
     * WHERE clause shared by the admin-RI invalidation ASK precheck and the
2534
     * matching DELETE. Identifies admin-tier {@code gen:RoleInstantiation} rows
2535
     * in the space-state graph whose {@code npa:viaNanopub} is the target of an
2536
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub
2537
     * has a load number in {@code (lastProcessed, ∞)}.
2538
     */
2539
    static String adminInvalidationCheckWhere(IRI graph, long lastProcessed) {
2540
        return String.format("""
60✔
2541
                  GRAPH <%1$s> {
2542
                    ?ri a gen:RoleInstantiation ;
2543
                        npa:inverseProperty gen:hasAdmin ;
2544
                        npa:viaNanopub ?np .
2545
                  }
2546
                  GRAPH <%2$s> {
2547
                    ?invNp <%3$s> ?np ;
2548
                           npa:hasLoadNumber ?ln .
2549
                    FILTER (?ln > %4$d)
2550
                    %5$s
2551
                  }
2552
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2553
                samePublisherClause("invNp", "np"));
6✔
2554
    }
2555

2556
    /** DELETE template for admin-tier RoleInstantiations whose source nanopub was invalidated. */
2557
    static String adminInvalidationDelete(IRI graph, long lastProcessed) {
2558
        return String.format("""
63✔
2559
                PREFIX npa: <%1$s>
2560
                PREFIX gen: <%2$s>
2561
                DELETE { GRAPH <%3$s> {
2562
                  ?ri ?p ?o .
2563
                } }
2564
                WHERE {
2565
                  GRAPH <%3$s> { ?ri ?p ?o . }
2566
                %4$s
2567
                }
2568
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2569
                adminInvalidationCheckWhere(graph, lastProcessed));
6✔
2570
    }
2571

2572
    /** WHERE clause for RoleAssignment invalidation. */
2573
    static String roleAssignmentInvalidationCheckWhere(IRI graph, long lastProcessed) {
2574
        return String.format("""
60✔
2575
                  GRAPH <%1$s> {
2576
                    ?ra a gen:RoleAssignment ;
2577
                        npa:viaNanopub ?np .
2578
                  }
2579
                  GRAPH <%2$s> {
2580
                    ?invNp <%3$s> ?np ;
2581
                           npa:hasLoadNumber ?ln .
2582
                    FILTER (?ln > %4$d)
2583
                    %5$s
2584
                  }
2585
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2586
                samePublisherClause("invNp", "np"));
6✔
2587
    }
2588

2589
    /** DELETE template for RoleAssignments whose source nanopub was invalidated. */
2590
    static String roleAssignmentInvalidationDelete(IRI graph, long lastProcessed) {
2591
        return String.format("""
63✔
2592
                PREFIX npa: <%1$s>
2593
                PREFIX gen: <%2$s>
2594
                DELETE { GRAPH <%3$s> {
2595
                  ?ra ?p ?o .
2596
                } }
2597
                WHERE {
2598
                  GRAPH <%3$s> { ?ra ?p ?o . }
2599
                %4$s
2600
                }
2601
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2602
                roleAssignmentInvalidationCheckWhere(graph, lastProcessed));
6✔
2603
    }
2604

2605
    /**
2606
     * DELETE template for non-admin (leaf-tier) RoleInstantiations whose source
2607
     * nanopub was invalidated. Identified as {@code gen:RoleInstantiation} rows
2608
     * lacking the admin-pinning {@code npa:inverseProperty gen:hasAdmin} triple.
2609
     * No flag is set; leaf-tier removals are recoverable on the next cycle.
2610
     */
2611
    static String leafTierInvalidationDelete(IRI graph, long lastProcessed) {
2612
        return String.format("""
84✔
2613
                PREFIX npa: <%1$s>
2614
                PREFIX gen: <%2$s>
2615
                DELETE { GRAPH <%3$s> {
2616
                  ?ri ?p ?o .
2617
                } }
2618
                WHERE {
2619
                  GRAPH <%3$s> {
2620
                    ?ri a gen:RoleInstantiation ;
2621
                        npa:viaNanopub ?np .
2622
                    FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }
2623
                    ?ri ?p ?o .
2624
                  }
2625
                  GRAPH <%4$s> {
2626
                    ?invNp <%5$s> ?np ;
2627
                           npa:hasLoadNumber ?ln .
2628
                    FILTER (?ln > %6$d)
2629
                    %7$s
2630
                  }
2631
                }
2632
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2633
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2634
                samePublisherClause("invNp", "np"));
6✔
2635
    }
2636

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

2660
    /**
2661
     * DELETE template for validated {@code npa:SubSpaceDeclaration} rows whose
2662
     * source nanopub was invalidated. Removes the per-declaration row by subject;
2663
     * the convenience direct triples ({@code <child> npa:isSubSpaceOf <parent>}
2664
     * and inverse) are then dropped by {@link #subSpaceConvenienceEdgeCleanup} in the
2665
     * same cycle (issue #125 finding #5) once no surviving link backs them.
2666
     */
2667
    static String subSpaceInvalidationDelete(IRI graph, long lastProcessed) {
2668
        return String.format("""
63✔
2669
                PREFIX npa: <%1$s>
2670
                PREFIX gen: <%2$s>
2671
                DELETE { GRAPH <%3$s> {
2672
                  ?d ?p ?o .
2673
                } }
2674
                WHERE {
2675
                  GRAPH <%3$s> { ?d ?p ?o . }
2676
                %4$s
2677
                }
2678
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2679
                subSpaceInvalidationCheckWhere(graph, lastProcessed));
6✔
2680
    }
2681

2682
    /**
2683
     * DELETE template for validated {@code npa:MaintainedResourceDeclaration} rows
2684
     * whose source nanopub was invalidated. Removes the per-declaration row by
2685
     * subject; the convenience direct triples ({@code <r> npa:isMaintainedBy <s>}
2686
     * and inverse) are then dropped by {@link #maintainedResourceConvenienceEdgeCleanup}
2687
     * in the same cycle (issue #125 finding #5). No structural-rebuild flag —
2688
     * maintained-resource is a leaf relation, no downstream consumers depend on its
2689
     * closure, so the prompt edge cleanup fully resolves its invalidation.
2690
     */
2691
    static String maintainedResourceInvalidationDelete(IRI graph, long lastProcessed) {
2692
        return String.format("""
84✔
2693
                PREFIX npa: <%1$s>
2694
                PREFIX gen: <%2$s>
2695
                DELETE { GRAPH <%3$s> {
2696
                  ?d ?p ?o .
2697
                } }
2698
                WHERE {
2699
                  GRAPH <%3$s> {
2700
                    ?d a npa:MaintainedResourceDeclaration ;
2701
                       npa:viaNanopub ?np .
2702
                    ?d ?p ?o .
2703
                  }
2704
                  GRAPH <%4$s> {
2705
                    ?invNp <%5$s> ?np ;
2706
                           npa:hasLoadNumber ?ln .
2707
                    FILTER (?ln > %6$d)
2708
                    %7$s
2709
                  }
2710
                }
2711
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2712
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2713
                samePublisherClause("invNp", "np"));
6✔
2714
    }
2715

2716
    /**
2717
     * WHERE clause shared by the alias invalidation ASK precheck and the matching
2718
     * DELETE. Identifies validated {@code npa:SpaceAliasDeclaration} rows in the
2719
     * space-state graph whose {@code npa:viaNanopub} is the target of an
2720
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
2721
     * load number in {@code (lastProcessed, ∞)}.
2722
     */
2723
    static String aliasInvalidationCheckWhere(IRI graph, long lastProcessed) {
2724
        return String.format("""
60✔
2725
                  GRAPH <%1$s> {
2726
                    ?d a npa:SpaceAliasDeclaration ;
2727
                       npa:viaNanopub ?np .
2728
                  }
2729
                  GRAPH <%2$s> {
2730
                    ?invNp <%3$s> ?np ;
2731
                           npa:hasLoadNumber ?ln .
2732
                    FILTER (?ln > %4$d)
2733
                    %5$s
2734
                  }
2735
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2736
                samePublisherClause("invNp", "np"));
6✔
2737
    }
2738

2739
    /**
2740
     * DELETE template for validated {@code npa:SpaceAliasDeclaration} rows whose
2741
     * source nanopub was invalidated. Removes the per-declaration row by subject; the
2742
     * convenience {@code <alias> npa:sameAsSpace <canonical>} edge is then dropped by
2743
     * {@link #aliasConvenienceEdgeCleanup} in the same cycle (issue #125 finding #5),
2744
     * so an alias can no longer grant admin authority after its declaration is retracted.
2745
     * The alias feeds the authority closure, so this kind is still structural and flips
2746
     * {@code npa:needsFullRebuild} to bound any rows already derived through the edge.
2747
     */
2748
    static String aliasInvalidationDelete(IRI graph, long lastProcessed) {
2749
        return String.format("""
63✔
2750
                PREFIX npa: <%1$s>
2751
                PREFIX gen: <%2$s>
2752
                DELETE { GRAPH <%3$s> {
2753
                  ?d ?p ?o .
2754
                } }
2755
                WHERE {
2756
                  GRAPH <%3$s> { ?d ?p ?o . }
2757
                %4$s
2758
                }
2759
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
2760
                aliasInvalidationCheckWhere(graph, lastProcessed));
6✔
2761
    }
2762

2763
    /**
2764
     * WHERE clause shared by the maintained-resource invalidation ASK precheck and the
2765
     * matching cleanup. Identifies validated {@code npa:MaintainedResourceDeclaration}
2766
     * rows in the space-state graph whose {@code npa:viaNanopub} is the target of an
2767
     * {@code npx:invalidates} triple in {@code npa:graph} whose subject nanopub has a
2768
     * load number in {@code (lastProcessed, ∞)}.
2769
     */
2770
    static String maintainedResourceInvalidationCheckWhere(IRI graph, long lastProcessed) {
2771
        return String.format("""
60✔
2772
                  GRAPH <%1$s> {
2773
                    ?d a npa:MaintainedResourceDeclaration ;
2774
                       npa:viaNanopub ?np .
2775
                  }
2776
                  GRAPH <%2$s> {
2777
                    ?invNp <%3$s> ?np ;
2778
                           npa:hasLoadNumber ?ln .
2779
                    FILTER (?ln > %4$d)
2780
                    %5$s
2781
                  }
2782
                """, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2783
                samePublisherClause("invNp", "np"));
6✔
2784
    }
2785

2786
    /**
2787
     * Convenience-edge cleanup for invalidated sub-space declarations (issue #125
2788
     * finding #5). Run after {@link #subSpaceInvalidationDelete} (which removes the
2789
     * {@code npa:SubSpaceDeclaration} rows). Two phases as one multi-operation update:
2790
     * <ol>
2791
     *   <li>delete every {@code npa:SubSpaceLink} provenance link whose
2792
     *       {@code npa:viaNanopub} was invalidated (same {@code npx:invalidates} +
2793
     *       same-publisher gate as the declaration delete);</li>
2794
     *   <li>orphan-sweep: delete the convenience {@code npa:isSubSpaceOf} /
2795
     *       {@code npa:hasSubSpace} edges (both ref- and IRI-valued) that no surviving
2796
     *       link backs — neither a {@code npa:SubSpaceLink} (explicit declaration) nor a
2797
     *       {@code npa:DerivedSubSpaceLink} (URL-prefix fallback).</li>
2798
     * </ol>
2799
     * Edges backed by another surviving declaration or by the URL-prefix fallback are
2800
     * kept. The {@code npa:needsFullRebuild} flag still fires for the structural kind, so
2801
     * downstream rows derived through a removed edge remain rebuild-bounded; this only
2802
     * stops the convenience edges themselves from going sticky.
2803
     */
2804
    static String subSpaceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2805
        return String.format("""
72✔
2806
                PREFIX npa: <%1$s>
2807
                # 1. Drop sub-space provenance links whose source nanopub was invalidated.
2808
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2809
                WHERE {
2810
                  GRAPH <%2$s> {
2811
                    ?l a npa:SubSpaceLink ;
2812
                       npa:viaNanopub ?np .
2813
                    ?l ?p ?o .
2814
                  }
2815
                  GRAPH <%3$s> {
2816
                    ?invNp <%4$s> ?np ;
2817
                           npa:hasLoadNumber ?ln .
2818
                    FILTER (?ln > %5$d)
2819
                    %6$s
2820
                  }
2821
                } ;
2822
                # 2. Orphan-sweep isSubSpaceOf edges (ref- and IRI-valued) with no backing link.
2823
                DELETE { GRAPH <%2$s> { ?c npa:isSubSpaceOf ?p . } }
2824
                WHERE {
2825
                  GRAPH <%2$s> {
2826
                    ?c npa:isSubSpaceOf ?p .
2827
                    FILTER NOT EXISTS {
2828
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2829
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2830
                        UNION
2831
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2832
                    }
2833
                  }
2834
                } ;
2835
                # 3. Orphan-sweep the inverse hasSubSpace edges symmetrically.
2836
                DELETE { GRAPH <%2$s> { ?p npa:hasSubSpace ?c . } }
2837
                WHERE {
2838
                  GRAPH <%2$s> {
2839
                    ?p npa:hasSubSpace ?c .
2840
                    FILTER NOT EXISTS {
2841
                      { ?l a npa:SubSpaceLink } UNION { ?l a npa:DerivedSubSpaceLink }
2842
                      { { ?l npa:childSpaceRef ?c . ?l npa:parentSpaceRef ?p }
2843
                        UNION
2844
                        { ?l npa:childSpace ?c . ?l npa:parentSpace ?p } }
2845
                    }
2846
                  }
2847
                }
2848
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2849
                samePublisherClause("invNp", "np"));
6✔
2850
    }
2851

2852
    /**
2853
     * Convenience-edge cleanup for invalidated maintained-resource declarations (issue
2854
     * #125 finding #5). Run after {@link #maintainedResourceInvalidationDelete}. Deletes
2855
     * the {@code npa:MaintainedResourceLink} provenance links whose source nanopub was
2856
     * invalidated, then orphan-sweeps the {@code npa:isMaintainedBy} /
2857
     * {@code npa:hasMaintainedResource} edges (ref- and IRI-valued) that no surviving link
2858
     * backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase structure.
2859
     */
2860
    static String maintainedResourceConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2861
        return String.format("""
72✔
2862
                PREFIX npa: <%1$s>
2863
                # 1. Drop maintained-resource provenance links whose source nanopub was invalidated.
2864
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2865
                WHERE {
2866
                  GRAPH <%2$s> {
2867
                    ?l a npa:MaintainedResourceLink ;
2868
                       npa:viaNanopub ?np .
2869
                    ?l ?p ?o .
2870
                  }
2871
                  GRAPH <%3$s> {
2872
                    ?invNp <%4$s> ?np ;
2873
                           npa:hasLoadNumber ?ln .
2874
                    FILTER (?ln > %5$d)
2875
                    %6$s
2876
                  }
2877
                } ;
2878
                # 2. Orphan-sweep isMaintainedBy edges (ref- and IRI-valued) with no backing link.
2879
                DELETE { GRAPH <%2$s> { ?r npa:isMaintainedBy ?o . } }
2880
                WHERE {
2881
                  GRAPH <%2$s> {
2882
                    ?r npa:isMaintainedBy ?o .
2883
                    FILTER NOT EXISTS {
2884
                      ?l a npa:MaintainedResourceLink ;
2885
                         npa:resourceIri ?r .
2886
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2887
                    }
2888
                  }
2889
                } ;
2890
                # 3. Orphan-sweep the inverse hasMaintainedResource edges symmetrically.
2891
                DELETE { GRAPH <%2$s> { ?o npa:hasMaintainedResource ?r . } }
2892
                WHERE {
2893
                  GRAPH <%2$s> {
2894
                    ?o npa:hasMaintainedResource ?r .
2895
                    FILTER NOT EXISTS {
2896
                      ?l a npa:MaintainedResourceLink ;
2897
                         npa:resourceIri ?r .
2898
                      { ?l npa:maintainerSpaceRef ?o } UNION { ?l npa:maintainerSpace ?o }
2899
                    }
2900
                  }
2901
                } ;
2902
                # 4. Orphan-sweep the maintained arm of hasGoverningSpaceRef (issue #130).
2903
                #    Only the ref-valued maintained edge is removed here — it is backed by a
2904
                #    MaintainedResourceLink. The reflexive space self-edge (subject = a space
2905
                #    IRI that has its own SpaceRef) is NOT a maintained edge and is left to the
2906
                #    self-healing reflexive pass, so the guard keeps any ?r that is itself a space.
2907
                DELETE { GRAPH <%2$s> { ?r npa:hasGoverningSpaceRef ?o . } }
2908
                WHERE {
2909
                  GRAPH <%2$s> {
2910
                    ?r npa:hasGoverningSpaceRef ?o .
2911
                    FILTER NOT EXISTS {
2912
                      ?l a npa:MaintainedResourceLink ;
2913
                         npa:resourceIri ?r ;
2914
                         npa:maintainerSpaceRef ?o .
2915
                    }
2916
                    FILTER NOT EXISTS { GRAPH <%7$s> { ?o npa:spaceIri ?r . } }
2917
                  }
2918
                }
2919
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2920
                samePublisherClause("invNp", "np"), SpacesVocab.SPACES_GRAPH);
18✔
2921
    }
2922

2923
    /**
2924
     * Convenience-edge cleanup for invalidated space-alias declarations (issue #125
2925
     * finding #5 — the load-bearing case, since the alias edge feeds the admin-authority
2926
     * closure). Run after {@link #aliasInvalidationDelete}. Deletes the
2927
     * {@code npa:SpaceAliasLink} provenance links whose source nanopub was invalidated,
2928
     * then orphan-sweeps the {@code npa:sameAsSpace} edges (ref- and IRI-valued) that no
2929
     * surviving link backs. See {@link #subSpaceConvenienceEdgeCleanup} for the two-phase
2930
     * structure.
2931
     */
2932
    static String aliasConvenienceEdgeCleanup(IRI graph, long lastProcessed) {
2933
        return String.format("""
72✔
2934
                PREFIX npa: <%1$s>
2935
                # 1. Drop alias provenance links whose source nanopub was invalidated.
2936
                DELETE { GRAPH <%2$s> { ?l ?p ?o . } }
2937
                WHERE {
2938
                  GRAPH <%2$s> {
2939
                    ?l a npa:SpaceAliasLink ;
2940
                       npa:viaNanopub ?np .
2941
                    ?l ?p ?o .
2942
                  }
2943
                  GRAPH <%3$s> {
2944
                    ?invNp <%4$s> ?np ;
2945
                           npa:hasLoadNumber ?ln .
2946
                    FILTER (?ln > %5$d)
2947
                    %6$s
2948
                  }
2949
                } ;
2950
                # 2. Orphan-sweep sameAsSpace edges (ref- and IRI-valued) with no backing link.
2951
                DELETE { GRAPH <%2$s> { ?alias npa:sameAsSpace ?o . } }
2952
                WHERE {
2953
                  GRAPH <%2$s> {
2954
                    ?alias npa:sameAsSpace ?o .
2955
                    FILTER NOT EXISTS {
2956
                      ?l a npa:SpaceAliasLink ;
2957
                         npa:aliasSpace ?alias .
2958
                      { ?l npa:canonicalSpaceRef ?o } UNION { ?l npa:canonicalSpace ?o }
2959
                    }
2960
                  }
2961
                }
2962
                """, NPA.NAMESPACE, graph, NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
2963
                samePublisherClause("invNp", "np"));
6✔
2964
    }
2965

2966
    /**
2967
     * WHERE clause shared by the preset-deactivation ASK precheck and the matching DELETE
2968
     * (Nanodash issue #302). Binds {@code ?ra} = a materialized preset-derived
2969
     * {@code gen:RoleAssignment} ({@code npa:derivedFromPreset}) for which a <em>newer,
2970
     * admin-authored</em> same-{@code (preset, resource)} assignment exists by
2971
     * {@code dct:created} (load number in {@code (lastProcessed, ∞)}). This is NOT an
2972
     * {@code npx:invalidates} check — preset activation is latest-wins by timestamp.
2973
     *
2974
     * <p>Authorization-scoped (anti-hijack, design doc §3/§4.4): the newer assignment's
2975
     * publisher must itself be a validated admin of the row's {@code npa:forSpaceRef}, so an
2976
     * unauthorized key's newer assignment can neither delete nor shadow an admin's
2977
     * materialized role. {@code dct:created} is written as a full IRI (not a {@code dct:}
2978
     * prefix) because {@link #wouldInvalidate}'s ASK wrapper only declares {@code npa:} /
2979
     * {@code gen:}.
2980
     */
2981
    static String presetDeactivationCheckWhere(IRI graph, long lastProcessed) {
2982
        return String.format("""
60✔
2983
                  GRAPH <%1$s> {
2984
                    ?ra a gen:RoleAssignment ;
2985
                        npa:derivedFromPreset ?assignNp ;
2986
                        npa:forSpaceRef ?targetRef .
2987
                  }
2988
                  GRAPH <%2$s> {
2989
                    ?pa a npa:PresetAssignment ;
2990
                        npa:viaNanopub  ?assignNp ;
2991
                        npa:ofPreset    ?preset ;
2992
                        npa:forResource ?resource ;
2993
                        <http://purl.org/dc/terms/created> ?created .
2994
                    ?paNewer a npa:PresetAssignment ;
2995
                             npa:ofPreset    ?preset ;
2996
                             npa:forResource ?resource ;
2997
                             npa:pubkeyHash  ?pkhNewer ;
2998
                             npa:viaNanopub  ?assignNpNewer ;
2999
                             <http://purl.org/dc/terms/created> ?createdNewer .
3000
                    FILTER (?createdNewer > ?created
3001
                            || (?createdNewer = ?created && STR(?paNewer) > STR(?pa)))
3002
                  }
3003
                  GRAPH <%3$s> {
3004
                    ?assignNpNewer npa:hasLoadNumber ?lnNewer .
3005
                    FILTER (?lnNewer > %4$d)
3006
                  }
3007
                  GRAPH <%1$s> {
3008
                    ?acctNewer a npa:AccountState ;
3009
                               npa:agent  ?publisherNewer ;
3010
                               npa:pubkey ?pkhNewer .
3011
                    ?adminRINewer a gen:RoleInstantiation ;
3012
                                  npa:forSpaceRef ?targetRef ;
3013
                                  npa:inverseProperty gen:hasAdmin ;
3014
                                  npa:forAgent ?publisherNewer .
3015
                  }
3016
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed);
6✔
3017
    }
3018

3019
    /**
3020
     * DELETE template for preset-derived {@code gen:RoleAssignment} rows superseded by a
3021
     * newer admin-authored same-pair assignment (issue #302). Removes the whole row by
3022
     * subject; scoped via {@code npa:derivedFromPreset} so directly-published attachments
3023
     * are never touched. The {@link #presetAttachmentValidationUpdate} re-INSERT in the
3024
     * same cycle re-materializes the pair iff the newest assignment is still active.
3025
     */
3026
    static String presetDeactivationDelete(IRI graph, long lastProcessed) {
3027
        return String.format("""
63✔
3028
                PREFIX npa: <%1$s>
3029
                PREFIX gen: <%2$s>
3030
                DELETE { GRAPH <%3$s> {
3031
                  ?ra ?p ?o .
3032
                } }
3033
                WHERE {
3034
                  GRAPH <%3$s> { ?ra ?p ?o . }
3035
                %4$s
3036
                }
3037
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3038
                presetDeactivationCheckWhere(graph, lastProcessed));
6✔
3039
    }
3040

3041
    /**
3042
     * WHERE clause matching a materialized <em>non-admin</em> {@code gen:RoleInstantiation}
3043
     * row whose {@code (forSpaceRef, forAgent, gen:hasRole)} key is shadowed by a newer
3044
     * authorized {@code npa:RoleRevocation} (issue #129). The grant timestamp comes from the
3045
     * originating instantiation in the extraction graph (the materialized row carries no
3046
     * {@code dct:created}); the revocation nanopub's load number must be in
3047
     * {@code (lastProcessed, ∞)} so only revocations new in this cycle trigger a delete.
3048
     * Authorization is keyed on the row's bound {@code ?tier} (the matrix: a strictly-higher
3049
     * tier in the ref, or self). Not an {@code npx:invalidates} check.
3050
     */
3051
    static String roleRevocationCheckWhere(IRI graph, long lastProcessed, IRI targetTier) {
3052
        return String.format("""
60✔
3053
                  GRAPH <%1$s> {
3054
                    ?ri2 a gen:RoleInstantiation ;
3055
                         npa:forSpaceRef ?spaceRef ;
3056
                         npa:forAgent    ?agent ;
3057
                         gen:hasRole     ?role ;
3058
                         npa:hasRoleType <%7$s> ;
3059
                         npa:viaNanopub  ?np .
3060
                  }
3061
                  OPTIONAL { GRAPH <%2$s> {
3062
                    ?riSrc npa:viaNanopub ?np ;
3063
                           <http://purl.org/dc/terms/created> ?candCreatedRaw .
3064
                  } }
3065
                  BIND(COALESCE(?candCreatedRaw, %5$s) AS ?candCreated)
3066
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
3067
                  UNION
3068
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
3069
                  GRAPH <%2$s> {
3070
                    ?rev a npa:RoleRevocation ;
3071
                         npa:forSpace    ?revSpace ;
3072
                         npa:forAgent    ?agent ;
3073
                         npa:revokedRole ?role ;
3074
                         npa:pubkeyHash  ?revPkh ;
3075
                         npa:viaNanopub  ?revNp .
3076
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
3077
                  }
3078
                  BIND(COALESCE(?revCreatedRaw, %5$s) AS ?revCreated)
3079
                  GRAPH <%3$s> {
3080
                    ?revNp npa:hasLoadNumber ?lnRev .
3081
                    FILTER (?lnRev > %4$d)
3082
                  }
3083
                  FILTER (?revCreated > ?candCreated
3084
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?ri2)))
3085
                  { %6$s }
3086
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed,
30✔
3087
                EPOCH_DT, revocationAuthorityArmsForTier(graph, targetTier), targetTier);
18✔
3088
    }
3089

3090
    /**
3091
     * DELETE template removing a non-admin {@code gen:RoleInstantiation} row of {@code
3092
     * targetTier} shadowed by a newer authorized revocation (issue #129). Removes the whole
3093
     * row by subject. Run once per non-admin tier (maintainer/member/observer) so the
3094
     * authorization arms are the compile-time set for that tier — matching the inline
3095
     * suppression filter, no runtime {@code ?tier} (see {@link #revocationAuthorityArmsForTier}).
3096
     * Caller sets {@code needsFullRebuild} (a revoked maintainer/member is a sub-granting
3097
     * authority).
3098
     */
3099
    static String roleRevocationDelete(IRI graph, long lastProcessed, IRI targetTier) {
3100
        return String.format("""
66✔
3101
                PREFIX npa: <%1$s>
3102
                PREFIX gen: <%2$s>
3103
                DELETE { GRAPH <%3$s> {
3104
                  ?ri2 ?p ?o .
3105
                } }
3106
                WHERE {
3107
                  GRAPH <%3$s> { ?ri2 ?p ?o . }
3108
                %4$s
3109
                }
3110
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3111
                roleRevocationCheckWhere(graph, lastProcessed, targetTier));
6✔
3112
    }
3113

3114
    /**
3115
     * WHERE clause matching a materialized <em>admin</em> {@code gen:RoleInstantiation} row
3116
     * whose {@code (forSpaceRef, forAgent)} key is shadowed by a newer authorized admin
3117
     * {@code npa:RoleRevocation} ({@code revokedRole = gen:AdminRole}), authorized by an
3118
     * admin of the ref or by the agent itself. <b>Root admins are exempt</b> (constitutional,
3119
     * issue #129/#110): the nested {@code FILTER NOT EXISTS} on {@code npa:hasRootAdmin}
3120
     * makes the revocation inert. The revocation nanopub's load number must be in
3121
     * {@code (lastProcessed, ∞)}.
3122
     */
3123
    static String adminRevocationCheckWhere(IRI graph, long lastProcessed) {
3124
        return String.format("""
60✔
3125
                  GRAPH <%1$s> {
3126
                    ?sri a gen:RoleInstantiation ;
3127
                         npa:forSpaceRef     ?spaceRef ;
3128
                         npa:inverseProperty gen:hasAdmin ;
3129
                         npa:forAgent        ?agent ;
3130
                         npa:viaNanopub      ?np .
3131
                  }
3132
                  FILTER NOT EXISTS { GRAPH <%2$s> {
3133
                    ?rootDef a npa:SpaceDefinition ;
3134
                             npa:forSpaceRef  ?spaceRef ;
3135
                             npa:hasRootAdmin ?agent .
3136
                  } }
3137
                  OPTIONAL { GRAPH <%2$s> {
3138
                    ?riSrc npa:viaNanopub ?np ;
3139
                           <http://purl.org/dc/terms/created> ?candCreatedRaw .
3140
                  } }
3141
                  BIND(COALESCE(?candCreatedRaw, %5$s) AS ?candCreated)
3142
                  { GRAPH <%2$s> { ?spaceRef npa:spaceIri ?revSpace . } }
3143
                  UNION
3144
                  { GRAPH <%1$s> { ?revSpace npa:sameAsSpace ?spaceRef . } }
3145
                  GRAPH <%2$s> {
3146
                    ?rev a npa:RoleRevocation ;
3147
                         npa:forSpace    ?revSpace ;
3148
                         npa:forAgent    ?agent ;
3149
                         npa:revokedRole gen:AdminRole ;
3150
                         npa:pubkeyHash  ?revPkh ;
3151
                         npa:viaNanopub  ?revNp .
3152
                    OPTIONAL { ?rev <http://purl.org/dc/terms/created> ?revCreatedRaw . }
3153
                  }
3154
                  BIND(COALESCE(?revCreatedRaw, %5$s) AS ?revCreated)
3155
                  GRAPH <%3$s> {
3156
                    ?revNp npa:hasLoadNumber ?lnRev .
3157
                    FILTER (?lnRev > %4$d)
3158
                  }
3159
                  FILTER (?revCreated > ?candCreated
3160
                          || (?revCreated = ?candCreated && STR(?rev) > STR(?sri)))
3161
                  { %6$s }
3162
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed, EPOCH_DT,
27✔
3163
                "{ " + revokerAdminGraphBlock(graph) + " }\nUNION\n{ "
6✔
3164
                        + revokerSelfGraphBlock(graph) + " }");
9✔
3165
    }
3166

3167
    /**
3168
     * DELETE template removing an admin {@code gen:RoleInstantiation} row shadowed by a newer
3169
     * authorized admin revocation (issue #129). Removes the whole row by subject.
3170
     * <b>Structural</b> — admin RIs feed every downstream tier — so the caller sets
3171
     * {@code npa:needsFullRebuild} (mirrors {@code adminInvalidationDelete}). The
3172
     * {@code adminTierUpdate} inline suppression filter prevents re-materialization.
3173
     */
3174
    static String adminRevocationDelete(IRI graph, long lastProcessed) {
3175
        return String.format("""
63✔
3176
                PREFIX npa: <%1$s>
3177
                PREFIX gen: <%2$s>
3178
                DELETE { GRAPH <%3$s> {
3179
                  ?sri ?p ?o .
3180
                } }
3181
                WHERE {
3182
                  GRAPH <%3$s> { ?sri ?p ?o . }
3183
                %4$s
3184
                }
3185
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3186
                adminRevocationCheckWhere(graph, lastProcessed));
6✔
3187
    }
3188

3189
    /**
3190
     * WHERE clause matching a materialized {@code gen:RoleAssignment} row (direct
3191
     * <em>or</em> preset-derived) whose {@code (forSpaceRef, gen:hasRole)} key is shadowed by
3192
     * a newer admin-authored {@code npa:RoleDetachment} (issue #129). The attachment
3193
     * timestamp comes from whichever extraction row shares the materialized row's
3194
     * {@code npa:viaNanopub} (a {@code RoleAssignment} for direct attachments, a
3195
     * {@code PresetAssignment} for preset-derived ones). The detachment nanopub's load number
3196
     * must be in {@code (lastProcessed, ∞)}; authority = admin of the ref.
3197
     */
3198
    static String roleDetachmentCheckWhere(IRI graph, long lastProcessed) {
3199
        return String.format("""
60✔
3200
                  GRAPH <%1$s> {
3201
                    ?ra2 a gen:RoleAssignment ;
3202
                         npa:forSpaceRef ?targetRef ;
3203
                         gen:hasRole     ?role ;
3204
                         npa:viaNanopub  ?np .
3205
                  }
3206
                  OPTIONAL { GRAPH <%2$s> {
3207
                    ?attSrc npa:viaNanopub ?np ;
3208
                            <http://purl.org/dc/terms/created> ?attCreatedRaw .
3209
                  } }
3210
                  BIND(COALESCE(?attCreatedRaw, %5$s) AS ?attCreated)
3211
                  { GRAPH <%2$s> { ?targetRef npa:spaceIri ?detSpace . } }
3212
                  UNION
3213
                  { GRAPH <%1$s> { ?detSpace npa:sameAsSpace ?targetRef . } }
3214
                  GRAPH <%2$s> {
3215
                    ?det a npa:RoleDetachment ;
3216
                         npa:forSpace    ?detSpace ;
3217
                         npa:revokedRole ?role ;
3218
                         npa:pubkeyHash  ?detPkh ;
3219
                         npa:viaNanopub  ?detNp .
3220
                    OPTIONAL { ?det <http://purl.org/dc/terms/created> ?detCreatedRaw . }
3221
                  }
3222
                  BIND(COALESCE(?detCreatedRaw, %5$s) AS ?detCreated)
3223
                  GRAPH <%3$s> {
3224
                    ?detNp npa:hasLoadNumber ?lnDet .
3225
                    FILTER (?lnDet > %4$d)
3226
                  }
3227
                  FILTER (?detCreated > ?attCreated
3228
                          || (?detCreated = ?attCreated && STR(?det) > STR(?ra2)))
3229
                  GRAPH <%1$s> {
3230
                    ?detAcct a npa:AccountState ; npa:pubkey ?detPkh ; npa:agent ?detAgent .
3231
                    ?detAdminRI a gen:RoleInstantiation ;
3232
                                npa:forSpaceRef ?targetRef ;
3233
                                npa:inverseProperty gen:hasAdmin ;
3234
                                npa:forAgent ?detAgent .
3235
                  }
3236
                """, graph, SpacesVocab.SPACES_GRAPH, NPA.GRAPH, lastProcessed, EPOCH_DT);
18✔
3237
    }
3238

3239
    /**
3240
     * DELETE template removing a {@code gen:RoleAssignment} row (direct or preset-derived)
3241
     * shadowed by a newer admin-authored {@code gen:detachedRole} (issue #129). Removes the
3242
     * whole row by subject. <b>Structural</b> — instantiations anchored on the removed
3243
     * attachment are bounded by the periodic full rebuild (the cascade), so the caller sets
3244
     * {@code npa:needsFullRebuild}. The attachment-tier inline filters prevent
3245
     * re-materialization until a newer attachment / preset assignment out-ranks the detach
3246
     * (non-sticky).
3247
     */
3248
    static String roleDetachmentDelete(IRI graph, long lastProcessed) {
3249
        return String.format("""
63✔
3250
                PREFIX npa: <%1$s>
3251
                PREFIX gen: <%2$s>
3252
                DELETE { GRAPH <%3$s> {
3253
                  ?ra2 ?p ?o .
3254
                } }
3255
                WHERE {
3256
                  GRAPH <%3$s> { ?ra2 ?p ?o . }
3257
                %4$s
3258
                }
3259
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3260
                roleDetachmentCheckWhere(graph, lastProcessed));
6✔
3261
    }
3262

3263
    /**
3264
     * DELETE template for ref-scoped preset-assignment stamps ({@link
3265
     * #presetAssignmentRefStampUpdate}) whose underlying assignment nanopub was
3266
     * hard-retracted (issue #122). Removes the whole row by subject; scoped to
3267
     * state-graph {@code npa:PresetAssignment} rows that carry {@code npa:forSpaceRef}
3268
     * (the IRI-keyed extraction rows never do), so it can never touch them.
3269
     *
3270
     * <p>Leaf delete — no structural flag: nothing downstream derives from a listing
3271
     * stamp, so a stale row only mis-displays a retracted assignment until this cycle's
3272
     * delete runs. Admin-grant revocation is bounded by the periodic full rebuild (same
3273
     * sticky-convenience policy as the alias / sub-space declaration edges). A
3274
     * <em>deactivation</em> needs no delete here: it is represented as a newer
3275
     * admin-authored stamp with {@code npa:isActivated false}, resolved by the consumer's
3276
     * latest-wins.
3277
     */
3278
    static String presetAssignmentRefInvalidationDelete(IRI graph, long lastProcessed) {
3279
        return String.format("""
84✔
3280
                PREFIX npa: <%1$s>
3281
                PREFIX gen: <%2$s>
3282
                DELETE { GRAPH <%3$s> {
3283
                  ?paRef ?p ?o .
3284
                } }
3285
                WHERE {
3286
                  GRAPH <%3$s> {
3287
                    ?paRef a npa:PresetAssignment ;
3288
                           npa:forSpaceRef ?targetRef ;
3289
                           npa:viaNanopub  ?assignNp .
3290
                    ?paRef ?p ?o .
3291
                  }
3292
                  GRAPH <%4$s> {
3293
                    ?invNp <%5$s> ?assignNp ;
3294
                           npa:hasLoadNumber ?ln .
3295
                    FILTER (?ln > %6$d)
3296
                    %7$s
3297
                  }
3298
                }
3299
                """, NPA.NAMESPACE, GEN.NAMESPACE, graph,
3300
                NPA.GRAPH, NPX.INVALIDATES, lastProcessed,
18✔
3301
                samePublisherClause("invNp", "assignNp"));
6✔
3302
    }
3303

3304
    /** Wraps an ASK by joining the shared prefixes. */
3305
    private boolean wouldInvalidate(IRI graph, long lastProcessed,
3306
                                    boolean adminPinned, String whereClause) {
3307
        // adminPinned is informational only — kept to make call sites read clearly;
3308
        // the WHERE clause already encodes the kind via its own type predicates.
3309
        String ask = String.format("""
51✔
3310
                PREFIX npa: <%1$s>
3311
                PREFIX gen: <%2$s>
3312
                ASK { %3$s }
3313
                """, NPA.NAMESPACE, GEN.NAMESPACE, whereClause);
3314
        return runAsk(ask);
12✔
3315
    }
3316

3317
    private boolean runAsk(String sparql) {
3318
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3319
            return conn.prepareBooleanQuery(QueryLanguage.SPARQL, sparql).evaluate();
24✔
3320
        }
3321
    }
3322

3323
    private void executeUpdate(String sparqlUpdate) {
3324
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3325
            conn.prepareUpdate(QueryLanguage.SPARQL, sparqlUpdate).execute();
15✔
3326
        }
3327
    }
3✔
3328

3329
    // ---------------- Mirror step ----------------
3330

3331
    /**
3332
     * Copies trust-approved {@code npa:AccountState} rows from {@code npat:<T>}
3333
     * in the {@code trust} repo into {@code newGraph} in the {@code spaces} repo,
3334
     * inside one spaces-side serializable transaction.
3335
     *
3336
     * @return number of rows mirrored (useful for metrics / logging)
3337
     */
3338
    /**
3339
     * Whether the given trust state's graph holds anything at all.
3340
     *
3341
     * <p>Used by {@link #runFullBuild} to tell a build that read nothing because the store
3342
     * would not answer from a build that read nothing because there is nothing to read. Only
3343
     * the first is a reason to withhold the result; withholding the second would freeze a
3344
     * stale space state in place, and stale trust data is over-permissive.
3345
     *
3346
     * <p>Throws rather than guessing if the trust repo cannot be read — {@link #runFullBuild}
3347
     * then aborts without publishing or dropping anything, which is the safe direction.
3348
     *
3349
     * @param trustStateHash the trust state hash
3350
     * @return true if the trust state graph contains at least one triple
3351
     */
3352
    boolean trustStateHasContent(String trustStateHash) {
3353
        IRI trustStateIri = NPAT.forHash(trustStateHash);
×
3354
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
3355
            String query = String.format("ASK { GRAPH <%s> { ?s ?p ?o } }", trustStateIri);
×
3356
            return conn.prepareBooleanQuery(QueryLanguage.SPARQL, query).evaluate();
×
3357
        } catch (Exception ex) {
×
3358
            throw new SpaceStateUnavailableException(
×
3359
                    "failed to read trust state graph " + trustStateIri, ex);
3360
        }
3361
    }
3362

3363
    /**
3364
     * First object of {@code (subject, predicate)} in {@code context}, or {@code null}.
3365
     *
3366
     * <p>Closes the underlying {@link RepositoryResult}: the {@code .stream().findFirst()}
3367
     * shorthand this replaces leaves the iteration open, which RDF4J reports as
3368
     * "Connection closed before all iterations were closed" whenever the cleaner has not
3369
     * collected it before the connection closes.
3370
     */
3371
    private static Value firstObject(RepositoryConnection conn, IRI subject, IRI predicate, IRI context) {
3372
        try (RepositoryResult<Statement> r = conn.getStatements(subject, predicate, null, context)) {
36✔
3373
            return r.hasNext() ? r.next().getObject() : null;
36✔
3374
        }
3375
    }
3376

3377
    int mirrorTrustState(String trustStateHash, IRI newGraph) {
3378
        IRI trustStateIri = NPAT.forHash(trustStateHash);
9✔
3379
        int count = 0;
6✔
3380
        try (RepositoryConnection trustConn = TripleStore.get().getRepoConnection(TRUST_REPO);
12✔
3381
             RepositoryConnection spacesConn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3382
            trustConn.begin(IsolationLevels.READ_COMMITTED);
9✔
3383
            // Append-only writes into the not-yet-published newGraph (the current-state
3384
            // pointer is swapped to it only after the build completes), and all spaces
3385
            // writers serialise via this class's synchronized methods; see
3386
            // NanopubLoader#repoWriteLocks for why SERIALIZABLE is avoided.
3387
            spacesConn.begin(IsolationLevels.READ_COMMITTED);
9✔
3388
            // Walk rdf:type triples in the trust state's graph; for each AccountState,
3389
            // check status and copy the approved ones verbatim (minus status-specific
3390
            // detail triples, which we don't need for validation).
3391
            try (RepositoryResult<Statement> typeRows = trustConn.getStatements(
36✔
3392
                    null, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri)) {
3393
                while (typeRows.hasNext()) {
9✔
3394
                    Statement st = typeRows.next();
12✔
3395
                    if (!(st.getSubject() instanceof IRI accountStateIri)) continue;
27!
3396
                    Value status = firstObject(trustConn, accountStateIri, NPA_TRUST_STATUS, trustStateIri);
18✔
3397
                    if (!(status instanceof IRI statusIri) || !APPROVED_SET.contains(statusIri)) continue;
33!
3398
                    Value agent = firstObject(trustConn, accountStateIri, NPA_AGENT, trustStateIri);
18✔
3399
                    Value pubkey = firstObject(trustConn, accountStateIri, NPA_PUBKEY, trustStateIri);
18✔
3400
                    if (agent == null || pubkey == null) {
12✔
3401
                        logger.warn("AuthorityResolver.mirror: account {} missing agent or pubkey; skipping",
12✔
3402
                                accountStateIri);
3403
                        continue;
3✔
3404
                    }
3405
                    spacesConn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, newGraph);
33✔
3406
                    spacesConn.add(accountStateIri, NPA_AGENT, agent, newGraph);
33✔
3407
                    spacesConn.add(accountStateIri, NPA_PUBKEY, pubkey, newGraph);
33✔
3408
                    spacesConn.add(accountStateIri, NPA_TRUST_STATUS, statusIri, newGraph);
33✔
3409
                    // Mirror the authorizing introduction provenance when present (issue #125
3410
                    // finding #4). Optional: absent for snapshots from registries that predate
3411
                    // nanopub-registry#117/#118, so consumers (e.g. get-space-members-ref) must
3412
                    // treat npa:viaNanopub on an AccountState as best-effort, not guaranteed.
3413
                    Value viaNanopub = firstObject(trustConn, accountStateIri, NPA_VIA_NANOPUB, trustStateIri);
18✔
3414
                    if (viaNanopub != null) {
6✔
3415
                        spacesConn.add(accountStateIri, NPA_VIA_NANOPUB, viaNanopub, newGraph);
33✔
3416
                    }
3417
                    count++;
3✔
3418
                }
3✔
3419
            }
3420
            // Mirror canonical foaf:name triples for approved agents. The trust
3421
            // loader emits one per agent (across approved keys, MAX(ratio) wins).
3422
            // Copying them into the space-state graph means consumers reading
3423
            // ?agent foaf:name ?n inside the state graph hit local data, with no
3424
            // cross-repo SERVICE.
3425
            try (RepositoryResult<Statement> nameRows = trustConn.getStatements(
36✔
3426
                    null, FOAF.NAME, null, trustStateIri)) {
3427
                while (nameRows.hasNext()) {
9✔
3428
                    Statement st = nameRows.next();
12✔
3429
                    spacesConn.add(st.getSubject(), st.getPredicate(), st.getObject(), newGraph);
42✔
3430
                }
3✔
3431
            }
3432
            spacesConn.commit();
6✔
3433
            trustConn.commit();
6✔
3434
        }
3435
        return count;
6✔
3436
    }
3437

3438
    // ---------------- Pending-account mirror (issue #195) ----------------
3439

3440
    /**
3441
     * Result of one {@link #mirrorPendingAccounts} pass.
3442
     *
3443
     * @param rows        number of {@code npa:PendingAccountState} rows written
3444
     * @param scannedUpTo the new watermark: the highest {@code meta} load number this
3445
     *                    pass looked at, or the incoming watermark when it saw nothing
3446
     */
3447
    record PendingMirrorResult(int rows, long scannedUpTo) {}
27✔
3448

3449
    /** Approved (agent, pubkey) pairs already present in a space-state graph. */
3450
    private record ApprovedIndex(Set<IRI> agents, Set<String> pubkeys) {}
27✔
3451

3452
    /**
3453
     * Reads the approved {@code npa:AccountState} rows of a space-state graph into
3454
     * two lookup sets. Used by {@link #mirrorPendingAccounts} to keep self-asserted
3455
     * introductions away from identities that trust approval has already settled.
3456
     */
3457
    private ApprovedIndex readApprovedIndex(IRI graph) {
3458
        Set<IRI> agents = new HashSet<>();
12✔
3459
        Set<String> pubkeys = new HashSet<>();
12✔
3460
        String query = String.format("""
63✔
3461
                SELECT ?agent ?pubkey WHERE {
3462
                  GRAPH <%1$s> {
3463
                    ?acct a <%2$s> ;
3464
                          <%3$s> ?agent ;
3465
                          <%4$s> ?pubkey .
3466
                  }
3467
                }
3468
                """, graph, NPA_ACCOUNT_STATE, NPA_AGENT, NPA_PUBKEY);
3469
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
12✔
3470
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18✔
3471
            while (r.hasNext()) {
9✔
3472
                BindingSet b = r.next();
12✔
3473
                if (b.getValue("agent") instanceof IRI agent) agents.add(agent);
42!
3474
                pubkeys.add(b.getValue("pubkey").stringValue());
21✔
3475
            }
3✔
3476
        } catch (Exception ex) {
3✔
3477
            // Must not degrade to an empty index: that would drop both exclusions and
3478
            // let a self-asserted introduction claim an already-approved identity.
3479
            throw new SpaceStateUnavailableException(
24✔
3480
                    "failed to read approved account rows from " + graph, ex);
3481
        }
3✔
3482
        return new ApprovedIndex(agents, pubkeys);
18✔
3483
    }
3484

3485
    /**
3486
     * Mirrors introduced-but-unapproved accounts into {@code graph} as
3487
     * {@code npa:PendingAccountState} rows, so that self-signed observer roles and
3488
     * own-profile view displays from not-yet-approved users become visible
3489
     * (issue #195). Reads introduction nanopubs from {@code npa:graph} of the
3490
     * {@code meta} repo — the registry distributes them for unknown pubkeys too when
3491
     * optional load is on, which is why the data is there at all.
3492
     *
3493
     * <p>Only <em>authoritative</em> introductions count: the declared key must be the
3494
     * key that signed the introduction ({@code SHA256(?pubkey) = ?pkh}), so the other
3495
     * keys of a multi-key introduction are deliberately not mirrored. Self-retraction
3496
     * is honoured through the same publisher-matched {@code npx:invalidates} gate the
3497
     * tiers use (issue #112).
3498
     *
3499
     * <p>Two exclusions keep self-asserted data away from settled identities:
3500
     * an agent that already has an approved {@code AccountState} row is skipped (so a
3501
     * rogue introduction cannot pollute an approved user's page or lists), and so is a
3502
     * pubkey that already has one (so an approved key cannot be re-bound to a second
3503
     * identity).
3504
     *
3505
     * <p>The rows never confer authority: they carry {@link #NPA_PENDING_ACCOUNT_STATE},
3506
     * which no authority join in this class matches. Approval needs no cleanup pass —
3507
     * it flips the trust-state hash, which makes {@link #tick()} run a full build that
3508
     * re-derives everything and simply stops mirroring the pending row.
3509
     *
3510
     * @param graph          the space-state graph to write into
3511
     * @param fromLoadNumber scan only introductions with a {@code meta} load number
3512
     *                       greater than this ({@code -1} scans everything)
3513
     * @return the number of rows written and the new watermark
3514
     */
3515
    PendingMirrorResult mirrorPendingAccounts(IRI graph, long fromLoadNumber) {
3516
        if (!FeatureFlags.pendingAccountsEnabled()) {
6✔
3517
            return new PendingMirrorResult(0, fromLoadNumber);
18✔
3518
        }
3519
        ApprovedIndex approved = readApprovedIndex(graph);
12✔
3520
        // ?pkh, not ?pubkey, is what goes into the row: npa:pubkey on a space-state
3521
        // account row holds the pubkey *hash* (it is joined against the extraction
3522
        // graph's npa:pubkeyHash and against npa:hasValidSignatureForPublicKeyHash),
3523
        // despite the predicate's name. The full key is only used to prove the
3524
        // declaration is authoritative.
3525
        String query = String.format("""
96✔
3526
                SELECT DISTINCT ?agent ?pkh ?np ?ln WHERE {
3527
                  GRAPH <%1$s> {
3528
                    ?np <%2$s> ?agent ;
3529
                        <%3$s> ?pubkey ;
3530
                        <%4$s> ?pkh ;
3531
                        <%5$s> ?ln .
3532
                    # Authoritative introduction: the declared key signed it.
3533
                    FILTER (SHA256(?pubkey) = STR(?pkh))
3534
                    # Self-retraction gate (issue #112): only the introduction's own
3535
                    # publisher can invalidate it.
3536
                    FILTER NOT EXISTS {
3537
                      ?inv <%6$s> ?np ;
3538
                           <%4$s> ?pkh .
3539
                    }
3540
                    FILTER (?ln > %7$d)
3541
                  }
3542
                }
3543
                """, NPA.GRAPH, NPA.IS_INTRODUCTION_OF, NPA.DECLARES_PUBKEY,
3544
                NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH, NPA.HAS_LOAD_NUMBER,
3545
                NPX.INVALIDATES, fromLoadNumber);
6✔
3546

3547
        List<Statement[]> pending = new ArrayList<>();
12✔
3548
        long maxLoadNumber = fromLoadNumber;
6✔
3549
        try (RepositoryConnection metaConn = TripleStore.get().getRepoConnection(META_REPO);
12✔
3550
             TupleQueryResult r = metaConn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18✔
3551
            while (r.hasNext()) {
9✔
3552
                BindingSet b = r.next();
12✔
3553
                long ln = Long.parseLong(b.getValue("ln").stringValue());
18✔
3554
                maxLoadNumber = Math.max(maxLoadNumber, ln);
12✔
3555
                if (!(b.getValue("agent") instanceof IRI agent)) continue;
30!
3556
                if (!(b.getValue("np") instanceof IRI introNp)) continue;
30!
3557
                Value pubkeyHash = b.getValue("pkh");
12✔
3558
                if (approved.agents().contains(agent)) continue;
18✔
3559
                if (approved.pubkeys().contains(pubkeyHash.stringValue())) continue;
21✔
3560
                IRI rowIri = pendingAccountIri(graph, pubkeyHash.stringValue(), agent);
18✔
3561
                pending.add(new Statement[] {
36✔
3562
                        vf.createStatement(rowIri, RDF.TYPE, NPA_PENDING_ACCOUNT_STATE, graph),
27✔
3563
                        vf.createStatement(rowIri, NPA_AGENT, agent, graph),
27✔
3564
                        vf.createStatement(rowIri, NPA_PUBKEY, pubkeyHash, graph),
27✔
3565
                        vf.createStatement(rowIri, NPA_TRUST_STATUS, NPA_SEEN, graph),
27✔
3566
                        vf.createStatement(rowIri, NPA_VIA_NANOPUB, introNp, graph),
6✔
3567
                });
3568
            }
3✔
3569
        } catch (Exception ex) {
×
3570
            throw new SpaceStateUnavailableException(
×
3571
                    "failed to read introduction nanopubs from the " + META_REPO + " repo", ex);
3572
        }
3✔
3573

3574
        int count = 0;
6✔
3575
        try (RepositoryConnection spacesConn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3576
            // Append-only writes, same rationale as mirrorTrustState: all spaces writers
3577
            // serialise through this class's synchronized methods.
3578
            spacesConn.begin(IsolationLevels.READ_COMMITTED);
9✔
3579
            for (Statement[] row : pending) {
30✔
3580
                IRI rowIri = (IRI) row[0].getSubject();
18✔
3581
                // Re-running a cycle (or a watermark rewind) must not duplicate rows.
3582
                if (spacesConn.hasStatement(rowIri, RDF.TYPE, NPA_PENDING_ACCOUNT_STATE, false, graph)) {
39✔
3583
                    continue;
3✔
3584
                }
3585
                for (Statement st : row) {
48✔
3586
                    spacesConn.add(st);
15✔
3587
                }
3588
                count++;
3✔
3589
            }
3✔
3590
            spacesConn.commit();
6✔
3591
        }
3592
        return new PendingMirrorResult(count, maxLoadNumber);
18✔
3593
    }
3594

3595
    /**
3596
     * Mints the {@code npaa:} subject of a pending-account row. Keyed on the state
3597
     * graph (which encodes the trust-state hash and the build's load counter), so the
3598
     * IRI is stable within a build and different across builds — the same property
3599
     * {@code TrustStateLoader.accountStateHash} gives approved rows.
3600
     */
3601
    private static IRI pendingAccountIri(IRI graph, String pubkeyHash, IRI agent) {
3602
        String composite = graph.stringValue() + "|" + pubkeyHash + "|" + agent.stringValue() + "|pending";
21✔
3603
        return NPAA.forHash(Hashing.sha256().hashString(composite, StandardCharsets.UTF_8).toString());
21✔
3604
    }
3605

3606
    /**
3607
     * Runs {@link #mirrorPendingAccounts} and advances the watermark, or logs and
3608
     * carries on when the read fails.
3609
     *
3610
     * <p>Fail-soft is deliberate and is the opposite call from the trust mirror's:
3611
     * pending rows are purely additive and confer no authority, so losing a pass costs
3612
     * visibility for not-yet-approved users until the next cycle, whereas aborting the
3613
     * cycle would stall the whole space state over a display-only feature. The
3614
     * watermark is advanced only on success, so a failed pass is retried in full.
3615
     *
3616
     * @return number of rows written (0 if disabled or on read failure)
3617
     */
3618
    private int mirrorPendingAccountsSafely(IRI graph, boolean fromScratch) {
3619
        long fromLoadNumber = -1;
6✔
3620
        try {
3621
            if (!fromScratch) fromLoadNumber = readPendingScannedUpTo(graph);
18✔
3622
            PendingMirrorResult result = mirrorPendingAccounts(graph, fromLoadNumber);
15✔
3623
            if (result.scannedUpTo() != fromLoadNumber) {
15!
3624
                writePendingScannedUpTo(graph, result.scannedUpTo());
×
3625
            }
3626
            return result.rows();
9✔
3627
        } catch (Exception ex) {
3✔
3628
            logger.warn("AuthorityResolver: pending-account mirror failed on {} (watermark stays at {}); "
36✔
3629
                    + "not-yet-approved accounts stay invisible until the next cycle: {}",
3630
                    graph, fromLoadNumber, ex.getMessage(), ex);
33✔
3631
            return 0;
6✔
3632
        }
3633
    }
3634

3635
    // ---------------- Pointer + counter helpers ----------------
3636

3637
    /**
3638
     * Reads the current {@code npa:hasCurrentSpaceState} pointer from the
3639
     * {@code npa:graph} admin graph of the {@code spaces} repo. Returns
3640
     * {@code null} if no pointer exists yet.
3641
     */
3642
    IRI getCurrentSpaceStateGraph() {
3643
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3644
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18✔
3645
                    SpacesVocab.HAS_CURRENT_SPACE_STATE);
3646
            return (v instanceof IRI iri) ? iri : null;
36✔
3647
        } catch (Exception ex) {
3✔
3648
            throw new SpaceStateUnavailableException("failed to read hasCurrentSpaceState pointer", ex);
18✔
3649
        }
3650
    }
3651

3652
    long getCurrentLoadCounter() {
3653
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3654
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18✔
3655
                    SpacesVocab.CURRENT_LOAD_COUNTER);
3656
            if (v == null) return 0;
18✔
3657
            try {
3658
                return Long.parseLong(v.stringValue());
18✔
3659
            } catch (NumberFormatException ex) {
3✔
3660
                // Was "return 0", which would name the new graph <hash>_0 and make it
3661
                // differ from the real current graph — so the build proceeded and then
3662
                // dropped the good one. Corrupt bookkeeping must stop the build.
3663
                throw new SpaceStateUnavailableException("non-numeric currentLoadCounter: " + v, ex);
24✔
3664
            }
3665
        } catch (SpaceStateUnavailableException ex) {
15!
3666
            throw ex;
6✔
3667
        } catch (Exception ex) {
3✔
3668
            throw new SpaceStateUnavailableException("failed to read currentLoadCounter", ex);
18✔
3669
        }
3670
    }
3671

3672
    /**
3673
     * Atomic pointer flip: a single SPARQL {@code DELETE … INSERT … WHERE}
3674
     * replaces the old pointer with the new one in one statement, so readers
3675
     * never see a zero-pointer window.
3676
     */
3677
    void flipPointer(IRI newGraph) {
3678
        String update = String.format("""
135✔
3679
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3680
                INSERT { GRAPH <%s> { <%s> <%s> <%s> } }
3681
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3682
                """,
3683
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE,
3684
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE, newGraph,
3685
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.HAS_CURRENT_SPACE_STATE);
3686
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3687
            conn.begin(IsolationLevels.SNAPSHOT);
9✔
3688
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
15✔
3689
            conn.commit();
6✔
3690
        }
3691
    }
3✔
3692

3693
    void writeProcessedUpTo(IRI graph, long loadCounter) {
3694
        String update = String.format("""
96✔
3695
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3696
                INSERT { GRAPH <%s> { <%s> <%s> "%d"^^<http://www.w3.org/2001/XMLSchema#long> } }
3697
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3698
                """,
3699
                graph, graph, SpacesVocab.PROCESSED_UP_TO,
3700
                graph, graph, SpacesVocab.PROCESSED_UP_TO, loadCounter,
42✔
3701
                graph, graph, SpacesVocab.PROCESSED_UP_TO);
3702
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3703
            conn.begin(IsolationLevels.SNAPSHOT);
9✔
3704
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
15✔
3705
            conn.commit();
6✔
3706
        }
3707
    }
3✔
3708

3709
    /**
3710
     * Writes the pending-account watermark ({@link SpacesVocab#PENDING_SCANNED_UP_TO})
3711
     * into the given space-state graph. Same replace-in-place shape as
3712
     * {@link #writeProcessedUpTo}.
3713
     */
3714
    void writePendingScannedUpTo(IRI graph, long metaLoadNumber) {
3715
        String update = String.format("""
×
3716
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3717
                INSERT { GRAPH <%s> { <%s> <%s> "%d"^^<http://www.w3.org/2001/XMLSchema#long> } }
3718
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3719
                """,
3720
                graph, graph, SpacesVocab.PENDING_SCANNED_UP_TO,
3721
                graph, graph, SpacesVocab.PENDING_SCANNED_UP_TO, metaLoadNumber,
×
3722
                graph, graph, SpacesVocab.PENDING_SCANNED_UP_TO);
3723
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
×
3724
            conn.begin(IsolationLevels.SNAPSHOT);
×
3725
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
×
3726
            conn.commit();
×
3727
        }
3728
    }
×
3729

3730
    /**
3731
     * Reads the pending-account watermark from the given space-state graph.
3732
     * Returns {@code -1} when absent — which is both the never-scanned case and the
3733
     * upgrade case (a graph built by a version without this step), and correctly makes
3734
     * the next cycle scan every introduction.
3735
     */
3736
    long readPendingScannedUpTo(IRI graph) {
3737
        String query = String.format(
51✔
3738
                "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
3739
                graph, graph, SpacesVocab.PENDING_SCANNED_UP_TO);
3740
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO);
12✔
3741
             TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18✔
3742
            if (!r.hasNext()) return -1;
21!
3743
            return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
×
3744
        } catch (Exception ex) {
24!
3745
            // Unlike processedUpTo, -1 here is harmless (it re-scans), but a read that
3746
            // failed is still a read failure: let the caller's fail-soft wrapper log it
3747
            // rather than silently rescanning every cycle.
3748
            throw new SpaceStateUnavailableException("failed to read pendingScannedUpTo for " + graph, ex);
×
3749
        }
3750
    }
3751

3752
    /**
3753
     * Rewrites the {@link SpacesVocab#STATE_TRIPLE_COUNT} integrity stamp so it
3754
     * equals the graph's actual triple count (stamp triple included). Runs as a
3755
     * single transaction — delete old stamp, count, insert new stamp — so the
3756
     * stamp is either consistent with the content it was measured against or
3757
     * absent, never half-updated. Called after every mutation of a space-state
3758
     * graph; {@link #tick()} verifies it and rebuilds on mismatch.
3759
     */
3760
    void writeStateTripleCount(IRI graph) {
3761
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3762
            conn.begin(IsolationLevels.SNAPSHOT);
9✔
3763
            conn.prepareUpdate(QueryLanguage.SPARQL, String.format(
57✔
3764
                    "DELETE WHERE { GRAPH <%s> { <%s> <%s> ?old } }",
3765
                    graph, graph, SpacesVocab.STATE_TRIPLE_COUNT)).execute();
3✔
3766
            long withoutStamp;
3767
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, String.format(
33✔
3768
                    "SELECT (COUNT(*) AS ?n) WHERE { GRAPH <%s> { ?s ?p ?o } }", graph)).evaluate()) {
6✔
3769
                withoutStamp = Long.parseLong(r.next().getBinding("n").getValue().stringValue());
27✔
3770
            }
3771
            // +1 for the stamp triple itself, so a plain count of the graph matches the stamp.
3772
            conn.prepareUpdate(QueryLanguage.SPARQL, String.format(
72✔
3773
                    "INSERT DATA { GRAPH <%s> { <%s> <%s> \"%d\"^^<http://www.w3.org/2001/XMLSchema#long> } }",
3774
                    graph, graph, SpacesVocab.STATE_TRIPLE_COUNT, withoutStamp + 1)).execute();
9✔
3775
            conn.commit();
6✔
3776
        }
3777
    }
3✔
3778

3779
    /**
3780
     * Reads the {@link SpacesVocab#STATE_TRIPLE_COUNT} stamp from the given
3781
     * space-state graph. Returns {@code -1} if absent (graph published by a
3782
     * pre-stamp version; it becomes verifiable at its next mutation). Throws on
3783
     * read failure — the same absent-vs-error distinction as
3784
     * {@link #readProcessedUpTo}: a timed-out read must not look like damage.
3785
     */
3786
    long readStateTripleCount(IRI graph) {
3787
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3788
            String query = String.format(
51✔
3789
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
3790
                    graph, graph, SpacesVocab.STATE_TRIPLE_COUNT);
3791
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18✔
3792
                if (!r.hasNext()) return -1;
21✔
3793
                return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
33✔
3794
            }
12!
3795
        } catch (Exception ex) {
12!
3796
            throw new SpaceStateUnavailableException("failed to read stateTripleCount for " + graph, ex);
×
3797
        }
3798
    }
3799

3800
    /**
3801
     * Counts the triples in the given space-state graph. Throws on read failure
3802
     * rather than returning a sentinel, for the same reason as
3803
     * {@link #readStateTripleCount}.
3804
     */
3805
    long countStateGraphTriples(IRI graph) {
3806
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3807
            String query = String.format(
27✔
3808
                    "SELECT (COUNT(*) AS ?n) WHERE { GRAPH <%s> { ?s ?p ?o } }", graph);
3809
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18✔
3810
                return Long.parseLong(r.next().getBinding("n").getValue().stringValue());
33✔
3811
            }
3812
        } catch (Exception ex) {
×
3813
            throw new SpaceStateUnavailableException("failed to count triples of " + graph, ex);
×
3814
        }
3815
    }
3816

3817
    /**
3818
     * Reads {@code processedUpTo} from the given space-state graph.
3819
     * Returns {@code -1} if absent (graph not fully built yet).
3820
     */
3821
    long readProcessedUpTo(IRI graph) {
3822
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3823
            String query = String.format(
51✔
3824
                    "SELECT ?n WHERE { GRAPH <%s> { <%s> <%s> ?n } }",
3825
                    graph, graph, SpacesVocab.PROCESSED_UP_TO);
3826
            try (TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
18✔
3827
                if (!r.hasNext()) return -1;
21✔
3828
                BindingSet b = r.next();
12✔
3829
                return Long.parseLong(b.getBinding("n").getValue().stringValue());
27✔
3830
            }
12!
3831
        } catch (Exception ex) {
15!
3832
            // Must not collapse to -1: callers read -1 as "this graph was never
3833
            // finished" and rebuild from scratch. A timed-out read returning -1 would
3834
            // make a healthy state look damaged and trigger a destructive rebuild.
3835
            throw new SpaceStateUnavailableException("failed to read processedUpTo for " + graph, ex);
24✔
3836
        }
3837
    }
3838

3839
    /**
3840
     * Reads the {@code npa:needsFullRebuild} flag (boolean literal) from
3841
     * {@code npa:graph} in the {@code spaces} repo. Defaults to {@code false}
3842
     * when the triple is absent.
3843
     */
3844
    boolean readNeedsFullRebuild() {
3845
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3846
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18✔
3847
                    SpacesVocab.NEEDS_FULL_REBUILD);
3848
            return v != null && Boolean.parseBoolean(v.stringValue());
36✔
3849
        } catch (Exception ex) {
3✔
3850
            logger.warn("AuthorityResolver: failed to read needsFullRebuild: {}", ex.toString());
15✔
3851
            return false;
6✔
3852
        }
3853
    }
3854

3855
    void setNeedsFullRebuild() {
3856
        writeNeedsFullRebuild(true);
9✔
3857
    }
3✔
3858

3859
    void clearNeedsFullRebuild() {
3860
        writeNeedsFullRebuild(false);
9✔
3861
    }
3✔
3862

3863
    private void writeNeedsFullRebuild(boolean value) {
3864
        String update = String.format("""
96✔
3865
                DELETE { GRAPH <%s> { <%s> <%s> ?old } }
3866
                INSERT { GRAPH <%s> { <%s> <%s> "%s"^^<http://www.w3.org/2001/XMLSchema#boolean> } }
3867
                WHERE  { OPTIONAL { GRAPH <%s> { <%s> <%s> ?old } } }
3868
                """,
3869
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD,
3870
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD, value,
42✔
3871
                NPA.GRAPH, NPA.THIS_REPO, SpacesVocab.NEEDS_FULL_REBUILD);
3872
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3873
            conn.begin(IsolationLevels.SNAPSHOT);
9✔
3874
            conn.prepareUpdate(QueryLanguage.SPARQL, update).execute();
15✔
3875
            conn.commit();
6✔
3876
        }
3877
    }
3✔
3878

3879
    void dropGraph(IRI graph) {
3880
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(SPACES_REPO)) {
12✔
3881
            conn.begin(IsolationLevels.SNAPSHOT);
9✔
3882
            conn.clear(graph);
24✔
3883
            conn.commit();
6✔
3884
            logger.info("AuthorityResolver: dropped old space-state graph {}", graph);
12✔
3885
        }
3886
    }
3✔
3887

3888
    // ---------------- Trust-repo pointer lookup (used by TrustStateRegistry's bootstrap) ----------------
3889

3890
    /**
3891
     * Queries the {@code trust} repo directly for the current trust-state hash.
3892
     * Prefer {@link TrustStateRegistry#getCurrentHash()} in normal operation —
3893
     * this helper exists for tests and diagnostics.
3894
     *
3895
     * @return the current trust-state hash, or empty if none is set
3896
     */
3897
    Optional<String> readTrustRepoCurrentHash() {
3898
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
12✔
3899
            Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
18✔
3900
                    NPA_HAS_CURRENT_TRUST_STATE);
3901
            if (!(v instanceof IRI iri)) return Optional.empty();
33✔
3902
            String s = iri.stringValue();
9✔
3903
            if (!s.startsWith(NPAT.NAMESPACE)) return Optional.empty();
24✔
3904
            return Optional.of(s.substring(NPAT.NAMESPACE.length()));
24✔
3905
        } catch (Exception ex) {
27!
3906
            logger.warn("AuthorityResolver: failed to read trust-repo current pointer: {}", ex.toString());
15✔
3907
            return Optional.empty();
6✔
3908
        }
3909
    }
3910

3911
    private static String abbrev(String hash) {
3912
        return hash.length() > 12 ? hash.substring(0, 12) + "…" : hash;
33!
3913
    }
3914

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